1// Copyright 2023 The Gitea Authors. All rights reserved.
2// SPDX-License-Identifier: MIT
3
4package templates
5
6import (
7 "context"
8 "encoding/hex"
9 "fmt"
10 "html/template"
11 "math"
12 "net/url"
13 "regexp"
14 "strings"
15 "unicode"
16
17 issues_model "forgejo.org/models/issues"
18 "forgejo.org/modules/emoji"
19 "forgejo.org/modules/log"
20 "forgejo.org/modules/markup"
21 "forgejo.org/modules/markup/markdown"
22 "forgejo.org/modules/setting"
23 "forgejo.org/modules/translation"
24 "forgejo.org/modules/util"
25)
26
27// RenderCommitMessage renders commit message with XSS-safe and special links.
28func RenderCommitMessage(ctx context.Context, msg string, metas map[string]string) template.HTML {
29 cleanMsg := template.HTMLEscapeString(msg)
30 // we can safely assume that it will not return any error, since there
31 // shouldn't be any special HTML.
32 fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
33 Ctx: ctx,
34 Metas: metas,
35 }, cleanMsg)
36 if err != nil {
37 log.Error("RenderCommitMessage: %v", err)
38 return ""
39 }
40 msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n")
41 if len(msgLines) == 0 {
42 return template.HTML("")
43 }
44 return RenderCodeBlock(template.HTML(msgLines[0]))
45}
46
47// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
48// the provided default url, handling for special links without email to links.
49func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML {
50 msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
51 lineEnd := strings.IndexByte(msgLine, '\n')
52 if lineEnd > 0 {
53 msgLine = msgLine[:lineEnd]
54 }
55 msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
56 if len(msgLine) == 0 {
57 return template.HTML("")
58 }
59
60 // we can safely assume that it will not return any error, since there
61 // shouldn't be any special HTML.
62 renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{
63 Ctx: ctx,
64 DefaultLink: urlDefault,
65 Metas: metas,
66 }, template.HTMLEscapeString(msgLine))
67 if err != nil {
68 log.Error("RenderCommitMessageSubject: %v", err)
69 return template.HTML("")
70 }
71 return RenderCodeBlock(template.HTML(renderedMessage))
72}
73
74// RenderCommitBody extracts the body of a commit message without its title.
75func RenderCommitBody(ctx context.Context, msg string, metas map[string]string) template.HTML {
76 msgLine := strings.TrimSpace(msg)
77 lineEnd := strings.IndexByte(msgLine, '\n')
78 if lineEnd > 0 {
79 msgLine = msgLine[lineEnd+1:]
80 } else {
81 return ""
82 }
83 msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace)
84 if len(msgLine) == 0 {
85 return ""
86 }
87
88 renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
89 Ctx: ctx,
90 Metas: metas,
91 }, template.HTMLEscapeString(msgLine))
92 if err != nil {
93 log.Error("RenderCommitMessage: %v", err)
94 return ""
95 }
96 return template.HTML(renderedMessage)
97}
98
99// Match text that is between back ticks.
100var codeMatcher = regexp.MustCompile("`([^`]+)`")
101
102// RenderCodeBlock renders "`…`" as highlighted "<code>" block, intended for issue and PR titles
103func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
104 htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags
105 return template.HTML(htmlWithCodeTags)
106}
107
108const (
109 activeLabelOpacity = uint8(255)
110 archivedLabelOpacity = uint8(127)
111)
112
113func GetLabelOpacityByte(isArchived bool) uint8 {
114 if isArchived {
115 return archivedLabelOpacity
116 }
117 return activeLabelOpacity
118}
119
120// RenderIssueTitle renders issue/pull title with defined post processors
121func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML {
122 renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
123 Ctx: ctx,
124 Metas: metas,
125 }, template.HTMLEscapeString(text))
126 if err != nil {
127 log.Error("RenderIssueTitle: %v", err)
128 return template.HTML("")
129 }
130 return template.HTML(renderedText)
131}
132
133// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
134func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
135 renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
136 if err != nil {
137 log.Error("RenderRefIssueTitle: %v", err)
138 return ""
139 }
140
141 return template.HTML(renderedText)
142}
143
144// RenderLabel renders a label
145// locale is needed due to an import cycle with our context providing the `Tr` function
146func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
147 var (
148 archivedCSSClass string
149 textColor = util.ContrastColor(label.Color)
150 labelScope = label.ExclusiveScope()
151 )
152
153 description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
154
155 if label.IsArchived() {
156 archivedCSSClass = "archived-label"
157 description = locale.TrString("repo.issues.archived_label_description", description)
158 }
159
160 if labelScope == "" {
161 // Regular label
162
163 labelColor := label.Color + hex.EncodeToString([]byte{GetLabelOpacityByte(label.IsArchived())})
164 s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
165 archivedCSSClass, textColor, labelColor, description, RenderEmoji(ctx, label.Name))
166 return template.HTML(s)
167 }
168
169 // Scoped label
170 scopeText := RenderEmoji(ctx, labelScope)
171 itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
172
173 // Make scope and item background colors slightly darker and lighter respectively.
174 // More contrast needed with higher luminance, empirically tweaked.
175 luminance := util.GetRelativeLuminance(label.Color)
176 contrast := 0.01 + luminance*0.03
177 // Ensure we add the same amount of contrast also near 0 and 1.
178 darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
179 lighten := contrast + math.Max(contrast-luminance, 0.0)
180 // Compute factor to keep RGB values proportional.
181 darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
182 lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
183
184 opacity := GetLabelOpacityByte(label.IsArchived())
185 r, g, b := util.HexToRBGColor(label.Color)
186 scopeBytes := []byte{
187 uint8(math.Min(math.Round(r*darkenFactor), 255)),
188 uint8(math.Min(math.Round(g*darkenFactor), 255)),
189 uint8(math.Min(math.Round(b*darkenFactor), 255)),
190 opacity,
191 }
192 itemBytes := []byte{
193 uint8(math.Min(math.Round(r*lightenFactor), 255)),
194 uint8(math.Min(math.Round(g*lightenFactor), 255)),
195 uint8(math.Min(math.Round(b*lightenFactor), 255)),
196 opacity,
197 }
198
199 scopeColor := "#" + hex.EncodeToString(scopeBytes)
200 itemColor := "#" + hex.EncodeToString(itemBytes)
201
202 s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
203 "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
204 "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
205 "</span>",
206 archivedCSSClass, description,
207 textColor, scopeColor, scopeText,
208 textColor, itemColor, itemText)
209 return template.HTML(s)
210}
211
212// RenderEmoji renders html text with emoji post processors
213func RenderEmoji(ctx context.Context, text string) template.HTML {
214 renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx},
215 template.HTMLEscapeString(text))
216 if err != nil {
217 log.Error("RenderEmoji: %v", err)
218 return template.HTML("")
219 }
220 return template.HTML(renderedText)
221}
222
223// ReactionToEmoji renders emoji for use in reactions
224func ReactionToEmoji(reaction string) template.HTML {
225 val := emoji.FromCode(reaction)
226 if val != nil {
227 return template.HTML(val.Emoji)
228 }
229 val = emoji.FromAlias(reaction)
230 if val != nil {
231 return template.HTML(val.Emoji)
232 }
233 return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
234}
235
236func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive
237 output, err := markdown.RenderString(&markup.RenderContext{
238 Ctx: ctx,
239 Metas: map[string]string{"mode": "document"},
240 }, input)
241 if err != nil {
242 log.Error("RenderString: %v", err)
243 }
244 return output
245}
246
247func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, isPull bool) template.HTML {
248 htmlCode := `<span class="labels-list">`
249 for _, label := range labels {
250 // Protect against nil value in labels - shouldn't happen but would cause a panic if so
251 if label == nil {
252 continue
253 }
254
255 issuesOrPull := "issues"
256 if isPull {
257 issuesOrPull = "pulls"
258 }
259 htmlCode += fmt.Sprintf("<a href='%s/%s?labels=%d' rel='nofollow'>%s</a> ",
260 repoLink, issuesOrPull, label.ID, RenderLabel(ctx, locale, label))
261 }
262 htmlCode += "</span>"
263 return template.HTML(htmlCode)
264}
265
266func RenderReviewRequest(users []issues_model.RequestReviewTarget) template.HTML {
267 usernames := make([]string, 0, len(users))
268 for _, user := range users {
269 usernames = append(usernames, template.HTMLEscapeString(user.Name()))
270 }
271
272 htmlCode := `<span class="review-request-list">`
273 htmlCode += strings.Join(usernames, ", ")
274 htmlCode += "</span>"
275 return template.HTML(htmlCode)
276}