diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2023-04-29 20:02:29 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-29 08:02:29 -0400 |
commit | 241b74f6c536c1d7de3b4e79e552bf1a3264cc6d (patch) | |
tree | b6e482d056eeb1d00ca48de9161dc1123f6bbaf4 /modules | |
parent | 5a5ab8ef5ac5fbdb893707933f06ff6bcd8e834a (diff) | |
download | gitea-241b74f6c536c1d7de3b4e79e552bf1a3264cc6d.tar.gz gitea-241b74f6c536c1d7de3b4e79e552bf1a3264cc6d.zip |
Improve template helper (#24417)
It seems that we really need the "context function" soon. So we should
clean up the helper functions first.
Major changes:
* Improve StringUtils and add JsonUtils
* Remove one-time-use helper functions like CompareLink
* Move other code (no change) to util_avatar/util_render/util_misc (no
need to propose changes for them)
I have tested the changed templates:
![image](https://user-images.githubusercontent.com/2114189/235283862-608dbf6b-2da3-4d06-8157-b523ca93edb4.png)
![image](https://user-images.githubusercontent.com/2114189/235283888-1dfc0471-e622-4d64-9d76-7859819580d3.png)
![image](https://user-images.githubusercontent.com/2114189/235283903-d559f14d-4abb-4a50-915f-2b9cbc381a7a.png)
![image](https://user-images.githubusercontent.com/2114189/235283955-b7b5adea-aca3-4758-b38a-3aae3f7c6048.png)
---------
Co-authored-by: Giteabot <teabot@gitea.io>
Diffstat (limited to 'modules')
-rw-r--r-- | modules/templates/helper.go | 557 | ||||
-rw-r--r-- | modules/templates/util_avatar.go | 84 | ||||
-rw-r--r-- | modules/templates/util_json.go | 35 | ||||
-rw-r--r-- | modules/templates/util_misc.go | 209 | ||||
-rw-r--r-- | modules/templates/util_render.go | 254 | ||||
-rw-r--r-- | modules/templates/util_string.go | 18 |
6 files changed, 608 insertions, 549 deletions
diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a290d38979..20261eb959 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -5,46 +5,25 @@ package templates import ( - "bytes" "context" - "encoding/hex" "fmt" "html" "html/template" - "math" - "mime" "net/url" - "path/filepath" "regexp" "strings" "time" - "unicode" - activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/models/avatars" - issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/organization" - repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/emoji" - "code.gitea.io/gitea/modules/git" - giturl "code.gitea.io/gitea/modules/git/url" - gitea_html "code.gitea.io/gitea/modules/html" - "code.gitea.io/gitea/modules/json" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/markup/markdown" - "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/templates/eval" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/gitdiff" - - "github.com/editorconfig/editorconfig-core-go/v2" ) // Used from static.go && dynamic.go @@ -53,6 +32,8 @@ var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`) // NewFuncMap returns functions for injecting to templates func NewFuncMap() []template.FuncMap { return []template.FuncMap{map[string]interface{}{ + "DumpVar": dumpVar, + // ----------------------------------------------------------------- // html/template related functions "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. @@ -63,6 +44,7 @@ func NewFuncMap() []template.FuncMap { "JSEscape": template.JSEscapeString, "Str2html": Str2html, // TODO: rename it to SanitizeHTML "URLJoin": util.URLJoin, + "DotEscape": DotEscape, "PathEscape": url.PathEscape, "PathEscapeSegments": util.PathEscapeSegments, @@ -70,30 +52,7 @@ func NewFuncMap() []template.FuncMap { // utils "StringUtils": NewStringUtils, "SliceUtils": NewSliceUtils, - - // ----------------------------------------------------------------- - // string / json - // TODO: move string helper functions to StringUtils - "Join": strings.Join, - "DotEscape": DotEscape, - "EllipsisString": base.EllipsisString, - "DumpVar": dumpVar, - - "Json": func(in interface{}) string { - out, err := json.Marshal(in) - if err != nil { - return "" - } - return string(out) - }, - "JsonPrettyPrint": func(in string) string { - var out bytes.Buffer - err := json.Indent(&out, []byte(in), "", " ") - if err != nil { - return "" - } - return out.String() - }, + "JsonUtils": NewJsonUtils, // ----------------------------------------------------------------- // svg / avatar / icon @@ -107,31 +66,7 @@ func NewFuncMap() []template.FuncMap { "MigrationIcon": MigrationIcon, "ActionIcon": ActionIcon, - "SortArrow": func(normSort, revSort, urlSort string, isDefault bool) template.HTML { - // if needed - if len(normSort) == 0 || len(urlSort) == 0 { - return "" - } - - if len(urlSort) == 0 && isDefault { - // if sort is sorted as default add arrow tho this table header - if isDefault { - return svg.RenderHTML("octicon-triangle-down", 16) - } - } else { - // if sort arg is in url test if it correlates with column header sort arguments - // the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev) - if urlSort == normSort { - // the table is sorted with this header normal - return svg.RenderHTML("octicon-triangle-up", 16) - } else if urlSort == revSort { - // the table is sorted with this header reverse - return svg.RenderHTML("octicon-triangle-down", 16) - } - } - // the table is NOT sorted with this header - return "" - }, + "SortArrow": SortArrow, // ----------------------------------------------------------------- // time / number / format @@ -242,32 +177,9 @@ func NewFuncMap() []template.FuncMap { "ReactionToEmoji": ReactionToEmoji, "RenderNote": RenderNote, - "RenderMarkdownToHtml": func(ctx context.Context, input string) template.HTML { - output, err := markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: setting.AppSubURL, - }, input) - if err != nil { - log.Error("RenderString: %v", err) - } - return template.HTML(output) - }, - "RenderLabel": func(ctx context.Context, label *issues_model.Label) template.HTML { - return template.HTML(RenderLabel(ctx, label)) - }, - "RenderLabels": func(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML { - htmlCode := `<span class="labels-list">` - for _, label := range labels { - // Protect against nil value in labels - shouldn't happen but would cause a panic if so - if label == nil { - continue - } - htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", - repoLink, label.ID, RenderLabel(ctx, label)) - } - htmlCode += "</span>" - return template.HTML(htmlCode) - }, + "RenderMarkdownToHtml": RenderMarkdownToHtml, + "RenderLabel": RenderLabel, + "RenderLabels": RenderLabels, // ----------------------------------------------------------------- // misc @@ -278,124 +190,11 @@ func NewFuncMap() []template.FuncMap { "CommentMustAsDiff": gitdiff.CommentMustAsDiff, "MirrorRemoteAddress": mirrorRemoteAddress, - "ParseDeadline": func(deadline string) []string { - return strings.Split(deadline, "|") - }, - "FilenameIsImage": func(filename string) bool { - mimeType := mime.TypeByExtension(filepath.Ext(filename)) - return strings.HasPrefix(mimeType, "image/") - }, - "TabSizeClass": func(ec interface{}, filename string) string { - var ( - value *editorconfig.Editorconfig - ok bool - ) - if ec != nil { - if value, ok = ec.(*editorconfig.Editorconfig); !ok || value == nil { - return "tab-size-8" - } - def, err := value.GetDefinitionForFilename(filename) - if err != nil { - log.Error("tab size class: getting definition for filename: %v", err) - return "tab-size-8" - } - if def.TabWidth > 0 { - return fmt.Sprintf("tab-size-%d", def.TabWidth) - } - } - return "tab-size-8" - }, - "SubJumpablePath": func(str string) []string { - var path []string - index := strings.LastIndex(str, "/") - if index != -1 && index != len(str) { - path = append(path, str[0:index+1], str[index+1:]) - } else { - path = append(path, str) - } - return path - }, - "CompareLink": func(baseRepo, repo *repo_model.Repository, branchName string) string { - var curBranch string - if repo.ID != baseRepo.ID { - curBranch += fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name)) - } - curBranch += util.PathEscapeSegments(branchName) - - return fmt.Sprintf("%s/compare/%s...%s", - baseRepo.Link(), - util.PathEscapeSegments(baseRepo.DefaultBranch), - curBranch, - ) - }, + "FilenameIsImage": FilenameIsImage, + "TabSizeClass": TabSizeClass, }} } -// AvatarHTML creates the HTML for an avatar -func AvatarHTML(src string, size int, class, name string) template.HTML { - sizeStr := fmt.Sprintf(`%d`, size) - - if name == "" { - name = "avatar" - } - - return template.HTML(`<img class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`) -} - -// Avatar renders user avatars. args: user, size (int), class (string) -func Avatar(ctx context.Context, item interface{}, others ...interface{}) template.HTML { - size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) - - switch t := item.(type) { - case *user_model.User: - src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) - if src != "" { - return AvatarHTML(src, size, class, t.DisplayName()) - } - case *repo_model.Collaborator: - src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) - if src != "" { - return AvatarHTML(src, size, class, t.DisplayName()) - } - case *organization.Organization: - src := t.AsUser().AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) - if src != "" { - return AvatarHTML(src, size, class, t.AsUser().DisplayName()) - } - } - - return template.HTML("") -} - -// AvatarByAction renders user avatars from action. args: action, size (int), class (string) -func AvatarByAction(ctx context.Context, action *activities_model.Action, others ...interface{}) template.HTML { - action.LoadActUser(ctx) - return Avatar(ctx, action.ActUser, others...) -} - -// RepoAvatar renders repo avatars. args: repo, size(int), class (string) -func RepoAvatar(repo *repo_model.Repository, others ...interface{}) template.HTML { - size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) - - src := repo.RelAvatarLink() - if src != "" { - return AvatarHTML(src, size, class, repo.FullName()) - } - return template.HTML("") -} - -// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) -func AvatarByEmail(ctx context.Context, email, name string, others ...interface{}) template.HTML { - size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) - src := avatars.GenerateEmailAvatarFastLink(ctx, email, size*setting.Avatar.RenderedSizeFactor) - - if src != "" { - return AvatarHTML(src, size, class, name) - } - - return template.HTML("") -} - // Safe render raw as HTML func Safe(raw string) template.HTML { return template.HTML(raw) @@ -411,342 +210,6 @@ func DotEscape(raw string) string { return strings.ReplaceAll(raw, ".", "\u200d.\u200d") } -// RenderCommitMessage renders commit message with XSS-safe and special links. -func RenderCommitMessage(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { - return RenderCommitMessageLink(ctx, msg, urlPrefix, "", metas) -} - -// RenderCommitMessageLink renders commit message as a XXS-safe link to the provided -// default url, handling for special links. -func RenderCommitMessageLink(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { - cleanMsg := template.HTMLEscapeString(msg) - // we can safely assume that it will not return any error, since there - // shouldn't be any special HTML. - fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, - DefaultLink: urlDefault, - Metas: metas, - }, cleanMsg) - if err != nil { - log.Error("RenderCommitMessage: %v", err) - return "" - } - msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n") - if len(msgLines) == 0 { - return template.HTML("") - } - return template.HTML(msgLines[0]) -} - -// RenderCommitMessageLinkSubject renders commit message as a XXS-safe link to -// the provided default url, handling for special links without email to links. -func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { - msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace) - lineEnd := strings.IndexByte(msgLine, '\n') - if lineEnd > 0 { - msgLine = msgLine[:lineEnd] - } - msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) - if len(msgLine) == 0 { - return template.HTML("") - } - - // we can safely assume that it will not return any error, since there - // shouldn't be any special HTML. - renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, - DefaultLink: urlDefault, - Metas: metas, - }, template.HTMLEscapeString(msgLine)) - if err != nil { - log.Error("RenderCommitMessageSubject: %v", err) - return template.HTML("") - } - return template.HTML(renderedMessage) -} - -// RenderCommitBody extracts the body of a commit message without its title. -func RenderCommitBody(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { - msgLine := strings.TrimRightFunc(msg, unicode.IsSpace) - lineEnd := strings.IndexByte(msgLine, '\n') - if lineEnd > 0 { - msgLine = msgLine[lineEnd+1:] - } else { - return template.HTML("") - } - msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace) - if len(msgLine) == 0 { - return template.HTML("") - } - - renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, - Metas: metas, - }, template.HTMLEscapeString(msgLine)) - if err != nil { - log.Error("RenderCommitMessage: %v", err) - return "" - } - return template.HTML(renderedMessage) -} - -// Match text that is between back ticks. -var codeMatcher = regexp.MustCompile("`([^`]+)`") - -// RenderCodeBlock renders "`…`" as highlighted "<code>" block. -// Intended for issue and PR titles, these containers should have styles for "<code>" elements -func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML { - htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), "<code>$1</code>") // replace with HTML <code> tags - return template.HTML(htmlWithCodeTags) -} - -// RenderIssueTitle renders issue/pull title with defined post processors -func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[string]string) template.HTML { - renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, - Metas: metas, - }, template.HTMLEscapeString(text)) - if err != nil { - log.Error("RenderIssueTitle: %v", err) - return template.HTML("") - } - return template.HTML(renderedText) -} - -// RenderLabel renders a label -func RenderLabel(ctx context.Context, label *issues_model.Label) string { - labelScope := label.ExclusiveScope() - - textColor := "#111" - if label.UseLightTextColor() { - textColor = "#eee" - } - - description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) - - if labelScope == "" { - // Regular label - return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", - textColor, label.Color, description, RenderEmoji(ctx, label.Name)) - } - - // Scoped label - scopeText := RenderEmoji(ctx, labelScope) - itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) - - itemColor := label.Color - scopeColor := label.Color - if r, g, b, err := label.ColorRGB(); err == nil { - // Make scope and item background colors slightly darker and lighter respectively. - // More contrast needed with higher luminance, empirically tweaked. - luminance := (0.299*r + 0.587*g + 0.114*b) / 255 - contrast := 0.01 + luminance*0.03 - // Ensure we add the same amount of contrast also near 0 and 1. - darken := contrast + math.Max(luminance+contrast-1.0, 0.0) - lighten := contrast + math.Max(contrast-luminance, 0.0) - // Compute factor to keep RGB values proportional. - darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) - lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) - - scopeBytes := []byte{ - uint8(math.Min(math.Round(r*darkenFactor), 255)), - uint8(math.Min(math.Round(g*darkenFactor), 255)), - uint8(math.Min(math.Round(b*darkenFactor), 255)), - } - itemBytes := []byte{ - uint8(math.Min(math.Round(r*lightenFactor), 255)), - uint8(math.Min(math.Round(g*lightenFactor), 255)), - uint8(math.Min(math.Round(b*lightenFactor), 255)), - } - - itemColor = "#" + hex.EncodeToString(itemBytes) - scopeColor = "#" + hex.EncodeToString(scopeBytes) - } - - return fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ - "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ - "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ - "</span>", - description, - textColor, scopeColor, scopeText, - textColor, itemColor, itemText) -} - -// RenderEmoji renders html text with emoji post processors -func RenderEmoji(ctx context.Context, text string) template.HTML { - renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx}, - template.HTMLEscapeString(text)) - if err != nil { - log.Error("RenderEmoji: %v", err) - return template.HTML("") - } - return template.HTML(renderedText) -} - -// ReactionToEmoji renders emoji for use in reactions -func ReactionToEmoji(reaction string) template.HTML { - val := emoji.FromCode(reaction) - if val != nil { - return template.HTML(val.Emoji) - } - val = emoji.FromAlias(reaction) - if val != nil { - return template.HTML(val.Emoji) - } - return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) -} - -// RenderNote renders the contents of a git-notes file as a commit message. -func RenderNote(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { - cleanMsg := template.HTMLEscapeString(msg) - fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, - Metas: metas, - }, cleanMsg) - if err != nil { - log.Error("RenderNote: %v", err) - return "" - } - return template.HTML(fullMessage) -} - -// IsMultilineCommitMessage checks to see if a commit message contains multiple lines. -func IsMultilineCommitMessage(msg string) bool { - return strings.Count(strings.TrimSpace(msg), "\n") >= 1 -} - -// Actioner describes an action -type Actioner interface { - GetOpType() activities_model.ActionType - GetActUserName() string - GetRepoUserName() string - GetRepoName() string - GetRepoPath() string - GetRepoLink() string - GetBranch() string - GetContent() string - GetCreate() time.Time - GetIssueInfos() []string -} - -// ActionIcon accepts an action operation type and returns an icon class name. -func ActionIcon(opType activities_model.ActionType) string { - switch opType { - case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo: - return "repo" - case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch: - return "git-commit" - case activities_model.ActionCreateIssue: - return "issue-opened" - case activities_model.ActionCreatePullRequest: - return "git-pull-request" - case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: - return "comment-discussion" - case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: - return "git-merge" - case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: - return "issue-closed" - case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: - return "issue-reopened" - case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete: - return "mirror" - case activities_model.ActionApprovePullRequest: - return "check" - case activities_model.ActionRejectPullRequest: - return "diff" - case activities_model.ActionPublishRelease: - return "tag" - case activities_model.ActionPullReviewDismissed: - return "x" - default: - return "question" - } -} - -// ActionContent2Commits converts action content to push commits -func ActionContent2Commits(act Actioner) *repository.PushCommits { - push := repository.NewPushCommits() - - if act == nil || act.GetContent() == "" { - return push - } - - if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil { - log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err) - } - - if push.Len == 0 { - push.Len = len(push.Commits) - } - - return push -} - -// DiffLineTypeToStr returns diff line type name -func DiffLineTypeToStr(diffType int) string { - switch diffType { - case 2: - return "add" - case 3: - return "del" - case 4: - return "tag" - } - return "same" -} - -// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from -func MigrationIcon(hostname string) string { - switch hostname { - case "github.com": - return "octicon-mark-github" - default: - return "gitea-git" - } -} - -type remoteAddress struct { - Address string - Username string - Password string -} - -func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string, ignoreOriginalURL bool) remoteAddress { - a := remoteAddress{} - - remoteURL := m.OriginalURL - if ignoreOriginalURL || remoteURL == "" { - var err error - remoteURL, err = git.GetRemoteAddress(ctx, m.RepoPath(), remoteName) - if err != nil { - log.Error("GetRemoteURL %v", err) - return a - } - } - - u, err := giturl.Parse(remoteURL) - if err != nil { - log.Error("giturl.Parse %v", err) - return a - } - - if u.Scheme != "ssh" && u.Scheme != "file" { - if u.User != nil { - a.Username = u.User.Username() - a.Password, _ = u.User.Password() - } - u.User = nil - } - a.Address = u.String() - - return a -} - // Eval the expression and return the result, see the comment of eval.Expr for details. // To use this helper function in templates, pass each token as a separate parameter. // diff --git a/modules/templates/util_avatar.go b/modules/templates/util_avatar.go new file mode 100644 index 0000000000..3badc97cb9 --- /dev/null +++ b/modules/templates/util_avatar.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "fmt" + "html" + "html/template" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/avatars" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + gitea_html "code.gitea.io/gitea/modules/html" + "code.gitea.io/gitea/modules/setting" +) + +// AvatarHTML creates the HTML for an avatar +func AvatarHTML(src string, size int, class, name string) template.HTML { + sizeStr := fmt.Sprintf(`%d`, size) + + if name == "" { + name = "avatar" + } + + return template.HTML(`<img class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`) +} + +// Avatar renders user avatars. args: user, size (int), class (string) +func Avatar(ctx context.Context, item interface{}, others ...interface{}) template.HTML { + size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) + + switch t := item.(type) { + case *user_model.User: + src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.DisplayName()) + } + case *repo_model.Collaborator: + src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.DisplayName()) + } + case *organization.Organization: + src := t.AsUser().AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.AsUser().DisplayName()) + } + } + + return template.HTML("") +} + +// AvatarByAction renders user avatars from action. args: action, size (int), class (string) +func AvatarByAction(ctx context.Context, action *activities_model.Action, others ...interface{}) template.HTML { + action.LoadActUser(ctx) + return Avatar(ctx, action.ActUser, others...) +} + +// RepoAvatar renders repo avatars. args: repo, size(int), class (string) +func RepoAvatar(repo *repo_model.Repository, others ...interface{}) template.HTML { + size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) + + src := repo.RelAvatarLink() + if src != "" { + return AvatarHTML(src, size, class, repo.FullName()) + } + return template.HTML("") +} + +// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) +func AvatarByEmail(ctx context.Context, email, name string, others ...interface{}) template.HTML { + size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) + src := avatars.GenerateEmailAvatarFastLink(ctx, email, size*setting.Avatar.RenderedSizeFactor) + + if src != "" { + return AvatarHTML(src, size, class, name) + } + + return template.HTML("") +} diff --git a/modules/templates/util_json.go b/modules/templates/util_json.go new file mode 100644 index 0000000000..71a4e23d36 --- /dev/null +++ b/modules/templates/util_json.go @@ -0,0 +1,35 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "bytes" + + "code.gitea.io/gitea/modules/json" +) + +type JsonUtils struct{} //nolint:revive + +var jsonUtils = JsonUtils{} + +func NewJsonUtils() *JsonUtils { //nolint:revive + return &jsonUtils +} + +func (su *JsonUtils) EncodeToString(v any) string { + out, err := json.Marshal(v) + if err != nil { + return "" + } + return string(out) +} + +func (su *JsonUtils) PrettyIndent(s string) string { + var out bytes.Buffer + err := json.Indent(&out, []byte(s), "", " ") + if err != nil { + return "" + } + return out.String() +} diff --git a/modules/templates/util_misc.go b/modules/templates/util_misc.go new file mode 100644 index 0000000000..599a0942ce --- /dev/null +++ b/modules/templates/util_misc.go @@ -0,0 +1,209 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "fmt" + "html/template" + "mime" + "path/filepath" + "strings" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + giturl "code.gitea.io/gitea/modules/git/url" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/svg" + + "github.com/editorconfig/editorconfig-core-go/v2" +) + +func SortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML { + // if needed + if len(normSort) == 0 || len(urlSort) == 0 { + return "" + } + + if len(urlSort) == 0 && isDefault { + // if sort is sorted as default add arrow tho this table header + if isDefault { + return svg.RenderHTML("octicon-triangle-down", 16) + } + } else { + // if sort arg is in url test if it correlates with column header sort arguments + // the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev) + if urlSort == normSort { + // the table is sorted with this header normal + return svg.RenderHTML("octicon-triangle-up", 16) + } else if urlSort == revSort { + // the table is sorted with this header reverse + return svg.RenderHTML("octicon-triangle-down", 16) + } + } + // the table is NOT sorted with this header + return "" +} + +// IsMultilineCommitMessage checks to see if a commit message contains multiple lines. +func IsMultilineCommitMessage(msg string) bool { + return strings.Count(strings.TrimSpace(msg), "\n") >= 1 +} + +// Actioner describes an action +type Actioner interface { + GetOpType() activities_model.ActionType + GetActUserName() string + GetRepoUserName() string + GetRepoName() string + GetRepoPath() string + GetRepoLink() string + GetBranch() string + GetContent() string + GetCreate() time.Time + GetIssueInfos() []string +} + +// ActionIcon accepts an action operation type and returns an icon class name. +func ActionIcon(opType activities_model.ActionType) string { + switch opType { + case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo: + return "repo" + case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch: + return "git-commit" + case activities_model.ActionCreateIssue: + return "issue-opened" + case activities_model.ActionCreatePullRequest: + return "git-pull-request" + case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: + return "comment-discussion" + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + return "git-merge" + case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: + return "issue-closed" + case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: + return "issue-reopened" + case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete: + return "mirror" + case activities_model.ActionApprovePullRequest: + return "check" + case activities_model.ActionRejectPullRequest: + return "diff" + case activities_model.ActionPublishRelease: + return "tag" + case activities_model.ActionPullReviewDismissed: + return "x" + default: + return "question" + } +} + +// ActionContent2Commits converts action content to push commits +func ActionContent2Commits(act Actioner) *repository.PushCommits { + push := repository.NewPushCommits() + + if act == nil || act.GetContent() == "" { + return push + } + + if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil { + log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err) + } + + if push.Len == 0 { + push.Len = len(push.Commits) + } + + return push +} + +// DiffLineTypeToStr returns diff line type name +func DiffLineTypeToStr(diffType int) string { + switch diffType { + case 2: + return "add" + case 3: + return "del" + case 4: + return "tag" + } + return "same" +} + +// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from +func MigrationIcon(hostname string) string { + switch hostname { + case "github.com": + return "octicon-mark-github" + default: + return "gitea-git" + } +} + +type remoteAddress struct { + Address string + Username string + Password string +} + +func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string, ignoreOriginalURL bool) remoteAddress { + a := remoteAddress{} + + remoteURL := m.OriginalURL + if ignoreOriginalURL || remoteURL == "" { + var err error + remoteURL, err = git.GetRemoteAddress(ctx, m.RepoPath(), remoteName) + if err != nil { + log.Error("GetRemoteURL %v", err) + return a + } + } + + u, err := giturl.Parse(remoteURL) + if err != nil { + log.Error("giturl.Parse %v", err) + return a + } + + if u.Scheme != "ssh" && u.Scheme != "file" { + if u.User != nil { + a.Username = u.User.Username() + a.Password, _ = u.User.Password() + } + u.User = nil + } + a.Address = u.String() + + return a +} + +func FilenameIsImage(filename string) bool { + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + return strings.HasPrefix(mimeType, "image/") +} + +func TabSizeClass(ec interface{}, filename string) string { + var ( + value *editorconfig.Editorconfig + ok bool + ) + if ec != nil { + if value, ok = ec.(*editorconfig.Editorconfig); !ok || value == nil { + return "tab-size-8" + } + def, err := value.GetDefinitionForFilename(filename) + if err != nil { + log.Error("tab size class: getting definition for filename: %v", err) + return "tab-size-8" + } + if def.TabWidth > 0 { + return fmt.Sprintf("tab-size-%d", def.TabWidth) + } + } + return "tab-size-8" +} diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go new file mode 100644 index 0000000000..a59ddd3f17 --- /dev/null +++ b/modules/templates/util_render.go @@ -0,0 +1,254 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "encoding/hex" + "fmt" + "html/template" + "math" + "net/url" + "regexp" + "strings" + "unicode" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" +) + +// RenderCommitMessage renders commit message with XSS-safe and special links. +func RenderCommitMessage(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { + return RenderCommitMessageLink(ctx, msg, urlPrefix, "", metas) +} + +// RenderCommitMessageLink renders commit message as a XXS-safe link to the provided +// default url, handling for special links. +func RenderCommitMessageLink(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { + cleanMsg := template.HTMLEscapeString(msg) + // we can safely assume that it will not return any error, since there + // shouldn't be any special HTML. + fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: urlPrefix, + DefaultLink: urlDefault, + Metas: metas, + }, cleanMsg) + if err != nil { + log.Error("RenderCommitMessage: %v", err) + return "" + } + msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n") + if len(msgLines) == 0 { + return template.HTML("") + } + return template.HTML(msgLines[0]) +} + +// RenderCommitMessageLinkSubject renders commit message as a XXS-safe link to +// the provided default url, handling for special links without email to links. +func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { + msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace) + lineEnd := strings.IndexByte(msgLine, '\n') + if lineEnd > 0 { + msgLine = msgLine[:lineEnd] + } + msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) + if len(msgLine) == 0 { + return template.HTML("") + } + + // we can safely assume that it will not return any error, since there + // shouldn't be any special HTML. + renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: urlPrefix, + DefaultLink: urlDefault, + Metas: metas, + }, template.HTMLEscapeString(msgLine)) + if err != nil { + log.Error("RenderCommitMessageSubject: %v", err) + return template.HTML("") + } + return template.HTML(renderedMessage) +} + +// RenderCommitBody extracts the body of a commit message without its title. +func RenderCommitBody(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { + msgLine := strings.TrimRightFunc(msg, unicode.IsSpace) + lineEnd := strings.IndexByte(msgLine, '\n') + if lineEnd > 0 { + msgLine = msgLine[lineEnd+1:] + } else { + return template.HTML("") + } + msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace) + if len(msgLine) == 0 { + return template.HTML("") + } + + renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: urlPrefix, + Metas: metas, + }, template.HTMLEscapeString(msgLine)) + if err != nil { + log.Error("RenderCommitMessage: %v", err) + return "" + } + return template.HTML(renderedMessage) +} + +// Match text that is between back ticks. +var codeMatcher = regexp.MustCompile("`([^`]+)`") + +// RenderCodeBlock renders "`…`" as highlighted "<code>" block. +// Intended for issue and PR titles, these containers should have styles for "<code>" elements +func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML { + htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), "<code>$1</code>") // replace with HTML <code> tags + return template.HTML(htmlWithCodeTags) +} + +// RenderIssueTitle renders issue/pull title with defined post processors +func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[string]string) template.HTML { + renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: urlPrefix, + Metas: metas, + }, template.HTMLEscapeString(text)) + if err != nil { + log.Error("RenderIssueTitle: %v", err) + return template.HTML("") + } + return template.HTML(renderedText) +} + +// RenderLabel renders a label +func RenderLabel(ctx context.Context, label *issues_model.Label) template.HTML { + labelScope := label.ExclusiveScope() + + textColor := "#111" + if label.UseLightTextColor() { + textColor = "#eee" + } + + description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) + + if labelScope == "" { + // Regular label + s := fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", + textColor, label.Color, description, RenderEmoji(ctx, label.Name)) + return template.HTML(s) + } + + // Scoped label + scopeText := RenderEmoji(ctx, labelScope) + itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) + + itemColor := label.Color + scopeColor := label.Color + if r, g, b, err := label.ColorRGB(); err == nil { + // Make scope and item background colors slightly darker and lighter respectively. + // More contrast needed with higher luminance, empirically tweaked. + luminance := (0.299*r + 0.587*g + 0.114*b) / 255 + contrast := 0.01 + luminance*0.03 + // Ensure we add the same amount of contrast also near 0 and 1. + darken := contrast + math.Max(luminance+contrast-1.0, 0.0) + lighten := contrast + math.Max(contrast-luminance, 0.0) + // Compute factor to keep RGB values proportional. + darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) + lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) + + scopeBytes := []byte{ + uint8(math.Min(math.Round(r*darkenFactor), 255)), + uint8(math.Min(math.Round(g*darkenFactor), 255)), + uint8(math.Min(math.Round(b*darkenFactor), 255)), + } + itemBytes := []byte{ + uint8(math.Min(math.Round(r*lightenFactor), 255)), + uint8(math.Min(math.Round(g*lightenFactor), 255)), + uint8(math.Min(math.Round(b*lightenFactor), 255)), + } + + itemColor = "#" + hex.EncodeToString(itemBytes) + scopeColor = "#" + hex.EncodeToString(scopeBytes) + } + + s := fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ + "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ + "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ + "</span>", + description, + textColor, scopeColor, scopeText, + textColor, itemColor, itemText) + return template.HTML(s) +} + +// RenderEmoji renders html text with emoji post processors +func RenderEmoji(ctx context.Context, text string) template.HTML { + renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx}, + template.HTMLEscapeString(text)) + if err != nil { + log.Error("RenderEmoji: %v", err) + return template.HTML("") + } + return template.HTML(renderedText) +} + +// ReactionToEmoji renders emoji for use in reactions +func ReactionToEmoji(reaction string) template.HTML { + val := emoji.FromCode(reaction) + if val != nil { + return template.HTML(val.Emoji) + } + val = emoji.FromAlias(reaction) + if val != nil { + return template.HTML(val.Emoji) + } + return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) +} + +// RenderNote renders the contents of a git-notes file as a commit message. +func RenderNote(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { + cleanMsg := template.HTMLEscapeString(msg) + fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: urlPrefix, + Metas: metas, + }, cleanMsg) + if err != nil { + log.Error("RenderNote: %v", err) + return "" + } + return template.HTML(fullMessage) +} + +func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive + output, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + URLPrefix: setting.AppSubURL, + }, input) + if err != nil { + log.Error("RenderString: %v", err) + } + return template.HTML(output) +} + +func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML { + htmlCode := `<span class="labels-list">` + for _, label := range labels { + // Protect against nil value in labels - shouldn't happen but would cause a panic if so + if label == nil { + continue + } + htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", + repoLink, label.ID, RenderLabel(ctx, label)) + } + htmlCode += "</span>" + return template.HTML(htmlCode) +} diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go index 42d11fc990..459380aee5 100644 --- a/modules/templates/util_string.go +++ b/modules/templates/util_string.go @@ -3,12 +3,18 @@ package templates -import "strings" +import ( + "strings" + + "code.gitea.io/gitea/modules/base" +) type StringUtils struct{} +var stringUtils = StringUtils{} + func NewStringUtils() *StringUtils { - return &StringUtils{} + return &stringUtils } func (su *StringUtils) HasPrefix(s, prefix string) bool { @@ -22,3 +28,11 @@ func (su *StringUtils) Contains(s, substr string) bool { func (su *StringUtils) Split(s, sep string) []string { return strings.Split(s, sep) } + +func (su *StringUtils) Join(a []string, sep string) string { + return strings.Join(a, sep) +} + +func (su *StringUtils) EllipsisString(s string, max int) string { + return base.EllipsisString(s, max) +} |