summaryrefslogtreecommitdiffstats
path: root/routers/web/repo/issue_content_history.go
blob: 16b250abdab84ceccf8bf306afd7cd0dfd9b7399 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
	"bytes"
	"html"
	"net/http"
	"strings"

	"code.gitea.io/gitea/models/avatars"
	issues_model "code.gitea.io/gitea/models/issues"
	"code.gitea.io/gitea/modules/log"
	"code.gitea.io/gitea/modules/setting"
	"code.gitea.io/gitea/modules/templates"
	"code.gitea.io/gitea/modules/timeutil"
	"code.gitea.io/gitea/services/context"

	"github.com/sergi/go-diff/diffmatchpatch"
)

// GetContentHistoryOverview get overview
func GetContentHistoryOverview(ctx *context.Context) {
	issue := GetActionIssue(ctx)
	if ctx.Written() {
		return
	}

	editedHistoryCountMap, _ := issues_model.QueryIssueContentHistoryEditedCountMap(ctx, issue.ID)
	ctx.JSON(http.StatusOK, map[string]any{
		"i18n": map[string]any{
			"textEdited":                   ctx.Tr("repo.issues.content_history.edited"),
			"textDeleteFromHistory":        ctx.Tr("repo.issues.content_history.delete_from_history"),
			"textDeleteFromHistoryConfirm": ctx.Tr("repo.issues.content_history.delete_from_history_confirm"),
			"textOptions":                  ctx.Tr("repo.issues.content_history.options"),
		},
		"editedHistoryCountMap": editedHistoryCountMap,
	})
}

// GetContentHistoryList  get list
func GetContentHistoryList(ctx *context.Context) {
	issue := GetActionIssue(ctx)
	if ctx.Written() {
		return
	}

	commentID := ctx.FormInt64("comment_id")
	items, _ := issues_model.FetchIssueContentHistoryList(ctx, issue.ID, commentID)

	// render history list to HTML for frontend dropdown items: (name, value)
	// name is HTML of "avatar + userName + userAction + timeSince"
	// value is historyId
	var results []map[string]any
	for _, item := range items {
		var actionText string
		if item.IsDeleted {
			actionTextDeleted := ctx.Locale.TrString("repo.issues.content_history.deleted")
			actionText = "<i data-history-is-deleted='1'>" + actionTextDeleted + "</i>"
		} else if item.IsFirstCreated {
			actionText = ctx.Locale.TrString("repo.issues.content_history.created")
		} else {
			actionText = ctx.Locale.TrString("repo.issues.content_history.edited")
		}

		username := item.UserName
		if setting.UI.DefaultShowFullName && strings.TrimSpace(item.UserFullName) != "" {
			username = strings.TrimSpace(item.UserFullName)
		}

		src := html.EscapeString(item.UserAvatarLink)
		class := avatars.DefaultAvatarClass + " tw-mr-2"
		name := html.EscapeString(username)
		avatarHTML := string(templates.AvatarHTML(src, 28, class, username))
		timeSinceText := string(timeutil.TimeSinceUnix(item.EditedUnix, ctx.Locale))

		results = append(results, map[string]any{
			"name":  avatarHTML + "<strong>" + name + "</strong> " + actionText + " " + timeSinceText,
			"value": item.HistoryID,
		})
	}

	ctx.JSON(http.StatusOK, map[string]any{
		"results": results,
	})
}

// canSoftDeleteContentHistory checks whether current user can soft-delete a history revision
// Admins or owners can always delete history revisions. Normal users can only delete own history revisions.
func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue, comment *issues_model.Comment,
	history *issues_model.ContentHistory,
) (canSoftDelete bool) {
	// CanWrite means the doer can manage the issue/PR list
	if ctx.Repo.IsOwner() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
		canSoftDelete = true
	} else if ctx.Doer == nil {
		canSoftDelete = false
	} else {
		// for read-only users, they could still post issues or comments,
		// they should be able to delete the history related to their own issue/comment, a case is:
		// 1. the user posts some sensitive data
		// 2. then the repo owner edits the post but didn't remove the sensitive data
		// 3. the poster wants to delete the edited history revision
		if comment == nil {
			// the issue poster or the history poster can soft-delete
			canSoftDelete = ctx.Doer.ID == issue.PosterID || ctx.Doer.ID == history.PosterID
			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID)
		} else {
			// the comment poster or the history poster can soft-delete
			canSoftDelete = ctx.Doer.ID == comment.PosterID || ctx.Doer.ID == history.PosterID
			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID)
			canSoftDelete = canSoftDelete && (history.CommentID == comment.ID)
		}
	}
	return canSoftDelete
}

// GetContentHistoryDetail get detail
func GetContentHistoryDetail(ctx *context.Context) {
	issue := GetActionIssue(ctx)
	if ctx.Written() {
		return
	}

	historyID := ctx.FormInt64("history_id")
	history, prevHistory, err := issues_model.GetIssueContentHistoryAndPrev(ctx, issue.ID, historyID)
	if err != nil {
		ctx.JSON(http.StatusNotFound, map[string]any{
			"message": "Can not find the content history",
		})
		return
	}

	// get the related comment if this history revision is for a comment, otherwise the history revision is for an issue.
	var comment *issues_model.Comment
	if history.CommentID != 0 {
		var err error
		if comment, err = issues_model.GetCommentByID(ctx, history.CommentID); err != nil {
			log.Error("can not get comment for issue content history %v. err=%v", historyID, err)
			return
		}
	}

	// get the previous history revision (if exists)
	var prevHistoryID int64
	var prevHistoryContentText string
	if prevHistory != nil {
		prevHistoryID = prevHistory.ID
		prevHistoryContentText = prevHistory.ContentText
	}

	// compare the current history revision with the previous one
	dmp := diffmatchpatch.New()
	// `checklines=false` makes better diff result
	diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false)
	diff = dmp.DiffCleanupSemantic(diff)
	diff = dmp.DiffCleanupEfficiency(diff)

	// use chroma to render the diff html
	diffHTMLBuf := bytes.Buffer{}
	diffHTMLBuf.WriteString("<pre class='chroma'>")
	for _, it := range diff {
		if it.Type == diffmatchpatch.DiffInsert {
			diffHTMLBuf.WriteString("<span class='gi'>")
			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
			diffHTMLBuf.WriteString("</span>")
		} else if it.Type == diffmatchpatch.DiffDelete {
			diffHTMLBuf.WriteString("<span class='gd'>")
			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
			diffHTMLBuf.WriteString("</span>")
		} else {
			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
		}
	}
	diffHTMLBuf.WriteString("</pre>")

	ctx.JSON(http.StatusOK, map[string]any{
		"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history),
		"historyId":     historyID,
		"prevHistoryId": prevHistoryID,
		"diffHtml":      diffHTMLBuf.String(),
	})
}

// SoftDeleteContentHistory soft delete
func SoftDeleteContentHistory(ctx *context.Context) {
	issue := GetActionIssue(ctx)
	if ctx.Written() {
		return
	}

	commentID := ctx.FormInt64("comment_id")
	historyID := ctx.FormInt64("history_id")

	var comment *issues_model.Comment
	var history *issues_model.ContentHistory
	var err error

	if history, err = issues_model.GetIssueContentHistoryByID(ctx, historyID); err != nil {
		log.Error("can not get issue content history %v. err=%v", historyID, err)
		return
	}
	if history.IssueID != issue.ID {
		ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{})
		return
	}
	if commentID != 0 {
		if history.CommentID != commentID {
			ctx.NotFound("CompareCommentID", issues_model.ErrCommentNotExist{})
			return
		}

		if comment, err = issues_model.GetCommentByID(ctx, commentID); err != nil {
			log.Error("can not get comment for issue content history %v. err=%v", historyID, err)
			return
		}
		if comment.IssueID != issue.ID {
			ctx.NotFound("CompareIssueID", issues_model.ErrCommentNotExist{})
			return
		}
	}

	canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history)
	if !canSoftDelete {
		ctx.JSON(http.StatusForbidden, map[string]any{
			"message": "Can not delete the content history",
		})
		return
	}

	err = issues_model.SoftDeleteIssueContentHistory(ctx, historyID)
	log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID)
	ctx.JSON(http.StatusOK, map[string]any{
		"ok": err == nil,
	})
}