gitea源码

issue_comment.go 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. // Copyright 2024 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package repo
  4. import (
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "net/http"
  9. "strconv"
  10. "strings"
  11. issues_model "code.gitea.io/gitea/models/issues"
  12. "code.gitea.io/gitea/models/renderhelper"
  13. user_model "code.gitea.io/gitea/models/user"
  14. "code.gitea.io/gitea/modules/git"
  15. "code.gitea.io/gitea/modules/gitrepo"
  16. "code.gitea.io/gitea/modules/htmlutil"
  17. "code.gitea.io/gitea/modules/log"
  18. "code.gitea.io/gitea/modules/markup/markdown"
  19. repo_module "code.gitea.io/gitea/modules/repository"
  20. "code.gitea.io/gitea/modules/setting"
  21. api "code.gitea.io/gitea/modules/structs"
  22. "code.gitea.io/gitea/modules/web"
  23. "code.gitea.io/gitea/services/context"
  24. "code.gitea.io/gitea/services/convert"
  25. "code.gitea.io/gitea/services/forms"
  26. issue_service "code.gitea.io/gitea/services/issue"
  27. pull_service "code.gitea.io/gitea/services/pull"
  28. )
  29. // NewComment create a comment for issue
  30. func NewComment(ctx *context.Context) {
  31. form := web.GetForm(ctx).(*forms.CreateCommentForm)
  32. issue := GetActionIssue(ctx)
  33. if ctx.Written() {
  34. return
  35. }
  36. if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) {
  37. if log.IsTrace() {
  38. if ctx.IsSigned {
  39. issueType := "issues"
  40. if issue.IsPull {
  41. issueType = "pulls"
  42. }
  43. log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+
  44. "User in Repo has Permissions: %-+v",
  45. ctx.Doer,
  46. issue.PosterID,
  47. issueType,
  48. ctx.Repo.Repository,
  49. ctx.Repo.Permission)
  50. } else {
  51. log.Trace("Permission Denied: Not logged in")
  52. }
  53. }
  54. ctx.HTTPError(http.StatusForbidden)
  55. return
  56. }
  57. if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin {
  58. ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked"))
  59. return
  60. }
  61. var attachments []string
  62. if setting.Attachment.Enabled {
  63. attachments = form.Files
  64. }
  65. if ctx.HasError() {
  66. ctx.JSONError(ctx.GetErrMsg())
  67. return
  68. }
  69. var comment *issues_model.Comment
  70. defer func() {
  71. // Check if issue admin/poster changes the status of issue.
  72. if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) &&
  73. (form.Status == "reopen" || form.Status == "close") &&
  74. !(issue.IsPull && issue.PullRequest.HasMerged) {
  75. // Duplication and conflict check should apply to reopen pull request.
  76. var pr *issues_model.PullRequest
  77. if form.Status == "reopen" && issue.IsPull {
  78. pull := issue.PullRequest
  79. var err error
  80. pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow)
  81. if err != nil {
  82. if !issues_model.IsErrPullRequestNotExist(err) {
  83. ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
  84. return
  85. }
  86. }
  87. // Regenerate patch and test conflict.
  88. if pr == nil {
  89. issue.PullRequest.HeadCommitID = ""
  90. pull_service.StartPullRequestCheckImmediately(ctx, issue.PullRequest)
  91. }
  92. // check whether the ref of PR <refs/pulls/pr_index/head> in base repo is consistent with the head commit of head branch in the head repo
  93. // get head commit of PR
  94. if pull.Flow == issues_model.PullRequestFlowGithub {
  95. prHeadRef := pull.GetGitHeadRefName()
  96. if err := pull.LoadBaseRepo(ctx); err != nil {
  97. ctx.ServerError("Unable to load base repo", err)
  98. return
  99. }
  100. prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef)
  101. if err != nil {
  102. ctx.ServerError("Get head commit Id of pr fail", err)
  103. return
  104. }
  105. // get head commit of branch in the head repo
  106. if err := pull.LoadHeadRepo(ctx); err != nil {
  107. ctx.ServerError("Unable to load head repo", err)
  108. return
  109. }
  110. if ok := gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.BaseBranch); !ok {
  111. // todo localize
  112. ctx.JSONError("The origin branch is delete, cannot reopen.")
  113. return
  114. }
  115. headBranchRef := pull.GetGitHeadBranchRefName()
  116. headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef)
  117. if err != nil {
  118. ctx.ServerError("Get head commit Id of head branch fail", err)
  119. return
  120. }
  121. err = pull.LoadIssue(ctx)
  122. if err != nil {
  123. ctx.ServerError("load the issue of pull request error", err)
  124. return
  125. }
  126. if prHeadCommitID != headBranchCommitID {
  127. // force push to base repo
  128. err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{
  129. Remote: pull.BaseRepo.RepoPath(),
  130. Branch: pull.HeadBranch + ":" + prHeadRef,
  131. Force: true,
  132. Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo),
  133. })
  134. if err != nil {
  135. ctx.ServerError("force push error", err)
  136. return
  137. }
  138. }
  139. }
  140. }
  141. if pr != nil {
  142. ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index))
  143. } else {
  144. if form.Status == "close" && !issue.IsClosed {
  145. if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
  146. log.Error("CloseIssue: %v", err)
  147. if issues_model.IsErrDependenciesLeft(err) {
  148. if issue.IsPull {
  149. ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
  150. } else {
  151. ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked"))
  152. }
  153. return
  154. }
  155. } else {
  156. if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil {
  157. ctx.ServerError("stopTimerIfAvailable", err)
  158. return
  159. }
  160. log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
  161. }
  162. } else if form.Status == "reopen" && issue.IsClosed {
  163. if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
  164. log.Error("ReopenIssue: %v", err)
  165. }
  166. }
  167. }
  168. }
  169. // Redirect to comment hashtag if there is any actual content.
  170. typeName := "issues"
  171. if issue.IsPull {
  172. typeName = "pulls"
  173. }
  174. if comment != nil {
  175. ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag()))
  176. } else {
  177. ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index))
  178. }
  179. }()
  180. // Fix #321: Allow empty comments, as long as we have attachments.
  181. if len(form.Content) == 0 && len(attachments) == 0 {
  182. return
  183. }
  184. comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments)
  185. if err != nil {
  186. if errors.Is(err, user_model.ErrBlockedUser) {
  187. ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user"))
  188. } else {
  189. ctx.ServerError("CreateIssueComment", err)
  190. }
  191. return
  192. }
  193. log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID)
  194. }
  195. // UpdateCommentContent change comment of issue's content
  196. func UpdateCommentContent(ctx *context.Context) {
  197. comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
  198. if err != nil {
  199. ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
  200. return
  201. }
  202. if err := comment.LoadIssue(ctx); err != nil {
  203. ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err)
  204. return
  205. }
  206. if comment.Issue.RepoID != ctx.Repo.Repository.ID {
  207. ctx.NotFound(issues_model.ErrCommentNotExist{})
  208. return
  209. }
  210. if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
  211. ctx.HTTPError(http.StatusForbidden)
  212. return
  213. }
  214. if !comment.Type.HasContentSupport() {
  215. ctx.HTTPError(http.StatusNoContent)
  216. return
  217. }
  218. newContent := ctx.FormString("content")
  219. contentVersion := ctx.FormInt("content_version")
  220. if contentVersion != comment.ContentVersion {
  221. ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed"))
  222. return
  223. }
  224. if newContent != comment.Content {
  225. // allow to save empty content
  226. oldContent := comment.Content
  227. comment.Content = newContent
  228. if err = issue_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil {
  229. if errors.Is(err, user_model.ErrBlockedUser) {
  230. ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user"))
  231. } else if errors.Is(err, issues_model.ErrCommentAlreadyChanged) {
  232. ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed"))
  233. } else {
  234. ctx.ServerError("UpdateComment", err)
  235. }
  236. return
  237. }
  238. }
  239. if err := comment.LoadAttachments(ctx); err != nil {
  240. ctx.ServerError("LoadAttachments", err)
  241. return
  242. }
  243. // when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates
  244. if !ctx.FormBool("ignore_attachments") {
  245. if err := updateAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil {
  246. ctx.ServerError("UpdateAttachments", err)
  247. return
  248. }
  249. }
  250. var renderedContent template.HTML
  251. if comment.Content != "" {
  252. rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
  253. FootnoteContextID: strconv.FormatInt(comment.ID, 10),
  254. })
  255. renderedContent, err = markdown.RenderString(rctx, comment.Content)
  256. if err != nil {
  257. ctx.ServerError("RenderString", err)
  258. return
  259. }
  260. }
  261. if strings.TrimSpace(string(renderedContent)) == "" {
  262. renderedContent = htmlutil.HTMLFormat(`<span class="no-content">%s</span>`, ctx.Tr("repo.issues.no_content"))
  263. }
  264. ctx.JSON(http.StatusOK, map[string]any{
  265. "content": renderedContent,
  266. "contentVersion": comment.ContentVersion,
  267. "attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content),
  268. })
  269. }
  270. // DeleteComment delete comment of issue
  271. func DeleteComment(ctx *context.Context) {
  272. comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
  273. if err != nil {
  274. ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
  275. return
  276. }
  277. if err := comment.LoadIssue(ctx); err != nil {
  278. ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err)
  279. return
  280. }
  281. if comment.Issue.RepoID != ctx.Repo.Repository.ID {
  282. ctx.NotFound(issues_model.ErrCommentNotExist{})
  283. return
  284. }
  285. if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
  286. ctx.HTTPError(http.StatusForbidden)
  287. return
  288. } else if !comment.Type.HasContentSupport() {
  289. ctx.HTTPError(http.StatusNoContent)
  290. return
  291. }
  292. if err = issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil {
  293. ctx.ServerError("DeleteComment", err)
  294. return
  295. }
  296. ctx.Status(http.StatusOK)
  297. }
  298. // ChangeCommentReaction create a reaction for comment
  299. func ChangeCommentReaction(ctx *context.Context) {
  300. form := web.GetForm(ctx).(*forms.ReactionForm)
  301. comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
  302. if err != nil {
  303. ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
  304. return
  305. }
  306. if err := comment.LoadIssue(ctx); err != nil {
  307. ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err)
  308. return
  309. }
  310. if comment.Issue.RepoID != ctx.Repo.Repository.ID {
  311. ctx.NotFound(issues_model.ErrCommentNotExist{})
  312. return
  313. }
  314. if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull)) {
  315. if log.IsTrace() {
  316. if ctx.IsSigned {
  317. issueType := "issues"
  318. if comment.Issue.IsPull {
  319. issueType = "pulls"
  320. }
  321. log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+
  322. "User in Repo has Permissions: %-+v",
  323. ctx.Doer,
  324. comment.Issue.PosterID,
  325. issueType,
  326. ctx.Repo.Repository,
  327. ctx.Repo.Permission)
  328. } else {
  329. log.Trace("Permission Denied: Not logged in")
  330. }
  331. }
  332. ctx.HTTPError(http.StatusForbidden)
  333. return
  334. }
  335. if !comment.Type.HasContentSupport() {
  336. ctx.HTTPError(http.StatusNoContent)
  337. return
  338. }
  339. switch ctx.PathParam("action") {
  340. case "react":
  341. reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content)
  342. if err != nil {
  343. if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) {
  344. ctx.ServerError("ChangeIssueReaction", err)
  345. return
  346. }
  347. log.Info("CreateCommentReaction: %s", err)
  348. break
  349. }
  350. // Reload new reactions
  351. comment.Reactions = nil
  352. if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil {
  353. log.Info("comment.LoadReactions: %s", err)
  354. break
  355. }
  356. log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID, reaction.ID)
  357. case "unreact":
  358. if err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content); err != nil {
  359. ctx.ServerError("DeleteCommentReaction", err)
  360. return
  361. }
  362. // Reload new reactions
  363. comment.Reactions = nil
  364. if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil {
  365. log.Info("comment.LoadReactions: %s", err)
  366. break
  367. }
  368. log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID)
  369. default:
  370. ctx.NotFound(nil)
  371. return
  372. }
  373. if len(comment.Reactions) == 0 {
  374. ctx.JSON(http.StatusOK, map[string]any{
  375. "empty": true,
  376. "html": "",
  377. })
  378. return
  379. }
  380. html, err := ctx.RenderToHTML(tplReactions, map[string]any{
  381. "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
  382. "Reactions": comment.Reactions.GroupByType(),
  383. })
  384. if err != nil {
  385. ctx.ServerError("ChangeCommentReaction.HTMLString", err)
  386. return
  387. }
  388. ctx.JSON(http.StatusOK, map[string]any{
  389. "html": html,
  390. })
  391. }
  392. // GetCommentAttachments returns attachments for the comment
  393. func GetCommentAttachments(ctx *context.Context) {
  394. comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
  395. if err != nil {
  396. ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
  397. return
  398. }
  399. if err := comment.LoadIssue(ctx); err != nil {
  400. ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err)
  401. return
  402. }
  403. if comment.Issue.RepoID != ctx.Repo.Repository.ID {
  404. ctx.NotFound(issues_model.ErrCommentNotExist{})
  405. return
  406. }
  407. if !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull) {
  408. ctx.NotFound(issues_model.ErrCommentNotExist{})
  409. return
  410. }
  411. if !comment.Type.HasAttachmentSupport() {
  412. ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type))
  413. return
  414. }
  415. attachments := make([]*api.Attachment, 0)
  416. if err := comment.LoadAttachments(ctx); err != nil {
  417. ctx.ServerError("LoadAttachments", err)
  418. return
  419. }
  420. for i := 0; i < len(comment.Attachments); i++ {
  421. attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i]))
  422. }
  423. ctx.JSON(http.StatusOK, attachments)
  424. }