Files
tamabox/internal/template/template.go
T
2026-10-19 12:44:32 +08:00

215 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package template
import (
"encoding/json"
"fmt"
"html"
"html/template"
"strconv"
"strings"
"sync"
"time"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/moderation"
)
var (
funcMap []template.FuncMap
funcMapOnce sync.Once
)
func FuncMap() []template.FuncMap {
funcMapOnce.Do(func() {
funcMap = []template.FuncMap{map[string]interface{}{
"ICP": func() string {
return conf.App.ICP
},
"CommitSHA": func() string {
return conf.BuildCommit
},
"CommitSHAShort": func() string {
if len(conf.BuildCommit) > 7 {
return conf.BuildCommit[:7]
}
return conf.BuildCommit
},
"Date": func(t time.Time, format string) string {
replacer := strings.NewReplacer(datePatterns...)
format = replacer.Replace(format)
return t.Format(format)
},
"QuestionFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
"AnswerFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
// MarkdownFormat 将回答按 Markdown 渲染为安全 HTML(含白名单过滤,防 XSS)。
"MarkdownFormat": MarkdownToHTML,
// MarkdownPlainText 将 Markdown 渲染为纯文本(去标记符号、保留文字与换行、压缩空行),
// 供列表页等不希望展示大字号/彩色排版的场景使用。
"MarkdownPlainText": PlainText,
"SentryDSN": func() string {
return conf.App.SentryDSN
},
"ParsePublicURLs": func(input datatypes.JSON) string {
urls := make(map[string]string)
if err := json.Unmarshal(input, &urls); err != nil {
return ""
}
for _, url := range urls {
return url
}
return ""
},
"ImageBucketCDNHost": func() string {
return conf.Upload.ImageBucketCDNHost
},
"sub": func(a, b int) int {
return a - b
},
// dict 将键值对打包为 map,供 {{template "partial" (dict "Key" val ...)}} 向部分模板传参。
// 参数必须成对且键为字符串,否则返回错误(html/template 会将该错误作为执行错误抛出)。
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments: %d", len(values))
}
d := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key at index %d is not a string", i)
}
d[key] = values[i+1]
}
return d, nil
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
// loop 生成 1..n 的整数切片,用于模板中按数量 range(如评分 1-10)。
"loop": func(n int) []int {
if n <= 0 {
return []int{}
}
out := make([]int, n)
for i := 1; i <= n; i++ {
out[i-1] = i
}
return out
},
// hasDay 判断逗号分隔的周几列表(0=周日…6=周六,与 time.Weekday 一致)是否包含 wd。
// 用于功能管理页渲染循环定时的开放日复选框。
"hasDay": func(days string, wd int) bool {
for _, part := range strings.Split(days, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err == nil && n == wd {
return true
}
}
return false
},
// parseOptions 解析投票表单字段的 JSON 选项数组(select/radio/checkbox)。
"parseOptions": func(raw string) []string {
if raw == "" {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return []string{}
}
return out
},
// percentOf 计算百分比(模板内无法做除法)。
"percentOf": func(part, total int64) int {
if total <= 0 {
return 0
}
return int(part * 100 / total)
},
// add 加法(模板序号 1-based 等场景)。
"add": func(a, b int) int {
return a + b
},
// fieldTypeLabel 返回表单字段类型的本地化名称(参与端题型标签)。
"fieldTypeLabel": func(lang, fieldType string) string {
key := map[string]string{
"text": "vote.fieldTypeText",
"textarea": "vote.fieldTypeTextarea",
"select": "vote.fieldTypeSelect",
"radio": "vote.fieldTypeRadio",
"checkbox": "vote.fieldTypeCheckbox",
"number": "vote.fieldTypeNumber",
"rating": "vote.fieldTypeRating",
"rank": "vote.fieldTypeRank",
}[fieldType]
if key == "" {
return ""
}
return i18n.T(lang, key)
},
// toJSON 将值序列化为 JSON 并标记为安全的 JS 字面量。
// 必须用于 <script> 上下文中的 JSON 注入:template.HTML 会在 script 上下文中
// 被 html/template 按 JS 规则转义(引号被转义导致 JS 语法错误/值变成字符串),
// template.JS 则原样输出。
"toJSON": func(v interface{}) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("[]")
}
return template.JS(b)
},
"Safe": Safe,
// 兼容小写别名:模板中历史遗留 {{safe ...}} 调用
"safe": Safe,
// i18n 模板函数:
// 模板用法:{{t .LangCode "key"}} 或 {{t .LangCode "key" .Arg}}
// 语言名:{{langName "zh-CN"}} → 简体中文
"t": func(lang, key string, args ...interface{}) string {
return i18n.T(lang, key, args...)
},
"langName": func(lang string) string {
return i18n.Name(lang)
},
// moderationStrictness 返回账号「最终生效」的审核严格度档位(lax / standard / strict)。
//
// 设置页下拉框必须用它回显,不能直接用 LoggedUser.ModerationStrictness:
// 存量账号该字段为空值(表示"跟随站点默认"),三个 <option> 的 eq 判定全部为假,
// 浏览器会默认选中第一个选项(宽松),而实际生效的是站点默认档位(通常为严格)——
// 界面会告诉箱主"你是宽松",与真实拦截行为相反。
// 走 moderation.StrictnessFor 解析,保证回显与 internal/security/censor/policy.go
// 的裁决口径是同一份事实来源(含站点默认与非法值回退)。
"moderationStrictness": func(raw string) string {
return string(moderation.StrictnessFor(raw))
},
}}
})
return funcMap
}
func Safe(raw string) template.HTML {
return template.HTML(raw)
}