gitea/routers/web/repo/conversation.go
2024-11-04 17:10:50 +08:00

1005 lines
31 KiB
Go

// Copyright 2024 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"html/template"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
activities_model "code.gitea.io/gitea/models/activities"
conversations_model "code.gitea.io/gitea/models/conversations"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
conversation_indexer "code.gitea.io/gitea/modules/indexer/conversations"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
conversation_service "code.gitea.io/gitea/services/conversation"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/forms"
user_service "code.gitea.io/gitea/services/user"
)
const (
tplConversations base.TplName = "repo/conversation/list"
tplConversationNew base.TplName = "repo/conversation/new"
tplConversationChoose base.TplName = "repo/conversation/choose"
tplConversationView base.TplName = "repo/conversation/view"
)
// MustAllowUserComment checks to make sure if an conversation is locked.
// If locked and user has permissions to write to the repository,
// then the comment is allowed, else it is blocked
func ConversationMustAllowUserComment(ctx *context.Context) {
conversation := GetActionConversation(ctx)
if ctx.Written() {
return
}
if conversation.IsLocked && !ctx.Doer.IsAdmin {
ctx.Flash.Error(ctx.Tr("repo.conversations.comment_on_locked"))
ctx.Redirect(conversation.Link())
return
}
}
// MustEnableConversations check if repository enable internal conversations
func MustEnableConversations(ctx *context.Context) {
if !ctx.Repo.CanRead(unit.TypeConversations) &&
!ctx.Repo.CanRead(unit.TypeExternalTracker) {
ctx.NotFound("MustEnableConversations", nil)
return
}
unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
if err == nil {
ctx.Redirect(unit.ExternalTrackerConfig().ExternalTrackerURL)
return
}
}
// MustAllowPulls check if repository enable pull requests and user have right to do that
func ConversationMustAllowPulls(ctx *context.Context) {
if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) {
ctx.NotFound("MustAllowPulls", nil)
return
}
// User can send pull request if owns a forked repository.
if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) {
ctx.Repo.PullRequest.Allowed = true
ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Doer.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName)
}
}
// Conversations render conversations page
func Conversations(ctx *context.Context) {
if ctx.Written() {
return
}
ctx.Data["CanWriteConversations"] = ctx.Repo.CanWriteConversations()
ctx.HTML(http.StatusOK, tplConversations)
}
// NewConversation render creating conversation page
func NewConversation(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.conversations.new")
ctx.Data["PageIsConversationList"] = true
ctx.Data["NewConversationChooseTemplate"] = false
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
title := ctx.FormString("title")
ctx.Data["TitleQuery"] = title
body := ctx.FormString("body")
ctx.Data["BodyQuery"] = body
isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects)
ctx.Data["IsProjectsEnabled"] = isProjectsEnabled
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
projectID := ctx.FormInt64("project")
if projectID > 0 && isProjectsEnabled {
project, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
log.Error("GetProjectByID: %d: %v", projectID, err)
} else if project.RepoID != ctx.Repo.Repository.ID {
log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID))
} else {
ctx.Data["project_id"] = projectID
ctx.Data["Project"] = project
}
if len(ctx.Req.URL.Query().Get("project")) > 0 {
ctx.Data["redirect_after_creation"] = "project"
}
}
RetrieveRepoMetas(ctx, ctx.Repo.Repository, false)
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = tags
ctx.Data["HasConversationsWritePermission"] = ctx.Repo.CanWrite(unit.TypeConversations)
ctx.HTML(http.StatusOK, tplConversationNew)
}
// DeleteConversation deletes an conversation
func DeleteConversation(ctx *context.Context) {
conversation := GetActionConversation(ctx)
if ctx.Written() {
return
}
if err := conversation_service.DeleteConversation(ctx, ctx.Doer, ctx.Repo.GitRepo, conversation); err != nil {
ctx.ServerError("DeleteConversationByID", err)
return
}
ctx.Redirect(fmt.Sprintf("%s/conversations", ctx.Repo.Repository.Link()), http.StatusSeeOther)
}
// ViewConversation render conversation view page
func ViewConversation(ctx *context.Context) {
if ctx.PathParam(":type") == "conversations" {
// If conversation was requested we check if repo has external tracker and redirect
extConversationUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
if err == nil && extConversationUnit != nil {
if extConversationUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extConversationUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" {
metas := ctx.Repo.Repository.ComposeMetas(ctx)
metas["index"] = ctx.PathParam(":index")
res, err := vars.Expand(extConversationUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas)
if err != nil {
log.Error("unable to expand template vars for conversation url. conversation: %s, err: %v", metas["index"], err)
ctx.ServerError("Expand", err)
return
}
ctx.Redirect(res)
return
}
} else if err != nil && !repo_model.IsErrUnitTypeNotExist(err) {
ctx.ServerError("GetUnit", err)
return
}
}
conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index"))
if err != nil {
if conversations_model.IsErrConversationNotExist(err) {
ctx.NotFound("GetConversationByIndex", err)
} else {
ctx.ServerError("GetConversationByIndex", err)
}
return
}
if conversation.Repo == nil {
conversation.Repo = ctx.Repo.Repository
}
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
if err = conversation.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
repo := ctx.Repo.Repository
if ctx.IsSigned {
// Update conversation-user.
if err = activities_model.SetConversationReadBy(ctx, conversation.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("ReadBy", err)
return
}
}
var (
role conversations_model.RoleDescriptor
ok bool
marked = make(map[int64]conversations_model.RoleDescriptor)
comment *conversations_model.ConversationComment
participants = make([]*user_model.User, 1, 10)
latestCloseCommentID int64
)
// Check if the user can use the dependencies
// ctx.Data["CanCreateConversationDependencies"] = ctx.Repo.CanCreateConversationDependencies(ctx, ctx.Doer, conversation.IsPull)
// check if dependencies can be created across repositories
ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies
if err := conversation.Comments.LoadAttachmentsByConversation(ctx); err != nil {
ctx.ServerError("LoadAttachmentsByConversation", err)
return
}
if err := conversation.Comments.LoadPosters(ctx); err != nil {
ctx.ServerError("LoadPosters", err)
return
}
for _, comment = range conversation.Comments {
comment.Conversation = conversation
if comment.Type == conversations_model.CommentTypeComment {
comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
Links: markup.Links{
Base: ctx.Repo.RepoLink,
},
Metas: ctx.Repo.Repository.ComposeMetas(ctx),
GitRepo: ctx.Repo.GitRepo,
Repo: ctx.Repo.Repository,
Ctx: ctx,
}, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
// Check tag.
role, ok = marked[comment.PosterID]
if ok {
comment.ShowRole = role
continue
}
comment.ShowRole, err = conversationRoleDescriptor(ctx, repo, comment.Poster, comment.HasOriginalAuthor())
if err != nil {
ctx.ServerError("roleDescriptor", err)
return
}
marked[comment.PosterID] = comment.ShowRole
participants = addParticipant(comment.Poster, participants)
}
}
ctx.Data["LatestCloseCommentID"] = latestCloseCommentID
ctx.Data["Participants"] = participants
ctx.Data["NumParticipants"] = len(participants)
ctx.Data["Conversation"] = conversation
ctx.Data["IsConversation"] = true
ctx.Data["Comments"] = conversation.Comments
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string))
ctx.Data["HasConversationsOrPullsWritePermission"] = ctx.Repo.CanWriteConversations()
ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(unit.TypeProjects)
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
ctx.Data["LockReasons"] = setting.Repository.Conversation.LockReasons
var hiddenCommentTypes *big.Int
if ctx.IsSigned {
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {
ctx.ServerError("GetUserSetting", err)
return
}
hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
}
ctx.Data["ShouldShowCommentType"] = func(commentType conversations_model.CommentType) bool {
return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0
}
// For sidebar
PrepareBranchList(ctx)
if ctx.Written() {
return
}
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = tags
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
ctx.HTML(http.StatusOK, tplConversationView)
}
// GetActionConversation will return the conversation which is used in the context.
func GetActionConversation(ctx *context.Context) *conversations_model.Conversation {
conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index"))
if err != nil {
ctx.NotFoundOrServerError("GetConversationByIndex", conversations_model.IsErrConversationNotExist, err)
return nil
}
conversation.Repo = ctx.Repo.Repository
checkConversationRights(ctx)
if ctx.Written() {
return nil
}
if err = conversation.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return nil
}
return conversation
}
func checkConversationRights(ctx *context.Context) {
if !ctx.Repo.CanRead(unit.TypeConversations) {
ctx.NotFound("ConversationUnitNotAllowed", nil)
}
}
func getActionConversations(ctx *context.Context) conversations_model.ConversationList {
commaSeparatedConversationIDs := ctx.FormString("conversation_ids")
if len(commaSeparatedConversationIDs) == 0 {
return nil
}
conversationIDs := make([]int64, 0, 10)
for _, stringConversationID := range strings.Split(commaSeparatedConversationIDs, ",") {
conversationID, err := strconv.ParseInt(stringConversationID, 10, 64)
if err != nil {
ctx.ServerError("ParseInt", err)
return nil
}
conversationIDs = append(conversationIDs, conversationID)
}
conversations, err := conversations_model.GetConversationsByIDs(ctx, conversationIDs)
if err != nil {
ctx.ServerError("GetConversationsByIDs", err)
return nil
}
// Check access rights for all conversations
conversationUnitEnabled := ctx.Repo.CanRead(unit.TypeConversations)
for _, conversation := range conversations {
if conversation.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("some conversation's RepoID is incorrect", errors.New("some conversation's RepoID is incorrect"))
return nil
}
if !conversationUnitEnabled {
ctx.NotFound("ConversationUnitNotAllowed", nil)
return nil
}
if err = conversation.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return nil
}
}
return conversations
}
// GetConversationInfo get an conversation of a repository
func GetConversationInfo(ctx *context.Context) {
conversation, err := conversations_model.GetConversationWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index"))
if err != nil {
if conversations_model.IsErrConversationNotExist(err) {
ctx.Error(http.StatusNotFound)
} else {
ctx.Error(http.StatusInternalServerError, "GetConversationByIndex", err.Error())
}
return
}
// Need to check if Conversations are enabled and we can read Conversations
if !ctx.Repo.CanRead(unit.TypeConversations) {
ctx.Error(http.StatusNotFound)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"convertedConversation": convert.ToConversation(ctx, conversation),
})
}
// SearchConversations searches for conversations across the repositories that the user has access to
func SearchConversations(ctx *context.Context) {
before, since, err := context.GetQueryBeforeSince(ctx.Base)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, err.Error())
return
}
repoIDs, allPublic := GetUserAccessibleRepo(ctx)
keyword := ctx.FormTrim("q")
if strings.IndexByte(keyword, 0) >= 0 {
keyword = ""
}
// this api is also used in UI,
// so the default limit is set to fit UI needs
limit := ctx.FormInt("limit")
if limit == 0 {
limit = setting.UI.ConversationPagingNum
} else if limit > setting.API.MaxResponseItems {
limit = setting.API.MaxResponseItems
}
searchOpt := &conversation_indexer.SearchOptions{
Paginator: &db.ListOptions{
Page: ctx.FormInt("page"),
PageSize: limit,
},
Keyword: keyword,
RepoIDs: repoIDs,
AllPublic: allPublic,
SortBy: conversation_indexer.SortByCreatedDesc,
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
}
if before != 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(before)
}
// FIXME: It's unsupported to sort by priority repo when searching by indexer,
// it's indeed an regression, but I think it is worth to support filtering by indexer first.
_ = ctx.FormInt64("priority_repo_id")
ids, total, err := conversation_indexer.SearchConversations(ctx, searchOpt)
if err != nil {
ctx.Error(http.StatusInternalServerError, "SearchConversations", err.Error())
return
}
conversations, err := conversations_model.GetConversationsByIDs(ctx, ids, true)
if err != nil {
ctx.Error(http.StatusInternalServerError, "FindConversationsByIDs", err.Error())
return
}
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToConversationList(ctx, ctx.Doer, conversations))
}
func GetUserAccessibleRepo(ctx *context.Context) ([]int64, bool) {
var (
repoIDs []int64
allPublic bool
)
// find repos user can access (for conversation search)
opts := &repo_model.SearchRepoOptions{
Private: false,
AllPublic: true,
TopicOnly: false,
Collaborate: optional.None[bool](),
// This needs to be a column that is not nil in fixtures or
// MySQL will return different results when sorting by null in some cases
OrderBy: db.SearchOrderByAlphabetically,
Actor: ctx.Doer,
}
if ctx.IsSigned {
opts.Private = true
opts.AllLimited = true
}
if ctx.FormString("owner") != "" {
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Error(http.StatusBadRequest, "Owner not found", err.Error())
} else {
ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error())
}
return nil, false
}
opts.OwnerID = owner.ID
opts.AllLimited = false
opts.AllPublic = false
opts.Collaborate = optional.Some(false)
}
if ctx.FormString("team") != "" {
if ctx.FormString("owner") == "" {
ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team")
return nil, false
}
team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team"))
if err != nil {
if organization.IsErrTeamNotExist(err) {
ctx.Error(http.StatusBadRequest, "Team not found", err.Error())
} else {
ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error())
}
return nil, false
}
opts.TeamID = team.ID
}
if opts.AllPublic {
allPublic = true
opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer
}
repoIDs, _, err := repo_model.SearchRepositoryIDs(ctx, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error())
return nil, false
}
if len(repoIDs) == 0 {
// no repos found, don't let the indexer return all repos
repoIDs = []int64{0}
}
return repoIDs, allPublic
}
// ListConversations list the conversations of a repository
func ListConversations(ctx *context.Context) {
before, since, err := context.GetQueryBeforeSince(ctx.Base)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, err.Error())
return
}
keyword := ctx.FormTrim("q")
if strings.IndexByte(keyword, 0) >= 0 {
keyword = ""
}
searchOpt := &conversation_indexer.SearchOptions{
Paginator: &db.ListOptions{
Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID},
SortBy: conversation_indexer.SortByCreatedDesc,
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
}
if before != 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(before)
}
ids, total, err := conversation_indexer.SearchConversations(ctx, searchOpt)
if err != nil {
ctx.Error(http.StatusInternalServerError, "SearchConversations", err.Error())
return
}
conversations, err := conversations_model.GetConversationsByIDs(ctx, ids, true)
if err != nil {
ctx.Error(http.StatusInternalServerError, "FindConversationsByIDs", err.Error())
return
}
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToConversationList(ctx, ctx.Doer, conversations))
}
func BatchDeleteConversations(ctx *context.Context) {
conversations := getActionConversations(ctx)
if ctx.Written() {
return
}
for _, conversation := range conversations {
if err := conversation_service.DeleteConversation(ctx, ctx.Doer, ctx.Repo.GitRepo, conversation); err != nil {
ctx.ServerError("DeleteConversation", err)
return
}
}
ctx.JSONOK()
}
// NewComment create a comment for conversation
func NewConversationComment(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateConversationCommentForm)
conversation := GetActionConversation(ctx)
if ctx.Written() {
return
}
if !ctx.IsSigned || (!ctx.Repo.CanReadConversations()) {
if log.IsTrace() {
if ctx.IsSigned {
conversationType := "conversations"
log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+
"User in Repo has Permissions: %-+v",
ctx.Doer,
conversationType,
ctx.Repo.Repository,
ctx.Repo.Permission)
} else {
log.Trace("Permission Denied: Not logged in")
}
}
ctx.Error(http.StatusForbidden)
return
}
if conversation.IsLocked && !ctx.Repo.CanWriteConversations() && !ctx.Doer.IsAdmin {
ctx.JSONError(ctx.Tr("repo.conversations.comment_on_locked"))
return
}
var attachments []string
if setting.Attachment.Enabled {
attachments = form.Files
}
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
var comment *conversations_model.ConversationComment
defer func() {
// Redirect to comment hashtag if there is any actual content.
typeName := "commit"
if comment != nil {
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag()))
} else {
ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha))
}
}()
// Fix #321: Allow empty comments, as long as we have attachments.
if len(form.Content) == 0 && len(attachments) == 0 {
return
}
comment, err := conversation_service.CreateConversationComment(ctx, ctx.Doer, ctx.Repo.Repository, conversation, form.Content, attachments)
if err != nil {
if errors.Is(err, user_model.ErrBlockedUser) {
ctx.JSONError(ctx.Tr("repo.conversations.comment.blocked_user"))
} else {
ctx.ServerError("CreateConversationComment", err)
}
return
}
log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, conversation.ID, comment.ID)
}
// UpdateCommentContent change comment of conversation's content
func UpdateConversationCommentContent(ctx *context.Context) {
comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id"))
if err != nil {
ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err)
return
}
if err := comment.LoadConversation(ctx); err != nil {
ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err)
return
}
if comment.Conversation.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{})
return
}
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteConversations()) {
ctx.Error(http.StatusForbidden)
return
}
if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent)
return
}
oldContent := comment.Content
newContent := ctx.FormString("content")
contentVersion := ctx.FormInt("content_version")
// allow to save empty content
comment.Content = newContent
if err = conversation_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil {
if errors.Is(err, user_model.ErrBlockedUser) {
ctx.JSONError(ctx.Tr("repo.conversations.comment.blocked_user"))
} else if errors.Is(err, conversations_model.ErrCommentAlreadyChanged) {
ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed"))
} else {
ctx.ServerError("UpdateComment", err)
}
return
}
if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
// when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates
if !ctx.FormBool("ignore_attachments") {
if err := updateConversationAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil {
ctx.ServerError("UpdateAttachments", err)
return
}
}
var renderedContent template.HTML
if comment.Content != "" {
renderedContent, err = markdown.RenderString(&markup.RenderContext{
Links: markup.Links{
Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ?
},
Metas: ctx.Repo.Repository.ComposeMetas(ctx),
GitRepo: ctx.Repo.GitRepo,
Repo: ctx.Repo.Repository,
Ctx: ctx,
}, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
} else {
contentEmpty := fmt.Sprintf(`<span class="no-content">%s</span>`, ctx.Tr("repo.conversations.no_content"))
renderedContent = template.HTML(contentEmpty)
}
ctx.JSON(http.StatusOK, map[string]any{
"content": renderedContent,
"contentVersion": comment.ContentVersion,
"attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content),
})
}
// DeleteComment delete comment of conversation
func DeleteConversationComment(ctx *context.Context) {
comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id"))
if err != nil {
ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err)
return
}
if err := comment.LoadConversation(ctx); err != nil {
ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err)
return
}
if comment.Conversation.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{})
return
}
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteConversations()) {
ctx.Error(http.StatusForbidden)
return
} else if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent)
return
}
if err = conversation_service.DeleteComment(ctx, ctx.Doer, comment); err != nil {
ctx.ServerError("DeleteComment", err)
return
}
ctx.Status(http.StatusOK)
}
// ChangeCommentReaction create a reaction for comment
func ChangeConversationCommentReaction(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.ReactionForm)
comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id"))
if err != nil {
ctx.NotFoundOrServerError("GetConversationCommentByID", conversations_model.IsErrCommentNotExist, err)
return
}
if err := comment.LoadConversation(ctx); err != nil {
ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err)
return
}
if comment.Conversation.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{})
return
}
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadConversations()) {
if log.IsTrace() {
if ctx.IsSigned {
conversationType := "conversations"
log.Trace("Permission Denied: User %-v cannot read %s in Repo %-v.\n"+
"User in Repo has Permissions: %-+v",
ctx.Doer,
conversationType,
ctx.Repo.Repository,
ctx.Repo.Permission)
} else {
log.Trace("Permission Denied: Not logged in")
}
}
ctx.Error(http.StatusForbidden)
return
}
if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent)
return
}
switch ctx.PathParam(":action") {
case "react":
if err = AddReaction(ctx, form, comment, nil); err != nil {
break
}
case "unreact":
if err = RemoveReaction(ctx, form, comment, nil); err != nil {
break
}
default:
ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil)
return
}
if len(comment.Reactions) == 0 {
ctx.JSON(http.StatusOK, map[string]any{
"empty": true,
"html": "",
})
return
}
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
"ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
"Reactions": comment.Reactions.GroupByType(),
})
if err != nil {
ctx.ServerError("ChangeCommentReaction.HTMLString", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"html": html,
})
}
// GetCommentAttachments returns attachments for the comment
func GetConversationCommentAttachments(ctx *context.Context) {
comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id"))
if err != nil {
ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err)
return
}
if err := comment.LoadConversation(ctx); err != nil {
ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err)
return
}
if comment.Conversation.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{})
return
}
if !ctx.Repo.Permission.CanReadConversations() {
ctx.NotFound("CanReadConversationsOrPulls", conversations_model.ErrCommentNotExist{})
return
}
if !comment.Type.HasAttachmentSupport() {
ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type))
return
}
attachments := make([]*api.Attachment, 0)
if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err)
return
}
for i := 0; i < len(comment.Attachments); i++ {
attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i]))
}
ctx.JSON(http.StatusOK, attachments)
}
func updateConversationAttachments(ctx *context.Context, item any, files []string) error {
var attachments []*repo_model.Attachment
switch content := item.(type) {
case *conversations_model.ConversationComment:
attachments = content.Attachments
default:
return fmt.Errorf("unknown Type: %T", content)
}
for i := 0; i < len(attachments); i++ {
if util.SliceContainsString(files, attachments[i].UUID) {
continue
}
if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil {
return err
}
}
var err error
if len(files) > 0 {
switch content := item.(type) {
case *conversations_model.Conversation:
err = conversations_model.UpdateConversationAttachments(ctx, content.ID, files)
case *conversations_model.ConversationComment:
err = content.UpdateAttachments(ctx, files)
default:
return fmt.Errorf("unknown Type: %T", content)
}
if err != nil {
return err
}
}
switch content := item.(type) {
case *conversations_model.ConversationComment:
content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID)
default:
return fmt.Errorf("unknown Type: %T", content)
}
return err
}
// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and conversation
func conversationRoleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, hasOriginalAuthor bool) (conversations_model.RoleDescriptor, error) {
roleDescriptor := conversations_model.RoleDescriptor{}
if hasOriginalAuthor {
return roleDescriptor, nil
}
perm, err := access_model.GetUserRepoPermission(ctx, repo, poster)
if err != nil {
return roleDescriptor, err
}
// If the poster is the actual poster of the conversation, enable Poster role.
roleDescriptor.IsPoster = false
// Check if the poster is owner of the repo.
if perm.IsOwner() {
// If the poster isn't an admin, enable the owner role.
if !poster.IsAdmin {
roleDescriptor.RoleInRepo = conversations_model.RoleRepoOwner
return roleDescriptor, nil
}
// Otherwise check if poster is the real repo admin.
ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
if err != nil {
return roleDescriptor, err
}
if ok {
roleDescriptor.RoleInRepo = conversations_model.RoleRepoOwner
return roleDescriptor, nil
}
}
// If repo is organization, check Member role
if err := repo.LoadOwner(ctx); err != nil {
return roleDescriptor, err
}
if repo.Owner.IsOrganization() {
if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
return roleDescriptor, err
} else if isMember {
roleDescriptor.RoleInRepo = conversations_model.RoleRepoMember
return roleDescriptor, nil
}
}
// If the poster is the collaborator of the repo
if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
return roleDescriptor, err
} else if isCollaborator {
roleDescriptor.RoleInRepo = conversations_model.RoleRepoCollaborator
return roleDescriptor, nil
}
return roleDescriptor, nil
}