diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /modules/templates | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'modules/templates')
26 files changed, 3008 insertions, 0 deletions
diff --git a/modules/templates/base.go b/modules/templates/base.go new file mode 100644 index 0000000..2c2f35b --- /dev/null +++ b/modules/templates/base.go @@ -0,0 +1,40 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "slices" + "strings" + + "code.gitea.io/gitea/modules/assetfs" + "code.gitea.io/gitea/modules/setting" +) + +func AssetFS() *assetfs.LayeredFS { + return assetfs.Layered(CustomAssets(), BuiltinAssets()) +} + +func CustomAssets() *assetfs.Layer { + return assetfs.Local("custom", setting.CustomPath, "templates") +} + +func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) { + files, err := assets.ListAllFiles(".", true) + if err != nil { + return nil, err + } + return slices.DeleteFunc(files, func(file string) bool { + return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") + }), nil +} + +func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) { + files, err := assets.ListAllFiles(".", true) + if err != nil { + return nil, err + } + return slices.DeleteFunc(files, func(file string) bool { + return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl") + }), nil +} diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go new file mode 100644 index 0000000..e1babd8 --- /dev/null +++ b/modules/templates/dynamic.go @@ -0,0 +1,15 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !bindata + +package templates + +import ( + "code.gitea.io/gitea/modules/assetfs" + "code.gitea.io/gitea/modules/setting" +) + +func BuiltinAssets() *assetfs.Layer { + return assetfs.Local("builtin(static)", setting.StaticRootPath, "templates") +} diff --git a/modules/templates/eval/eval.go b/modules/templates/eval/eval.go new file mode 100644 index 0000000..5d4ac91 --- /dev/null +++ b/modules/templates/eval/eval.go @@ -0,0 +1,344 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package eval + +import ( + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/util" +) + +type Num struct { + Value any // int64 or float64, nil on error +} + +var opPrecedence = map[string]int{ + // "(": 1, this is for low precedence like function calls, they are handled separately + "or": 2, + "and": 3, + "not": 4, + "==": 5, "!=": 5, "<": 5, "<=": 5, ">": 5, ">=": 5, + "+": 6, "-": 6, + "*": 7, "/": 7, +} + +type stack[T any] struct { + name string + elems []T +} + +func (s *stack[T]) push(t T) { + s.elems = append(s.elems, t) +} + +func (s *stack[T]) pop() T { + if len(s.elems) == 0 { + panic(s.name + " stack is empty") + } + t := s.elems[len(s.elems)-1] + s.elems = s.elems[:len(s.elems)-1] + return t +} + +func (s *stack[T]) peek() T { + if len(s.elems) == 0 { + panic(s.name + " stack is empty") + } + return s.elems[len(s.elems)-1] +} + +type operator string + +type eval struct { + stackNum stack[Num] + stackOp stack[operator] + funcMap map[string]func([]Num) Num +} + +func newEval() *eval { + e := &eval{} + e.stackNum.name = "num" + e.stackOp.name = "op" + return e +} + +func toNum(v any) (Num, error) { + switch v := v.(type) { + case string: + if strings.Contains(v, ".") { + n, err := strconv.ParseFloat(v, 64) + if err != nil { + return Num{n}, err + } + return Num{n}, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return Num{n}, err + } + return Num{n}, nil + case float32, float64: + n, _ := util.ToFloat64(v) + return Num{n}, nil + default: + n, err := util.ToInt64(v) + if err != nil { + return Num{n}, err + } + return Num{n}, nil + } +} + +func truth(b bool) int64 { + if b { + return int64(1) + } + return int64(0) +} + +func applyOp2Generic[T int64 | float64](op operator, n1, n2 T) Num { + switch op { + case "+": + return Num{n1 + n2} + case "-": + return Num{n1 - n2} + case "*": + return Num{n1 * n2} + case "/": + return Num{n1 / n2} + case "==": + return Num{truth(n1 == n2)} + case "!=": + return Num{truth(n1 != n2)} + case "<": + return Num{truth(n1 < n2)} + case "<=": + return Num{truth(n1 <= n2)} + case ">": + return Num{truth(n1 > n2)} + case ">=": + return Num{truth(n1 >= n2)} + case "and": + t1, _ := util.ToFloat64(n1) + t2, _ := util.ToFloat64(n2) + return Num{truth(t1 != 0 && t2 != 0)} + case "or": + t1, _ := util.ToFloat64(n1) + t2, _ := util.ToFloat64(n2) + return Num{truth(t1 != 0 || t2 != 0)} + } + panic("unknown operator: " + string(op)) +} + +func applyOp2(op operator, n1, n2 Num) Num { + float := false + if _, ok := n1.Value.(float64); ok { + float = true + } else if _, ok = n2.Value.(float64); ok { + float = true + } + if float { + f1, _ := util.ToFloat64(n1.Value) + f2, _ := util.ToFloat64(n2.Value) + return applyOp2Generic(op, f1, f2) + } + return applyOp2Generic(op, n1.Value.(int64), n2.Value.(int64)) +} + +func toOp(v any) (operator, error) { + if v, ok := v.(string); ok { + return operator(v), nil + } + return "", fmt.Errorf(`unsupported token type "%T"`, v) +} + +func (op operator) hasOpenBracket() bool { + return strings.HasSuffix(string(op), "(") // it's used to support functions like "sum(" +} + +func (op operator) isComma() bool { + return op == "," +} + +func (op operator) isCloseBracket() bool { + return op == ")" +} + +type ExprError struct { + msg string + tokens []any + err error +} + +func (err ExprError) Error() string { + sb := strings.Builder{} + sb.WriteString(err.msg) + sb.WriteString(" [ ") + for _, token := range err.tokens { + _, _ = fmt.Fprintf(&sb, `"%v" `, token) + } + sb.WriteString("]") + if err.err != nil { + sb.WriteString(": ") + sb.WriteString(err.err.Error()) + } + return sb.String() +} + +func (err ExprError) Unwrap() error { + return err.err +} + +func (e *eval) applyOp() { + op := e.stackOp.pop() + if op == "not" { + num := e.stackNum.pop() + i, _ := util.ToInt64(num.Value) + e.stackNum.push(Num{truth(i == 0)}) + } else if op.hasOpenBracket() || op.isCloseBracket() || op.isComma() { + panic(fmt.Sprintf("incomplete sub-expression with operator %q", op)) + } else { + num2 := e.stackNum.pop() + num1 := e.stackNum.pop() + e.stackNum.push(applyOp2(op, num1, num2)) + } +} + +func (e *eval) exec(tokens ...any) (ret Num, err error) { + defer func() { + if r := recover(); r != nil { + rErr, ok := r.(error) + if !ok { + rErr = fmt.Errorf("%v", r) + } + err = ExprError{"invalid expression", tokens, rErr} + } + }() + for _, token := range tokens { + n, err := toNum(token) + if err == nil { + e.stackNum.push(n) + continue + } + + op, err := toOp(token) + if err != nil { + return Num{}, ExprError{"invalid expression", tokens, err} + } + + switch { + case op.hasOpenBracket(): + e.stackOp.push(op) + case op.isCloseBracket(), op.isComma(): + var stackTopOp operator + for len(e.stackOp.elems) > 0 { + stackTopOp = e.stackOp.peek() + if stackTopOp.hasOpenBracket() || stackTopOp.isComma() { + break + } + e.applyOp() + } + if op.isCloseBracket() { + nums := []Num{e.stackNum.pop()} + for !e.stackOp.peek().hasOpenBracket() { + stackTopOp = e.stackOp.pop() + if !stackTopOp.isComma() { + return Num{}, ExprError{"bracket doesn't match", tokens, nil} + } + nums = append(nums, e.stackNum.pop()) + } + for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 { + nums[i], nums[j] = nums[j], nums[i] // reverse nums slice, to get the right order for arguments + } + stackTopOp = e.stackOp.pop() + fn := string(stackTopOp[:len(stackTopOp)-1]) + if fn == "" { + if len(nums) != 1 { + return Num{}, ExprError{"too many values in one bracket", tokens, nil} + } + e.stackNum.push(nums[0]) + } else if f, ok := e.funcMap[fn]; ok { + e.stackNum.push(f(nums)) + } else { + return Num{}, ExprError{"unknown function: " + fn, tokens, nil} + } + } else { + e.stackOp.push(op) + } + default: + for len(e.stackOp.elems) > 0 && len(e.stackNum.elems) > 0 { + stackTopOp := e.stackOp.peek() + if stackTopOp.hasOpenBracket() || stackTopOp.isComma() || precedence(stackTopOp, op) < 0 { + break + } + e.applyOp() + } + e.stackOp.push(op) + } + } + for len(e.stackOp.elems) > 0 && !e.stackOp.peek().isComma() { + e.applyOp() + } + if len(e.stackNum.elems) != 1 { + return Num{}, ExprError{fmt.Sprintf("expect 1 value as final result, but there are %d", len(e.stackNum.elems)), tokens, nil} + } + return e.stackNum.pop(), nil +} + +func precedence(op1, op2 operator) int { + p1 := opPrecedence[string(op1)] + p2 := opPrecedence[string(op2)] + if p1 == 0 { + panic("unknown operator precedence: " + string(op1)) + } else if p2 == 0 { + panic("unknown operator precedence: " + string(op2)) + } + return p1 - p2 +} + +func castFloat64(nums []Num) bool { + hasFloat := false + for _, num := range nums { + if _, hasFloat = num.Value.(float64); hasFloat { + break + } + } + if hasFloat { + for i, num := range nums { + if _, ok := num.Value.(float64); !ok { + f, _ := util.ToFloat64(num.Value) + nums[i] = Num{f} + } + } + } + return hasFloat +} + +func fnSum(nums []Num) Num { + if castFloat64(nums) { + var sum float64 + for _, num := range nums { + sum += num.Value.(float64) + } + return Num{sum} + } + var sum int64 + for _, num := range nums { + sum += num.Value.(int64) + } + return Num{sum} +} + +// Expr evaluates the given expression tokens and returns the result. +// It supports the following operators: +, -, *, /, and, or, not, ==, !=, >, >=, <, <=. +// Non-zero values are treated as true, zero values are treated as false. +// If no error occurs, the result is either an int64 or a float64. +// If all numbers are integer, the result is an int64, otherwise if there is any float number, the result is a float64. +func Expr(tokens ...any) (Num, error) { + e := newEval() + e.funcMap = map[string]func([]Num) Num{"sum": fnSum} + return e.exec(tokens...) +} diff --git a/modules/templates/eval/eval_test.go b/modules/templates/eval/eval_test.go new file mode 100644 index 0000000..3e68203 --- /dev/null +++ b/modules/templates/eval/eval_test.go @@ -0,0 +1,94 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package eval + +import ( + "math" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func tokens(s string) (a []any) { + for _, v := range strings.Fields(s) { + a = append(a, v) + } + return a +} + +func TestEval(t *testing.T) { + n, err := Expr(0, "/", 0.0) + require.NoError(t, err) + assert.True(t, math.IsNaN(n.Value.(float64))) + + _, err = Expr(nil) + require.ErrorContains(t, err, "unsupported token type") + _, err = Expr([]string{}) + require.ErrorContains(t, err, "unsupported token type") + _, err = Expr(struct{}{}) + require.ErrorContains(t, err, "unsupported token type") + + cases := []struct { + expr string + want any + }{ + {"-1", int64(-1)}, + {"1 + 2", int64(3)}, + {"3 - 2 + 4", int64(5)}, + {"1 + 2 * 3", int64(7)}, + {"1 + ( 2 * 3 )", int64(7)}, + {"( 1 + 2 ) * 3", int64(9)}, + {"( 1 + 2.0 ) / 3", float64(1)}, + {"sum( 1 , 2 , 3 , 4 )", int64(10)}, + {"100 + sum( 1 , 2 + 3 , 0.0 ) / 2", float64(103)}, + {"100 * 5 / ( 5 + 15 )", int64(25)}, + {"9 == 5", int64(0)}, + {"5 == 5", int64(1)}, + {"9 != 5", int64(1)}, + {"5 != 5", int64(0)}, + {"9 > 5", int64(1)}, + {"5 > 9", int64(0)}, + {"5 >= 9", int64(0)}, + {"9 >= 9", int64(1)}, + {"9 < 5", int64(0)}, + {"5 < 9", int64(1)}, + {"9 <= 5", int64(0)}, + {"5 <= 5", int64(1)}, + {"1 and 2", int64(1)}, // Golang template definition: non-zero values are all truth + {"1 and 0", int64(0)}, + {"0 and 0", int64(0)}, + {"1 or 2", int64(1)}, + {"1 or 0", int64(1)}, + {"0 or 1", int64(1)}, + {"0 or 0", int64(0)}, + {"not 2 == 1", int64(1)}, + {"not not ( 9 < 5 )", int64(0)}, + } + + for _, c := range cases { + n, err := Expr(tokens(c.expr)...) + require.NoError(t, err, "expr: %s", c.expr) + assert.Equal(t, c.want, n.Value) + } + + bads := []struct { + expr string + errMsg string + }{ + {"0 / 0", "integer divide by zero"}, + {"1 +", "num stack is empty"}, + {"+ 1", "num stack is empty"}, + {"( 1", "incomplete sub-expression"}, + {"1 )", "op stack is empty"}, // can not find the corresponding open bracket after the stack becomes empty + {"1 , 2", "expect 1 value as final result"}, + {"( 1 , 2 )", "too many values in one bracket"}, + {"1 a 2", "unknown operator"}, + } + for _, c := range bads { + _, err = Expr(tokens(c.expr)...) + require.ErrorContains(t, err, c.errMsg, "expr: %s", c.expr) + } +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go new file mode 100644 index 0000000..aeae820 --- /dev/null +++ b/modules/templates/helper.go @@ -0,0 +1,269 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "fmt" + "html" + "html/template" + "net/url" + "slices" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/markup" + "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" +) + +// NewFuncMap returns functions for injecting to templates +func NewFuncMap() template.FuncMap { + return map[string]any{ + "ctx": func() any { return nil }, // template context function + + "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. + "Eval": Eval, + "SafeHTML": SafeHTML, + "HTMLFormat": HTMLFormat, + "HTMLEscape": HTMLEscape, + "QueryEscape": QueryEscape, + "JSEscape": JSEscapeSafe, + "SanitizeHTML": SanitizeHTML, + "URLJoin": util.URLJoin, + "DotEscape": DotEscape, + + "PathEscape": url.PathEscape, + "PathEscapeSegments": util.PathEscapeSegments, + + // utils + "StringUtils": NewStringUtils, + "SliceUtils": NewSliceUtils, + "JsonUtils": NewJsonUtils, + + // ----------------------------------------------------------------- + // svg / avatar / icon / color + "svg": svg.RenderHTML, + "EntryIcon": base.EntryIcon, + "MigrationIcon": MigrationIcon, + "ActionIcon": ActionIcon, + "SortArrow": SortArrow, + "ContrastColor": util.ContrastColor, + + // ----------------------------------------------------------------- + // time / number / format + "FileSize": FileSizePanic, + "CountFmt": base.FormatNumberSI, + "TimeSince": timeutil.TimeSince, + "TimeSinceUnix": timeutil.TimeSinceUnix, + "DateTime": timeutil.DateTime, + "Sec2Time": util.SecToTime, + "LoadTimes": func(startTime time.Time) string { + return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" + }, + + // ----------------------------------------------------------------- + // setting + "AppName": func() string { + return setting.AppName + }, + "AppSlogan": func() string { + return setting.AppSlogan + }, + "AppDisplayName": func() string { + return setting.AppDisplayName + }, + "AppSubUrl": func() string { + return setting.AppSubURL + }, + "AssetUrlPrefix": func() string { + return setting.StaticURLPrefix + "/assets" + }, + "AppUrl": func() string { + // The usage of AppUrl should be avoided as much as possible, + // because the AppURL(ROOT_URL) may not match user's visiting site and the ROOT_URL in app.ini may be incorrect. + // And it's difficult for Gitea to guess absolute URL correctly with zero configuration, + // because Gitea doesn't know whether the scheme is HTTP or HTTPS unless the reverse proxy could tell Gitea. + return setting.AppURL + }, + "AppVer": func() string { + return setting.AppVer + }, + "AppDomain": func() string { // documented in mail-templates.md + return setting.Domain + }, + "RepoFlagsEnabled": func() bool { + return setting.Repository.EnableFlags + }, + "AssetVersion": func() string { + return setting.AssetVersion + }, + "DefaultShowFullName": func() bool { + return setting.UI.DefaultShowFullName + }, + "ShowFooterTemplateLoadTime": func() bool { + return setting.Other.ShowFooterTemplateLoadTime + }, + "ShowFooterPoweredBy": func() bool { + return setting.Other.ShowFooterPoweredBy + }, + "AllowedReactions": func() []string { + return setting.UI.Reactions + }, + "CustomEmojis": func() map[string]string { + return setting.UI.CustomEmojisMap + }, + "MetaAuthor": func() string { + return setting.UI.Meta.Author + }, + "MetaDescription": func() string { + return setting.UI.Meta.Description + }, + "MetaKeywords": func() string { + return setting.UI.Meta.Keywords + }, + "EnableTimetracking": func() bool { + return setting.Service.EnableTimetracking + }, + "DisableGitHooks": func() bool { + return setting.DisableGitHooks + }, + "DisableWebhooks": func() bool { + return setting.DisableWebhooks + }, + "DisableImportLocal": func() bool { + return !setting.ImportLocalPaths + }, + "ThemeName": func(user *user_model.User) string { + if user == nil || user.Theme == "" { + return setting.UI.DefaultTheme + } + return user.Theme + }, + "NotificationSettings": func() map[string]any { + return map[string]any{ + "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond), + "TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond), + "MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond), + "EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond), + } + }, + "MermaidMaxSourceCharacters": func() int { + return setting.MermaidMaxSourceCharacters + }, + "FederationEnabled": func() bool { + return setting.Federation.Enabled + }, + + // ----------------------------------------------------------------- + // render + "RenderCommitMessage": RenderCommitMessage, + "RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject, + + "RenderCommitBody": RenderCommitBody, + "RenderCodeBlock": RenderCodeBlock, + "RenderIssueTitle": RenderIssueTitle, + "RenderRefIssueTitle": RenderRefIssueTitle, + "RenderEmoji": RenderEmoji, + "ReactionToEmoji": ReactionToEmoji, + + "RenderMarkdownToHtml": RenderMarkdownToHtml, + "RenderLabel": RenderLabel, + "RenderLabels": RenderLabels, + + // ----------------------------------------------------------------- + // misc + "ShortSha": base.ShortSha, + "ActionContent2Commits": ActionContent2Commits, + "IsMultilineCommitMessage": IsMultilineCommitMessage, + "CommentMustAsDiff": gitdiff.CommentMustAsDiff, + "MirrorRemoteAddress": mirrorRemoteAddress, + + "FilenameIsImage": FilenameIsImage, + "TabSizeClass": TabSizeClass, + } +} + +func HTMLFormat(s string, rawArgs ...any) template.HTML { + args := slices.Clone(rawArgs) + for i, v := range args { + switch v := v.(type) { + case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML: + // for most basic types (including template.HTML which is safe), just do nothing and use it + case string: + args[i] = template.HTMLEscapeString(v) + case fmt.Stringer: + args[i] = template.HTMLEscapeString(v.String()) + default: + args[i] = template.HTMLEscapeString(fmt.Sprint(v)) + } + } + return template.HTML(fmt.Sprintf(s, args...)) +} + +// SafeHTML render raw as HTML +func SafeHTML(s any) template.HTML { + switch v := s.(type) { + case string: + return template.HTML(v) + case template.HTML: + return v + } + panic(fmt.Sprintf("unexpected type %T", s)) +} + +// SanitizeHTML sanitizes the input by pre-defined markdown rules +func SanitizeHTML(s string) template.HTML { + return template.HTML(markup.Sanitize(s)) +} + +func HTMLEscape(s any) template.HTML { + switch v := s.(type) { + case string: + return template.HTML(html.EscapeString(v)) + case template.HTML: + return v + } + panic(fmt.Sprintf("unexpected type %T", s)) +} + +func JSEscapeSafe(s string) template.HTML { + return template.HTML(template.JSEscapeString(s)) +} + +func QueryEscape(s string) template.URL { + return template.URL(url.QueryEscape(s)) +} + +// DotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent autolinkers from detecting these as urls +func DotEscape(raw string) string { + return strings.ReplaceAll(raw, ".", "\u200d.\u200d") +} + +// 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. +// +// {{ $int64 := Eval $var "+" 1 }} +// {{ $float64 := Eval $var "+" 1.0 }} +// +// Golang's template supports comparable int types, so the int64 result can be used in later statements like {{if lt $int64 10}} +func Eval(tokens ...any) (any, error) { + n, err := eval.Expr(tokens...) + return n.Value, err +} + +func FileSizePanic(s int64) string { + panic("Usage of FileSize in templates is deprecated in Forgejo. Locale.TrSize should be used instead.") +} diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go new file mode 100644 index 0000000..0cefb7a --- /dev/null +++ b/modules/templates/helper_test.go @@ -0,0 +1,67 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "html/template" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubjectBodySeparator(t *testing.T) { + test := func(input, subject, body string) { + loc := mailSubjectSplit.FindIndex([]byte(input)) + if loc == nil { + assert.Empty(t, subject, "no subject found, but one expected") + assert.Equal(t, body, input) + } else { + assert.Equal(t, subject, input[0:loc[0]]) + assert.Equal(t, body, input[loc[1]:]) + } + } + + test("Simple\n---------------\nCase", + "Simple\n", + "\nCase") + test("Only\nBody", + "", + "Only\nBody") + test("Minimal\n---\nseparator", + "Minimal\n", + "\nseparator") + test("False --- separator", + "", + "False --- separator") + test("False\n--- separator", + "", + "False\n--- separator") + test("False ---\nseparator", + "", + "False ---\nseparator") + test("With extra spaces\n----- \t \nBody", + "With extra spaces\n", + "\nBody") + test("With leading spaces\n -------\nOnly body", + "", + "With leading spaces\n -------\nOnly body") + test("Multiple\n---\n-------\n---\nSeparators", + "Multiple\n", + "\n-------\n---\nSeparators") + test("Insufficient\n--\nSeparators", + "", + "Insufficient\n--\nSeparators") +} + +func TestJSEscapeSafe(t *testing.T) { + assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, JSEscapeSafe(`&<>'"`)) +} + +func TestHTMLFormat(t *testing.T) { + assert.Equal(t, template.HTML("<a>< < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1)) +} + +func TestSanitizeHTML(t *testing.T) { + assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`)) +} diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go new file mode 100644 index 0000000..55a55dd --- /dev/null +++ b/modules/templates/htmlrenderer.go @@ -0,0 +1,287 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + texttemplate "text/template" + + "code.gitea.io/gitea/modules/assetfs" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates/scopedtmpl" + "code.gitea.io/gitea/modules/util" +) + +type TemplateExecutor scopedtmpl.TemplateExecutor + +type HTMLRender struct { + templates atomic.Pointer[scopedtmpl.ScopedTemplate] +} + +var ( + htmlRender *HTMLRender + htmlRenderOnce sync.Once +) + +var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors") + +func (h *HTMLRender) HTML(w io.Writer, status int, name string, data any, ctx context.Context) error { //nolint:revive + if respWriter, ok := w.(http.ResponseWriter); ok { + if respWriter.Header().Get("Content-Type") == "" { + respWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + } + respWriter.WriteHeader(status) + } + t, err := h.TemplateLookup(name, ctx) + if err != nil { + return texttemplate.ExecError{Name: name, Err: err} + } + return t.Execute(w, data) +} + +func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive + tmpls := h.templates.Load() + if tmpls == nil { + return nil, ErrTemplateNotInitialized + } + m := NewFuncMap() + m["ctx"] = func() any { return ctx } + return tmpls.Executor(name, m) +} + +func (h *HTMLRender) CompileTemplates() error { + assets := AssetFS() + extSuffix := ".tmpl" + tmpls := scopedtmpl.NewScopedTemplate() + tmpls.Funcs(NewFuncMap()) + files, err := ListWebTemplateAssetNames(assets) + if err != nil { + return nil + } + for _, file := range files { + if !strings.HasSuffix(file, extSuffix) { + continue + } + name := strings.TrimSuffix(file, extSuffix) + tmpl := tmpls.New(filepath.ToSlash(name)) + buf, err := assets.ReadFile(file) + if err != nil { + return err + } + if _, err = tmpl.Parse(string(buf)); err != nil { + return err + } + } + tmpls.Freeze() + h.templates.Store(tmpls) + return nil +} + +// HTMLRenderer init once and returns the globally shared html renderer +func HTMLRenderer() *HTMLRender { + htmlRenderOnce.Do(initHTMLRenderer) + return htmlRender +} + +func ReloadHTMLTemplates() error { + log.Trace("Reloading HTML templates") + if err := htmlRender.CompileTemplates(); err != nil { + log.Error("Template error: %v\n%s", err, log.Stack(2)) + return err + } + return nil +} + +func initHTMLRenderer() { + rendererType := "static" + if !setting.IsProd { + rendererType = "auto-reloading" + } + log.Debug("Creating %s HTML Renderer", rendererType) + + htmlRender = &HTMLRender{} + if err := htmlRender.CompileTemplates(); err != nil { + p := &templateErrorPrettier{assets: AssetFS()} + wrapTmplErrMsg(p.handleFuncNotDefinedError(err)) + wrapTmplErrMsg(p.handleUnexpectedOperandError(err)) + wrapTmplErrMsg(p.handleExpectedEndError(err)) + wrapTmplErrMsg(p.handleGenericTemplateError(err)) + wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err)) + } + + if !setting.IsProd { + go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() { + _ = ReloadHTMLTemplates() + }) + } +} + +func wrapTmplErrMsg(msg string) { + if msg == "" { + return + } + if setting.IsProd { + // in prod mode, Forgejo must have correct templates to run + log.Fatal("Forgejo can't run with template errors: %s", msg) + } + // in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded + log.Error("There are template errors but Forgejo continues to run in dev mode: %s", msg) +} + +type templateErrorPrettier struct { + assets *assetfs.LayeredFS +} + +var reGenericTemplateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`) + +func (p *templateErrorPrettier) handleGenericTemplateError(err error) string { + groups := reGenericTemplateError.FindStringSubmatch(err.Error()) + if len(groups) != 4 { + return "" + } + tmplName, lineStr, message := groups[1], groups[2], groups[3] + return p.makeDetailedError(message, tmplName, lineStr, -1, "") +} + +var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`) + +func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string { + groups := reFuncNotDefinedError.FindStringSubmatch(err.Error()) + if len(groups) != 5 { + return "" + } + tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4] + funcName, _ = strconv.Unquote(`"` + funcName + `"`) + return p.makeDetailedError(message, tmplName, lineStr, -1, funcName) +} + +var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`) + +func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string { + groups := reUnexpectedOperandError.FindStringSubmatch(err.Error()) + if len(groups) != 5 { + return "" + } + tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4] + unexpected, _ = strconv.Unquote(`"` + unexpected + `"`) + return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected) +} + +var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`) + +func (p *templateErrorPrettier) handleExpectedEndError(err error) string { + groups := reExpectedEndError.FindStringSubmatch(err.Error()) + if len(groups) != 5 { + return "" + } + tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4] + return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected) +} + +var ( + reTemplateExecutingError = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): (executing .*)`) + reTemplateExecutingErrorMsg = regexp.MustCompile(`^executing "(.*)" at <(.*)>: `) +) + +func (p *templateErrorPrettier) handleTemplateRenderingError(err error) string { + if groups := reTemplateExecutingError.FindStringSubmatch(err.Error()); len(groups) > 0 { + tmplName, lineStr, posStr, msgPart := groups[1], groups[2], groups[3], groups[4] + target := "" + if groups = reTemplateExecutingErrorMsg.FindStringSubmatch(msgPart); len(groups) > 0 { + target = groups[2] + } + return p.makeDetailedError(msgPart, tmplName, lineStr, posStr, target) + } else if execErr, ok := err.(texttemplate.ExecError); ok { + layerName := p.assets.GetFileLayerName(execErr.Name + ".tmpl") + return fmt.Sprintf("asset from: %s, %s", layerName, err.Error()) + } + return err.Error() +} + +func HandleTemplateRenderingError(err error) string { + p := &templateErrorPrettier{assets: AssetFS()} + return p.handleTemplateRenderingError(err) +} + +const dashSeparator = "----------------------------------------------------------------------" + +func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName string, lineNum, posNum any, target string) string { + code, layer, err := p.assets.ReadLayeredFile(tmplName + ".tmpl") + if err != nil { + return fmt.Sprintf("template error: %s, and unable to find template file %q", errMsg, tmplName) + } + line, err := util.ToInt64(lineNum) + if err != nil { + return fmt.Sprintf("template error: %s, unable to parse template %q line number %q", errMsg, tmplName, lineNum) + } + pos, err := util.ToInt64(posNum) + if err != nil { + return fmt.Sprintf("template error: %s, unable to parse template %q pos number %q", errMsg, tmplName, posNum) + } + detail := extractErrorLine(code, int(line), int(pos), target) + + var msg string + if pos >= 0 { + msg = fmt.Sprintf("template error: %s:%s:%d:%d : %s", layer, tmplName, line, pos, errMsg) + } else { + msg = fmt.Sprintf("template error: %s:%s:%d : %s", layer, tmplName, line, errMsg) + } + return msg + "\n" + dashSeparator + "\n" + detail + "\n" + dashSeparator +} + +func extractErrorLine(code []byte, lineNum, posNum int, target string) string { + b := bufio.NewReader(bytes.NewReader(code)) + var line []byte + var err error + for i := 0; i < lineNum; i++ { + if line, err = b.ReadBytes('\n'); err != nil { + if i == lineNum-1 && errors.Is(err, io.EOF) { + err = nil + } + break + } + } + if err != nil { + return fmt.Sprintf("unable to find target line %d", lineNum) + } + + line = bytes.TrimRight(line, "\r\n") + var indicatorLine []byte + targetBytes := []byte(target) + targetLen := len(targetBytes) + for i := 0; i < len(line); { + if posNum == -1 && target != "" && bytes.HasPrefix(line[i:], targetBytes) { + for j := 0; j < targetLen && i < len(line); j++ { + indicatorLine = append(indicatorLine, '^') + i++ + } + } else if i == posNum { + indicatorLine = append(indicatorLine, '^') + i++ + } else { + if line[i] == '\t' { + indicatorLine = append(indicatorLine, '\t') + } else { + indicatorLine = append(indicatorLine, ' ') + } + i++ + } + } + // if the indicatorLine only contains spaces, trim it together + return strings.TrimRight(string(line)+"\n"+string(indicatorLine), " \t\r\n") +} diff --git a/modules/templates/htmlrenderer_test.go b/modules/templates/htmlrenderer_test.go new file mode 100644 index 0000000..a1d3783 --- /dev/null +++ b/modules/templates/htmlrenderer_test.go @@ -0,0 +1,107 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "errors" + "html/template" + "os" + "strings" + "testing" + + "code.gitea.io/gitea/modules/assetfs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractErrorLine(t *testing.T) { + cases := []struct { + code string + line int + pos int + target string + expect string + }{ + {"hello world\nfoo bar foo bar\ntest", 2, -1, "bar", ` +foo bar foo bar + ^^^ ^^^ +`}, + + {"hello world\nfoo bar foo bar\ntest", 2, 4, "bar", ` +foo bar foo bar + ^ +`}, + + { + "hello world\nfoo bar foo bar\ntest", 2, 4, "", + ` +foo bar foo bar + ^ +`, + }, + + { + "hello world\nfoo bar foo bar\ntest", 5, 0, "", + `unable to find target line 5`, + }, + } + + for _, c := range cases { + actual := extractErrorLine([]byte(c.code), c.line, c.pos, c.target) + assert.Equal(t, strings.TrimSpace(c.expect), strings.TrimSpace(actual)) + } +} + +func TestHandleError(t *testing.T) { + dir := t.TempDir() + + p := &templateErrorPrettier{assets: assetfs.Layered(assetfs.Local("tmp", dir))} + + test := func(s string, h func(error) string, expect string) { + err := os.WriteFile(dir+"/test.tmpl", []byte(s), 0o644) + require.NoError(t, err) + tmpl := template.New("test") + _, err = tmpl.Parse(s) + require.Error(t, err) + msg := h(err) + assert.EqualValues(t, strings.TrimSpace(expect), strings.TrimSpace(msg)) + } + + test("{{", p.handleGenericTemplateError, ` +template error: tmp:test:1 : unclosed action +---------------------------------------------------------------------- +{{ +---------------------------------------------------------------------- +`) + + test("{{Func}}", p.handleFuncNotDefinedError, ` +template error: tmp:test:1 : function "Func" not defined +---------------------------------------------------------------------- +{{Func}} + ^^^^ +---------------------------------------------------------------------- +`) + + test("{{'x'3}}", p.handleUnexpectedOperandError, ` +template error: tmp:test:1 : unexpected "3" in operand +---------------------------------------------------------------------- +{{'x'3}} + ^ +---------------------------------------------------------------------- +`) + + // no idea about how to trigger such strange error, so mock an error to test it + err := os.WriteFile(dir+"/test.tmpl", []byte("god knows XXX"), 0o644) + require.NoError(t, err) + expectedMsg := ` +template error: tmp:test:1 : expected end; found XXX +---------------------------------------------------------------------- +god knows XXX + ^^^ +---------------------------------------------------------------------- +` + actualMsg := p.handleExpectedEndError(errors.New("template: test:1: expected end; found XXX")) + assert.EqualValues(t, strings.TrimSpace(expectedMsg), strings.TrimSpace(actualMsg)) +} diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go new file mode 100644 index 0000000..ee79755 --- /dev/null +++ b/modules/templates/mailer.go @@ -0,0 +1,110 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "fmt" + "html/template" + "regexp" + "strings" + texttmpl "text/template" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`) + +// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject +func mailSubjectTextFuncMap() texttmpl.FuncMap { + return texttmpl.FuncMap{ + "dict": dict, + "Eval": Eval, + + "EllipsisString": base.EllipsisString, + "AppName": func() string { + return setting.AppName + }, + "AppSlogan": func() string { + return setting.AppSlogan + }, + "AppDisplayName": func() string { + return setting.AppDisplayName + }, + "AppDomain": func() string { // documented in mail-templates.md + return setting.Domain + }, + } +} + +func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error { + // Split template into subject and body + var subjectContent []byte + bodyContent := content + loc := mailSubjectSplit.FindIndex(content) + if loc != nil { + subjectContent = content[0:loc[0]] + bodyContent = content[loc[1]:] + } + if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil { + return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err) + } + if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil { + return fmt.Errorf("failed to parse template [%s/body]: %w", name, err) + } + return nil +} + +// Mailer provides the templates required for sending notification mails. +func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) { + subjectTemplates := texttmpl.New("") + bodyTemplates := template.New("") + + subjectTemplates.Funcs(mailSubjectTextFuncMap()) + bodyTemplates.Funcs(NewFuncMap()) + + assetFS := AssetFS() + refreshTemplates := func(firstRun bool) { + if !firstRun { + log.Trace("Reloading mail templates") + } + assetPaths, err := ListMailTemplateAssetNames(assetFS) + if err != nil { + log.Error("Failed to list mail templates: %v", err) + return + } + + for _, assetPath := range assetPaths { + content, layerName, err := assetFS.ReadLayeredFile(assetPath) + if err != nil { + log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err) + continue + } + tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/") + if firstRun { + log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName) + } + if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil { + if firstRun { + log.Fatal("Failed to parse mail template, err: %v", err) + } + log.Error("Failed to parse mail template, err: %v", err) + } + } + } + + refreshTemplates(true) + + if !setting.IsProd { + // Now subjectTemplates and bodyTemplates are both synchronized + // thus it is safe to call refresh from a different goroutine + go assetFS.WatchLocalChanges(ctx, func() { + refreshTemplates(false) + }) + } + + return subjectTemplates, bodyTemplates +} diff --git a/modules/templates/main_test.go b/modules/templates/main_test.go new file mode 100644 index 0000000..bbdf5d2 --- /dev/null +++ b/modules/templates/main_test.go @@ -0,0 +1,24 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates_test + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/markup" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/issues" +) + +func TestMain(m *testing.M) { + markup.Init(&markup.ProcessorHelper{ + IsUsernameMentionable: func(ctx context.Context, username string) bool { + return username == "mention-user" + }, + }) + unittest.MainTest(m) +} diff --git a/modules/templates/scopedtmpl/scopedtmpl.go b/modules/templates/scopedtmpl/scopedtmpl.go new file mode 100644 index 0000000..2722ba9 --- /dev/null +++ b/modules/templates/scopedtmpl/scopedtmpl.go @@ -0,0 +1,239 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package scopedtmpl + +import ( + "fmt" + "html/template" + "io" + "reflect" + "sync" + texttemplate "text/template" + "text/template/parse" + "unsafe" +) + +type TemplateExecutor interface { + Execute(wr io.Writer, data any) error +} + +type ScopedTemplate struct { + all *template.Template + parseFuncs template.FuncMap // this func map is only used for parsing templates + frozen bool + + scopedMu sync.RWMutex + scopedTemplateSets map[string]*scopedTemplateSet +} + +func NewScopedTemplate() *ScopedTemplate { + return &ScopedTemplate{ + all: template.New(""), + parseFuncs: template.FuncMap{}, + scopedTemplateSets: map[string]*scopedTemplateSet{}, + } +} + +func (t *ScopedTemplate) Funcs(funcMap template.FuncMap) { + if t.frozen { + panic("cannot add new functions to frozen template set") + } + t.all.Funcs(funcMap) + for k, v := range funcMap { + t.parseFuncs[k] = v + } +} + +func (t *ScopedTemplate) New(name string) *template.Template { + if t.frozen { + panic("cannot add new template to frozen template set") + } + return t.all.New(name) +} + +func (t *ScopedTemplate) Freeze() { + t.frozen = true + // reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping + m := template.FuncMap{} + for k := range t.parseFuncs { + m[k] = func(v ...any) any { return nil } + } + t.all.Funcs(m) +} + +func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) { + t.scopedMu.RLock() + scopedTmplSet, ok := t.scopedTemplateSets[name] + t.scopedMu.RUnlock() + + if !ok { + var err error + t.scopedMu.Lock() + if scopedTmplSet, ok = t.scopedTemplateSets[name]; !ok { + if scopedTmplSet, err = newScopedTemplateSet(t.all, name); err == nil { + t.scopedTemplateSets[name] = scopedTmplSet + } + } + t.scopedMu.Unlock() + if err != nil { + return nil, err + } + } + + if scopedTmplSet == nil { + return nil, fmt.Errorf("template %s not found", name) + } + return scopedTmplSet.newExecutor(funcMap), nil +} + +type scopedTemplateSet struct { + name string + htmlTemplates map[string]*template.Template + textTemplates map[string]*texttemplate.Template + execFuncs map[string]reflect.Value +} + +func escapeTemplate(t *template.Template) error { + // force the Golang HTML template to complete the escaping work + err := t.Execute(io.Discard, nil) + if _, ok := err.(*template.Error); ok { + return err + } + return nil +} + +//nolint:unused +type htmlTemplate struct { + escapeErr error + text *texttemplate.Template +} + +//nolint:unused +type textTemplateCommon struct { + tmpl map[string]*template.Template // Map from name to defined templates. + muTmpl sync.RWMutex // protects tmpl + option struct { + missingKey int + } + muFuncs sync.RWMutex // protects parseFuncs and execFuncs + parseFuncs texttemplate.FuncMap + execFuncs map[string]reflect.Value +} + +//nolint:unused +type textTemplate struct { + name string + *parse.Tree + *textTemplateCommon + leftDelim string + rightDelim string +} + +func ptr[T, P any](ptr *P) *T { + // https://pkg.go.dev/unsafe#Pointer + // (1) Conversion of a *T1 to Pointer to *T2. + // Provided that T2 is no larger than T1 and that the two share an equivalent memory layout, + // this conversion allows reinterpreting data of one type as data of another type. + return (*T)(unsafe.Pointer(ptr)) +} + +func newScopedTemplateSet(all *template.Template, name string) (*scopedTemplateSet, error) { + targetTmpl := all.Lookup(name) + if targetTmpl == nil { + return nil, fmt.Errorf("template %q not found", name) + } + if err := escapeTemplate(targetTmpl); err != nil { + return nil, fmt.Errorf("template %q has an error when escaping: %w", name, err) + } + + ts := &scopedTemplateSet{ + name: name, + htmlTemplates: map[string]*template.Template{}, + textTemplates: map[string]*texttemplate.Template{}, + } + + htmlTmpl := ptr[htmlTemplate](all) + textTmpl := htmlTmpl.text + textTmplPtr := ptr[textTemplate](textTmpl) + + textTmplPtr.muFuncs.Lock() + ts.execFuncs = map[string]reflect.Value{} + for k, v := range textTmplPtr.execFuncs { + ts.execFuncs[k] = v + } + textTmplPtr.muFuncs.Unlock() + + var collectTemplates func(nodes []parse.Node) + var collectErr error // only need to collect the one error + collectTemplates = func(nodes []parse.Node) { + for _, node := range nodes { + if node.Type() == parse.NodeTemplate { + nodeTemplate := node.(*parse.TemplateNode) + subName := nodeTemplate.Name + if ts.htmlTemplates[subName] == nil { + subTmpl := all.Lookup(subName) + if subTmpl == nil { + // HTML template will add some internal templates like "$delimDoubleQuote" into the text template + ts.textTemplates[subName] = textTmpl.Lookup(subName) + } else if subTmpl.Tree == nil || subTmpl.Tree.Root == nil { + collectErr = fmt.Errorf("template %q has no tree, it's usually caused by broken templates", subName) + } else { + ts.htmlTemplates[subName] = subTmpl + if err := escapeTemplate(subTmpl); err != nil { + collectErr = fmt.Errorf("template %q has an error when escaping: %w", subName, err) + return + } + collectTemplates(subTmpl.Tree.Root.Nodes) + } + } + } else if node.Type() == parse.NodeList { + nodeList := node.(*parse.ListNode) + collectTemplates(nodeList.Nodes) + } else if node.Type() == parse.NodeIf { + nodeIf := node.(*parse.IfNode) + collectTemplates(nodeIf.BranchNode.List.Nodes) + if nodeIf.BranchNode.ElseList != nil { + collectTemplates(nodeIf.BranchNode.ElseList.Nodes) + } + } else if node.Type() == parse.NodeRange { + nodeRange := node.(*parse.RangeNode) + collectTemplates(nodeRange.BranchNode.List.Nodes) + if nodeRange.BranchNode.ElseList != nil { + collectTemplates(nodeRange.BranchNode.ElseList.Nodes) + } + } else if node.Type() == parse.NodeWith { + nodeWith := node.(*parse.WithNode) + collectTemplates(nodeWith.BranchNode.List.Nodes) + if nodeWith.BranchNode.ElseList != nil { + collectTemplates(nodeWith.BranchNode.ElseList.Nodes) + } + } + } + } + ts.htmlTemplates[name] = targetTmpl + collectTemplates(targetTmpl.Tree.Root.Nodes) + return ts, collectErr +} + +func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor { + tmpl := texttemplate.New("") + tmplPtr := ptr[textTemplate](tmpl) + tmplPtr.execFuncs = map[string]reflect.Value{} + for k, v := range ts.execFuncs { + tmplPtr.execFuncs[k] = v + } + if funcMap != nil { + tmpl.Funcs(funcMap) + } + // after escapeTemplate, the html templates are also escaped text templates, so it could be added to the text template directly + for _, t := range ts.htmlTemplates { + _, _ = tmpl.AddParseTree(t.Name(), t.Tree) + } + for _, t := range ts.textTemplates { + _, _ = tmpl.AddParseTree(t.Name(), t.Tree) + } + + // now the text template has all necessary escaped templates, so we can safely execute, just like what the html template does + return tmpl.Lookup(ts.name) +} diff --git a/modules/templates/scopedtmpl/scopedtmpl_test.go b/modules/templates/scopedtmpl/scopedtmpl_test.go new file mode 100644 index 0000000..9bbd0c7 --- /dev/null +++ b/modules/templates/scopedtmpl/scopedtmpl_test.go @@ -0,0 +1,99 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package scopedtmpl + +import ( + "bytes" + "html/template" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScopedTemplateSetFuncMap(t *testing.T) { + all := template.New("") + + all.Funcs(template.FuncMap{"CtxFunc": func(s string) string { + return "default" + }}) + + _, err := all.New("base").Parse(`{{CtxFunc "base"}}`) + require.NoError(t, err) + + _, err = all.New("test").Parse(strings.TrimSpace(` +{{template "base"}} +{{CtxFunc "test"}} +{{template "base"}} +{{CtxFunc "test"}} +`)) + require.NoError(t, err) + + ts, err := newScopedTemplateSet(all, "test") + require.NoError(t, err) + + // try to use different CtxFunc to render concurrently + + funcMap1 := template.FuncMap{ + "CtxFunc": func(s string) string { + time.Sleep(100 * time.Millisecond) + return s + "1" + }, + } + + funcMap2 := template.FuncMap{ + "CtxFunc": func(s string) string { + time.Sleep(100 * time.Millisecond) + return s + "2" + }, + } + + out1 := bytes.Buffer{} + out2 := bytes.Buffer{} + wg := sync.WaitGroup{} + wg.Add(2) + go func() { + err := ts.newExecutor(funcMap1).Execute(&out1, nil) + require.NoError(t, err) + wg.Done() + }() + go func() { + err := ts.newExecutor(funcMap2).Execute(&out2, nil) + require.NoError(t, err) + wg.Done() + }() + wg.Wait() + assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String()) + assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String()) +} + +func TestScopedTemplateSetEscape(t *testing.T) { + all := template.New("") + _, err := all.New("base").Parse(`<a href="?q={{.param}}">{{.text}}</a>`) + require.NoError(t, err) + + _, err = all.New("test").Parse(`{{template "base" .}}<form action="?q={{.param}}">{{.text}}</form>`) + require.NoError(t, err) + + ts, err := newScopedTemplateSet(all, "test") + require.NoError(t, err) + + out := bytes.Buffer{} + err = ts.newExecutor(nil).Execute(&out, map[string]string{"param": "/", "text": "<"}) + require.NoError(t, err) + + assert.Equal(t, `<a href="?q=%2f"><</a><form action="?q=%2f"><</form>`, out.String()) +} + +func TestScopedTemplateSetUnsafe(t *testing.T) { + all := template.New("") + _, err := all.New("test").Parse(`<a href="{{if true}}?{{end}}a={{.param}}"></a>`) + require.NoError(t, err) + + _, err = newScopedTemplateSet(all, "test") + require.ErrorContains(t, err, "appears in an ambiguous context within a URL") +} diff --git a/modules/templates/static.go b/modules/templates/static.go new file mode 100644 index 0000000..b5a7e56 --- /dev/null +++ b/modules/templates/static.go @@ -0,0 +1,22 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build bindata + +package templates + +import ( + "time" + + "code.gitea.io/gitea/modules/assetfs" + "code.gitea.io/gitea/modules/timeutil" +) + +// GlobalModTime provide a global mod time for embedded asset files +func GlobalModTime(filename string) time.Time { + return timeutil.GetExecutableModTime() +} + +func BuiltinAssets() *assetfs.Layer { + return assetfs.Bindata("builtin(bindata)", Assets) +} diff --git a/modules/templates/templates_bindata.go b/modules/templates/templates_bindata.go new file mode 100644 index 0000000..6f1d3cf --- /dev/null +++ b/modules/templates/templates_bindata.go @@ -0,0 +1,8 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build bindata + +package templates + +//go:generate go run ../../build/generate-bindata.go ../../templates templates bindata.go true diff --git a/modules/templates/util_avatar.go b/modules/templates/util_avatar.go new file mode 100644 index 0000000..afc1091 --- /dev/null +++ b/modules/templates/util_avatar.go @@ -0,0 +1,81 @@ +// 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" +) + +type AvatarUtils struct { + ctx context.Context +} + +func NewAvatarUtils(ctx context.Context) *AvatarUtils { + return &AvatarUtils{ctx: ctx} +} + +// 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 loading="lazy" class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`) +} + +// Avatar renders user avatars. args: user, size (int), class (string) +func (au *AvatarUtils) Avatar(item any, others ...any) template.HTML { + size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) + + switch t := item.(type) { + case *user_model.User: + src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.DisplayName()) + } + case *repo_model.Collaborator: + src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.DisplayName()) + } + case *organization.Organization: + src := t.AsUser().AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor) + if src != "" { + return AvatarHTML(src, size, class, t.AsUser().DisplayName()) + } + } + + return AvatarHTML(avatars.DefaultAvatarLink(), size, class, "") +} + +// AvatarByAction renders user avatars from action. args: action, size (int), class (string) +func (au *AvatarUtils) AvatarByAction(action *activities_model.Action, others ...any) template.HTML { + action.LoadActUser(au.ctx) + return au.Avatar(action.ActUser, others...) +} + +// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) +func (au *AvatarUtils) AvatarByEmail(email, name string, others ...any) template.HTML { + size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) + src := avatars.GenerateEmailAvatarFastLink(au.ctx, email, size*setting.Avatar.RenderedSizeFactor) + + if src != "" { + return AvatarHTML(src, size, class, name) + } + + return "" +} diff --git a/modules/templates/util_dict.go b/modules/templates/util_dict.go new file mode 100644 index 0000000..8d6376b --- /dev/null +++ b/modules/templates/util_dict.go @@ -0,0 +1,121 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "fmt" + "html" + "html/template" + "reflect" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" +) + +func dictMerge(base map[string]any, arg any) bool { + if arg == nil { + return true + } + rv := reflect.ValueOf(arg) + if rv.Kind() == reflect.Map { + for _, k := range rv.MapKeys() { + base[k.String()] = rv.MapIndex(k).Interface() + } + return true + } + return false +} + +// dict is a helper function for creating a map[string]any from a list of key-value pairs. +// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current +// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys. +func dict(args ...any) (map[string]any, error) { + if len(args)%2 != 0 { + return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs") + } + m := make(map[string]any, len(args)/2) + for i := 0; i < len(args); i += 2 { + key, ok := args[i].(string) + if !ok { + return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i) + } + if key == "." { + if ok = dictMerge(m, args[i+1]); !ok { + return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i) + } + } else { + m[key] = args[i+1] + } + } + return m, nil +} + +func dumpVarMarshalable(v any, dumped container.Set[uintptr]) (ret any, ok bool) { + if v == nil { + return nil, true + } + e := reflect.ValueOf(v) + for e.Kind() == reflect.Pointer { + e = e.Elem() + } + if e.CanAddr() { + addr := e.UnsafeAddr() + if !dumped.Add(addr) { + return "[dumped]", false + } + defer dumped.Remove(addr) + } + switch e.Kind() { + case reflect.Bool, reflect.String, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return e.Interface(), true + case reflect.Struct: + m := map[string]any{} + for i := 0; i < e.NumField(); i++ { + k := e.Type().Field(i).Name + if !e.Type().Field(i).IsExported() { + continue + } + v := e.Field(i).Interface() + m[k], _ = dumpVarMarshalable(v, dumped) + } + return m, true + case reflect.Map: + m := map[string]any{} + for _, k := range e.MapKeys() { + m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped) + } + return m, true + case reflect.Array, reflect.Slice: + var m []any + for i := 0; i < e.Len(); i++ { + v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped) + m = append(m, v) + } + return m, true + default: + return "[" + reflect.TypeOf(v).String() + "]", false + } +} + +// dumpVar helps to dump a variable in a template, to help debugging and development. +func dumpVar(v any) template.HTML { + if setting.IsProd { + return "<pre>dumpVar: only available in dev mode</pre>" + } + m, ok := dumpVarMarshalable(v, make(container.Set[uintptr])) + var dumpStr string + jsonBytes, err := json.MarshalIndent(m, "", " ") + if err != nil { + dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err) + } else if ok { + dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes)) + } else { + dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes)) + } + return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>") +} diff --git a/modules/templates/util_json.go b/modules/templates/util_json.go new file mode 100644 index 0000000..71a4e23 --- /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 0000000..7743854 --- /dev/null +++ b/modules/templates/util_misc.go @@ -0,0 +1,193 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "html/template" + "mime" + "path/filepath" + "strconv" + "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(ctx context.Context) string + GetRepoUserName(ctx context.Context) string + GetRepoName(ctx context.Context) string + GetRepoPath(ctx context.Context) string + GetRepoLink(ctx context.Context) 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: + return "git-commit" + case activities_model.ActionDeleteBranch: + return "git-branch" + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + return "git-merge" + case activities_model.ActionCreatePullRequest: + return "git-pull-request" + case activities_model.ActionClosePullRequest: + return "git-pull-request-closed" + case activities_model.ActionCreateIssue: + return "issue-opened" + case activities_model.ActionCloseIssue: + return "issue-closed" + case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: + return "issue-reopened" + case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: + return "comment-discussion" + case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete: + return "mirror" + case activities_model.ActionApprovePullRequest: + return "check" + case activities_model.ActionRejectPullRequest: + return "file-diff" + case activities_model.ActionPublishRelease, activities_model.ActionPushTag, activities_model.ActionDeleteTag: + 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 +} + +// 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) remoteAddress { + ret := remoteAddress{} + remoteURL, err := git.GetRemoteAddress(ctx, m.RepoPath(), remoteName) + if err != nil { + log.Error("GetRemoteURL %v", err) + return ret + } + + u, err := giturl.Parse(remoteURL) + if err != nil { + log.Error("giturl.Parse %v", err) + return ret + } + + if u.Scheme != "ssh" && u.Scheme != "file" { + if u.User != nil { + ret.Username = u.User.Username() + ret.Password, _ = u.User.Password() + } + } + + // The URL stored in the git repo could contain authentication, + // erase it, or it will be shown in the UI. + u.User = nil + ret.Address = u.String() + // Why not use m.OriginalURL to set ret.Address? + // It should be OK to use it, since m.OriginalURL should be the same as the authentication-erased URL from the Git repository. + // However, the old code has already stored authentication in m.OriginalURL when updating mirror settings. + // That means we need to use "giturl.Parse" for m.OriginalURL again to ensure authentication is erased. + // Instead of doing this, why not directly use the authentication-erased URL from the Git repository? + // It should be the same as long as there are no bugs. + + return ret +} + +func FilenameIsImage(filename string) bool { + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + return strings.HasPrefix(mimeType, "image/") +} + +func TabSizeClass(ec *editorconfig.Editorconfig, filename string) string { + if ec != nil { + def, err := ec.GetDefinitionForFilename(filename) + if err == nil && def.TabWidth >= 1 && def.TabWidth <= 16 { + return "tab-size-" + strconv.Itoa(def.TabWidth) + } + } + return "tab-size-4" +} diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go new file mode 100644 index 0000000..c53bdd8 --- /dev/null +++ b/modules/templates/util_render.go @@ -0,0 +1,264 @@ +// 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" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" +) + +// RenderCommitMessage renders commit message with XSS-safe and special links. +func RenderCommitMessage(ctx context.Context, msg 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, + 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 RenderCodeBlock(template.HTML(msgLines[0])) +} + +// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to +// the provided default url, handling for special links without email to links. +func RenderCommitMessageLinkSubject(ctx context.Context, msg, 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, + DefaultLink: urlDefault, + Metas: metas, + }, template.HTMLEscapeString(msgLine)) + if err != nil { + log.Error("RenderCommitMessageSubject: %v", err) + return template.HTML("") + } + return RenderCodeBlock(template.HTML(renderedMessage)) +} + +// RenderCommitBody extracts the body of a commit message without its title. +func RenderCommitBody(ctx context.Context, msg string, metas map[string]string) template.HTML { + msgLine := strings.TrimSpace(msg) + lineEnd := strings.IndexByte(msgLine, '\n') + if lineEnd > 0 { + msgLine = msgLine[lineEnd+1:] + } else { + return "" + } + msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace) + if len(msgLine) == 0 { + return "" + } + + renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ + Ctx: ctx, + 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 +func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML { + htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags + return template.HTML(htmlWithCodeTags) +} + +const ( + activeLabelOpacity = uint8(255) + archivedLabelOpacity = uint8(127) +) + +func GetLabelOpacityByte(isArchived bool) uint8 { + if isArchived { + return archivedLabelOpacity + } + return activeLabelOpacity +} + +// RenderIssueTitle renders issue/pull title with defined post processors +func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML { + renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ + Ctx: ctx, + Metas: metas, + }, template.HTMLEscapeString(text)) + if err != nil { + log.Error("RenderIssueTitle: %v", err) + return template.HTML("") + } + return template.HTML(renderedText) +} + +// RenderRefIssueTitle renders referenced issue/pull title with defined post processors +func RenderRefIssueTitle(ctx context.Context, text string) template.HTML { + renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text)) + if err != nil { + log.Error("RenderRefIssueTitle: %v", err) + return "" + } + + return template.HTML(renderedText) +} + +// RenderLabel renders a label +// locale is needed due to an import cycle with our context providing the `Tr` function +func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { + var ( + archivedCSSClass string + textColor = util.ContrastColor(label.Color) + labelScope = label.ExclusiveScope() + ) + + description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) + + if label.IsArchived() { + archivedCSSClass = "archived-label" + description = locale.TrString("repo.issues.archived_label_description", description) + } + + if labelScope == "" { + // Regular label + + labelColor := label.Color + hex.EncodeToString([]byte{GetLabelOpacityByte(label.IsArchived())}) + s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>", + archivedCSSClass, textColor, labelColor, description, RenderEmoji(ctx, label.Name)) + return template.HTML(s) + } + + // Scoped label + scopeText := RenderEmoji(ctx, labelScope) + itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) + + // Make scope and item background colors slightly darker and lighter respectively. + // More contrast needed with higher luminance, empirically tweaked. + luminance := util.GetRelativeLuminance(label.Color) + 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) + + opacity := GetLabelOpacityByte(label.IsArchived()) + r, g, b := util.HexToRBGColor(label.Color) + 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)), + opacity, + } + 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)), + opacity, + } + + scopeColor := "#" + hex.EncodeToString(scopeBytes) + itemColor := "#" + hex.EncodeToString(itemBytes) + + s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content 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>", + archivedCSSClass, 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))) +} + +func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive + output, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Metas: map[string]string{"mode": "document"}, + }, input) + if err != nil { + log.Error("RenderString: %v", err) + } + return output +} + +func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, isPull bool) 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 + } + + issuesOrPull := "issues" + if isPull { + issuesOrPull = "pulls" + } + htmlCode += fmt.Sprintf("<a href='%s/%s?labels=%d' rel='nofollow'>%s</a> ", + repoLink, issuesOrPull, label.ID, RenderLabel(ctx, locale, label)) + } + htmlCode += "</span>" + return template.HTML(htmlCode) +} diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go new file mode 100644 index 0000000..da74298 --- /dev/null +++ b/modules/templates/util_render_test.go @@ -0,0 +1,223 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + "html/template" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/translation" + + "github.com/stretchr/testify/assert" +) + +const testInput = ` space @mention-user +/just/a/path.bin +https://example.com/file.bin +[local link](file.bin) +[remote link](https://example.com) +[[local link|file.bin]] +[[remote link|https://example.com]] +![local image](image.jpg) +![remote image](https://example.com/image.jpg) +[[local image|image.jpg]] +[[remote link|https://example.com/image.jpg]] +https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +:+1: +mail@domain.com +@mention-user test +#123 + space +` + "`code :+1: #123 code`\n" + +var testMetas = map[string]string{ + "user": "user13", + "repo": "repo11", + "repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/", + "mode": "comment", +} + +func TestApostrophesInMentions(t *testing.T) { + rendered := RenderMarkdownToHtml(context.Background(), "@mention-user's comment") + assert.EqualValues(t, template.HTML("<p><a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a>'s comment</p>\n"), rendered) +} + +func TestNonExistantUserMention(t *testing.T) { + rendered := RenderMarkdownToHtml(context.Background(), "@ThisUserDoesNotExist @mention-user") + assert.EqualValues(t, template.HTML("<p>@ThisUserDoesNotExist <a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a></p>\n"), rendered) +} + +func TestRenderCommitBody(t *testing.T) { + type args struct { + ctx context.Context + msg string + metas map[string]string + } + tests := []struct { + name string + args args + want template.HTML + }{ + { + name: "multiple lines", + args: args{ + ctx: context.Background(), + msg: "first line\nsecond line", + }, + want: "second line", + }, + { + name: "multiple lines with leading newlines", + args: args{ + ctx: context.Background(), + msg: "\n\n\n\nfirst line\nsecond line", + }, + want: "second line", + }, + { + name: "multiple lines with trailing newlines", + args: args{ + ctx: context.Background(), + msg: "first line\nsecond line\n\n\n", + }, + want: "second line", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, RenderCommitBody(tt.args.ctx, tt.args.msg, tt.args.metas), "RenderCommitBody(%v, %v, %v)", tt.args.ctx, tt.args.msg, tt.args.metas) + }) + } + + expected := `/just/a/path.bin +<a href="https://example.com/file.bin" class="link">https://example.com/file.bin</a> +[local link](file.bin) +[remote link](<a href="https://example.com" class="link">https://example.com</a>) +[[local link|file.bin]] +[[remote link|<a href="https://example.com" class="link">https://example.com</a>]] +![local image](image.jpg) +![remote image](<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>) +[[local image|image.jpg]] +[[remote link|<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>]] +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +<span class="emoji" aria-label="thumbs up">👍</span> +<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a> +<a href="/mention-user" class="mention">@mention-user</a> test +<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> + space +` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">👍</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`" + assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas)) +} + +func TestRenderCommitMessage(t *testing.T) { + expected := `space <a href="/mention-user" class="mention">@mention-user</a> ` + + assert.EqualValues(t, expected, RenderCommitMessage(context.Background(), testInput, testMetas)) +} + +func TestRenderCommitMessageLinkSubject(t *testing.T) { + expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>` + + assert.EqualValues(t, expected, RenderCommitMessageLinkSubject(context.Background(), testInput, "https://example.com/link", testMetas)) +} + +func TestRenderIssueTitle(t *testing.T) { + expected := ` space @mention-user +/just/a/path.bin +https://example.com/file.bin +[local link](file.bin) +[remote link](https://example.com) +[[local link|file.bin]] +[[remote link|https://example.com]] +![local image](image.jpg) +![remote image](https://example.com/image.jpg) +[[local image|image.jpg]] +[[remote link|https://example.com/image.jpg]] +https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +<span class="emoji" aria-label="thumbs up">👍</span> +mail@domain.com +@mention-user test +<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> + space +<code class="inline-code-block">code :+1: #123 code</code> +` + assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas)) +} + +func TestRenderRefIssueTitle(t *testing.T) { + expected := ` space @mention-user +/just/a/path.bin +https://example.com/file.bin +[local link](file.bin) +[remote link](https://example.com) +[[local link|file.bin]] +[[remote link|https://example.com]] +![local image](image.jpg) +![remote image](https://example.com/image.jpg) +[[local image|image.jpg]] +[[remote link|https://example.com/image.jpg]] +https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +<span class="emoji" aria-label="thumbs up">👍</span> +mail@domain.com +@mention-user test +#123 + space +<code class="inline-code-block">code :+1: #123 code</code> +` + assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput)) +} + +func TestRenderMarkdownToHtml(t *testing.T) { + expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/> +/just/a/path.bin +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a> +<a href="/file.bin" rel="nofollow">local link</a> +<a href="https://example.com" rel="nofollow">remote link</a> +<a href="/src/file.bin" rel="nofollow">local link</a> +<a href="https://example.com" rel="nofollow">remote link</a> +<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a> +<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +<span class="emoji" aria-label="thumbs up">👍</span> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a> +<a href="/mention-user" rel="nofollow">@mention-user</a> test +#123 +space +<code>code :+1: #123 code</code></p> +` + assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput)) +} + +func TestRenderLabels(t *testing.T) { + unittest.PrepareTestEnv(t) + + tr := &translation.MockLocale{} + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) + + assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false), + "user2/repo1/issues?labels=1") + assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true), + "user2/repo1/pulls?labels=1") +} diff --git a/modules/templates/util_slice.go b/modules/templates/util_slice.go new file mode 100644 index 0000000..a3318cc --- /dev/null +++ b/modules/templates/util_slice.go @@ -0,0 +1,35 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "fmt" + "reflect" +) + +type SliceUtils struct{} + +func NewSliceUtils() *SliceUtils { + return &SliceUtils{} +} + +func (su *SliceUtils) Contains(s, v any) bool { + if s == nil { + return false + } + sv := reflect.ValueOf(s) + if sv.Kind() != reflect.Slice && sv.Kind() != reflect.Array { + panic(fmt.Sprintf("invalid type, expected slice or array, but got: %T", s)) + } + for i := 0; i < sv.Len(); i++ { + it := sv.Index(i) + if !it.CanInterface() { + continue + } + if it.Interface() == v { + return true + } + } + return false +} diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go new file mode 100644 index 0000000..f23b747 --- /dev/null +++ b/modules/templates/util_string.go @@ -0,0 +1,68 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "fmt" + "html/template" + "strings" + + "code.gitea.io/gitea/modules/base" +) + +type StringUtils struct{} + +var stringUtils = StringUtils{} + +func NewStringUtils() *StringUtils { + return &stringUtils +} + +func (su *StringUtils) HasPrefix(s any, prefix string) bool { + switch v := s.(type) { + case string: + return strings.HasPrefix(v, prefix) + case template.HTML: + return strings.HasPrefix(string(v), prefix) + } + return false +} + +func (su *StringUtils) ToString(v any) string { + switch v := v.(type) { + case string: + return v + case template.HTML: + return string(v) + case fmt.Stringer: + return v.String() + default: + return fmt.Sprint(v) + } +} + +func (su *StringUtils) Contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +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) Cut(s, sep string) []any { + before, after, found := strings.Cut(s, sep) + return []any{before, after, found} +} + +func (su *StringUtils) EllipsisString(s string, max int) string { + return base.EllipsisString(s, max) +} + +func (su *StringUtils) ToUpper(s string) string { + return strings.ToUpper(s) +} diff --git a/modules/templates/util_string_test.go b/modules/templates/util_string_test.go new file mode 100644 index 0000000..5844cf2 --- /dev/null +++ b/modules/templates/util_string_test.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren <contact@earl-warren.org> +// SPDX-License-Identifier: MIT + +package templates + +import ( + "html/template" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_StringUtils_HasPrefix(t *testing.T) { + su := &StringUtils{} + assert.True(t, su.HasPrefix("ABC", "A")) + assert.False(t, su.HasPrefix("ABC", "B")) + assert.True(t, su.HasPrefix(template.HTML("ABC"), "A")) + assert.False(t, su.HasPrefix(template.HTML("ABC"), "B")) + assert.False(t, su.HasPrefix(123, "B")) +} diff --git a/modules/templates/util_test.go b/modules/templates/util_test.go new file mode 100644 index 0000000..79aaba4 --- /dev/null +++ b/modules/templates/util_test.go @@ -0,0 +1,79 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "html/template" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDict(t *testing.T) { + type M map[string]any + cases := []struct { + args []any + want map[string]any + }{ + {[]any{"a", 1, "b", 2}, M{"a": 1, "b": 2}}, + {[]any{".", M{"base": 1}, "b", 2}, M{"base": 1, "b": 2}}, + {[]any{"a", 1, ".", M{"extra": 2}}, M{"a": 1, "extra": 2}}, + {[]any{"a", 1, ".", map[string]int{"int": 2}}, M{"a": 1, "int": 2}}, + {[]any{".", nil, "b", 2}, M{"b": 2}}, + } + + for _, c := range cases { + got, err := dict(c.args...) + require.NoError(t, err) + assert.EqualValues(t, c.want, got) + } + + bads := []struct { + args []any + }{ + {[]any{"a", 1, "b"}}, + {[]any{1}}, + {[]any{struct{}{}}}, + } + for _, c := range bads { + _, err := dict(c.args...) + require.Error(t, err) + } +} + +func TestUtils(t *testing.T) { + execTmpl := func(code string, data any) string { + tmpl := template.New("test") + tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils}) + template.Must(tmpl.Parse(code)) + w := &strings.Builder{} + require.NoError(t, tmpl.Execute(w, data)) + return w.String() + } + + actual := execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "a"}) + assert.Equal(t, "true", actual) + + actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "x"}) + assert.Equal(t, "false", actual) + + actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []int64{1, 2}, "Value": int64(2)}) + assert.Equal(t, "true", actual) + + actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "b"}) + assert.Equal(t, "true", actual) + + actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"}) + assert.Equal(t, "false", actual) + + tmpl := template.New("test") + tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils}) + template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}")) + // error is like this: `template: test:1:12: executing "test" at <SliceUtils.Contains>: error calling Contains: ...` + err := tmpl.Execute(io.Discard, map[string]any{"Slice": struct{}{}}) + require.ErrorContains(t, err, "invalid type, expected slice or array") +} diff --git a/modules/templates/vars/vars.go b/modules/templates/vars/vars.go new file mode 100644 index 0000000..cc9d0e9 --- /dev/null +++ b/modules/templates/vars/vars.go @@ -0,0 +1,92 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package vars + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +// ErrWrongSyntax represents a wrong syntax with a template +type ErrWrongSyntax struct { + Template string +} + +func (err ErrWrongSyntax) Error() string { + return fmt.Sprintf("wrong syntax found in %s", err.Template) +} + +// ErrVarMissing represents an error that no matched variable +type ErrVarMissing struct { + Template string + Var string +} + +func (err ErrVarMissing) Error() string { + return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template) +} + +// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors +// if error occurs, the error part doesn't change and is returned as it is. +func Expand(template string, vars map[string]string) (string, error) { + // in the future, if necessary, we can introduce some escape-char, + // for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'. + var buf strings.Builder + var err error + + posBegin := 0 + strLen := len(template) + for posBegin < strLen { + // find the next `{` + pos := strings.IndexByte(template[posBegin:], '{') + if pos == -1 { + buf.WriteString(template[posBegin:]) + break + } + + // copy texts between vars + buf.WriteString(template[posBegin : posBegin+pos]) + + // find the var between `{` and `}`/end + posBegin += pos + posEnd := posBegin + 1 + for posEnd < strLen { + if template[posEnd] == '}' { + posEnd++ + break + } // in the future, if we need to support escape chars, we can do: if (isEscapeChar) { posEnd+=2 } + posEnd++ + } + + // the var part, it can be "{", "{}", "{..." or or "{...}" + part := template[posBegin:posEnd] + posBegin = posEnd + if part == "{}" || part[len(part)-1] != '}' { + // treat "{}" or "{..." as error + err = ErrWrongSyntax{Template: template} + buf.WriteString(part) + } else { + // now we get a valid key "{...}" + key := part[1 : len(part)-1] + keyFirst, _ := utf8.DecodeRuneInString(key) + if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) { + // the if key doesn't start with a letter, then we do not treat it as a var now + buf.WriteString(part) + } else { + // look up in the map + if val, ok := vars[key]; ok { + buf.WriteString(val) + } else { + // write the non-existing var as it is + buf.WriteString(part) + err = ErrVarMissing{Template: template, Var: key} + } + } + } + } + + return buf.String(), err +} diff --git a/modules/templates/vars/vars_test.go b/modules/templates/vars/vars_test.go new file mode 100644 index 0000000..c543422 --- /dev/null +++ b/modules/templates/vars/vars_test.go @@ -0,0 +1,72 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package vars + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExpandVars(t *testing.T) { + kases := []struct { + tmpl string + data map[string]string + out string + error bool + }{ + { + tmpl: "{a}", + data: map[string]string{ + "a": "1", + }, + out: "1", + }, + { + tmpl: "expand {a}, {b} and {c}, with non-var { } {#}", + data: map[string]string{ + "a": "1", + "b": "2", + "c": "3", + }, + out: "expand 1, 2 and 3, with non-var { } {#}", + }, + { + tmpl: "中文内容 {一}, {二} 和 {三} 中文结尾", + data: map[string]string{ + "一": "11", + "二": "22", + "三": "33", + }, + out: "中文内容 11, 22 和 33 中文结尾", + }, + { + tmpl: "expand {{a}, {b} and {c}", + data: map[string]string{ + "a": "foo", + "b": "bar", + }, + out: "expand {{a}, bar and {c}", + error: true, + }, + { + tmpl: "expand } {} and {", + out: "expand } {} and {", + error: true, + }, + } + + for _, kase := range kases { + t.Run(kase.tmpl, func(t *testing.T) { + res, err := Expand(kase.tmpl, kase.data) + assert.EqualValues(t, kase.out, res) + if kase.error { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} |