0904
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// 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 (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateFormat pattern rules.
|
||||
var datePatterns = []string{
|
||||
// year
|
||||
"Y", "2006", // A full numeric representation of a year, 4 digits Examples: 1999 or 2003
|
||||
"y", "06", // A two digit representation of a year Examples: 99 or 03
|
||||
|
||||
// month
|
||||
"m", "01", // Numeric representation of a month, with leading zeros 01 through 12
|
||||
"n", "1", // Numeric representation of a month, without leading zeros 1 through 12
|
||||
"M", "Jan", // A short textual representation of a month, three letters Jan through Dec
|
||||
"F", "January", // A full textual representation of a month, such as January or March January through December
|
||||
|
||||
// day
|
||||
"d", "02", // Day of the month, 2 digits with leading zeros 01 to 31
|
||||
"j", "2", // Day of the month without leading zeros 1 to 31
|
||||
|
||||
// week
|
||||
"D", "Mon", // A textual representation of a day, three letters Mon through Sun
|
||||
"l", "Monday", // A full textual representation of the day of the week Sunday through Saturday
|
||||
|
||||
// time
|
||||
"g", "3", // 12-hour format of an hour without leading zeros 1 through 12
|
||||
"G", "15", // 24-hour format of an hour without leading zeros 0 through 23
|
||||
"h", "03", // 12-hour format of an hour with leading zeros 01 through 12
|
||||
"H", "15", // 24-hour format of an hour with leading zeros 00 through 23
|
||||
|
||||
"a", "pm", // Lowercase Ante meridiem and Post meridiem am or pm
|
||||
"A", "PM", // Uppercase Ante meridiem and Post meridiem AM or PM
|
||||
|
||||
"i", "04", // Minutes with leading zeros 00 to 59
|
||||
"s", "05", // Seconds, with leading zeros 00 through 59
|
||||
|
||||
// time zone
|
||||
"T", "MST",
|
||||
"P", "-07:00",
|
||||
"O", "-0700",
|
||||
|
||||
// RFC 2822
|
||||
"r", time.RFC1123Z,
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
stdhtml "html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// blockEndTags 与 reBlockEndBrList:删除块级元素闭合标签紧前的 <br>。
|
||||
// HardLineBreak 会把块级元素末尾的换行也渲染成 <br>,产生多余空行;
|
||||
// 这里仅清除末尾的 <br>(可含中间空白),行内真正的换行保留。
|
||||
// 注意:RE2 不支持 lookahead,故对每个闭合标签单独编译。
|
||||
var blockEndTags = []string{
|
||||
"</li>", "</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>",
|
||||
"</blockquote>", "</pre>", "</div>",
|
||||
}
|
||||
|
||||
var reBlockEndBrList = func() []*regexp.Regexp {
|
||||
res := make([]*regexp.Regexp, 0, len(blockEndTags))
|
||||
for _, tag := range blockEndTags {
|
||||
// 捕获闭合标签本身,替换时用 $1 保留
|
||||
res = append(res, regexp.MustCompile(`<br>[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`))
|
||||
}
|
||||
return res
|
||||
}()
|
||||
|
||||
// reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。
|
||||
var reBlankLines = regexp.MustCompile(`\n{2,}`)
|
||||
|
||||
// PlainText 渲染 Markdown 为纯文本并压缩连续空行为单个换行,
|
||||
// 供列表/摘要等紧凑展示场景使用(保留文字与换行,不显示 Markdown 符号与排版)。
|
||||
func PlainText(markdown string) string {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
}
|
||||
return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n")
|
||||
}
|
||||
|
||||
// MarkdownToHTML 将 Markdown 渲染为安全的 HTML 片段(供模板直接输出)。
|
||||
//
|
||||
// 安全性:先用 blackfriday 渲染,再经白名单过滤器清洗:
|
||||
// - 只保留常用排版标签与属性(a[href]/img[src,alt,title] 等)
|
||||
// - 丢弃 script / style / iframe / object / embed / form 等危险标签
|
||||
// - href/src 仅允许 http/https/mailto,其余一律去除
|
||||
// - 剥离所有 on* 事件属性
|
||||
//
|
||||
// 这样既支持 Markdown 排版,又不会引入存储型 XSS。
|
||||
func MarkdownToHTML(markdown string) template.HTML {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := blackfriday.Run([]byte(markdown),
|
||||
// CommonExtensions 提供标准 Markdown;HardLineBreak 让「回车」直接换行(<br>),
|
||||
// 无需行尾双空格(对非技术用户更友好)。
|
||||
blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak),
|
||||
)
|
||||
|
||||
cleaned := sanitizeHTML(raw)
|
||||
// 清除块级元素末尾的冗余 <br>(保留闭合标签,见 reBlockEndBrList 注释)
|
||||
for _, re := range reBlockEndBrList {
|
||||
cleaned = re.ReplaceAllString(cleaned, "$1")
|
||||
}
|
||||
return template.HTML(cleaned)
|
||||
}
|
||||
|
||||
// MarkdownToPlainText 将 Markdown 渲染为纯文本(剥离所有标记符号与 HTML 标签),
|
||||
// 供分享图绘制、摘要等纯文本场景使用。块级元素之间以换行分隔(调用方可按需折叠)。
|
||||
func MarkdownToPlainText(markdown string) string {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
}
|
||||
raw := blackfriday.Run([]byte(markdown),
|
||||
blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak),
|
||||
)
|
||||
return extractPlainText(raw)
|
||||
}
|
||||
|
||||
// blockElements 提取纯文本时需在其后补换行的块级元素。
|
||||
var blockElements = map[string]bool{
|
||||
"p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true,
|
||||
"h5": true, "h6": true, "ul": true, "ol": true, "li": true,
|
||||
"blockquote": true, "pre": true, "table": true, "tr": true,
|
||||
}
|
||||
|
||||
// extractPlainText 遍历 HTML token,只保留文本内容,块级元素与 <br> 后补换行。
|
||||
func extractPlainText(raw []byte) string {
|
||||
var out strings.Builder
|
||||
tokenizer := html.NewTokenizer(bytes.NewReader(raw))
|
||||
for {
|
||||
tt := tokenizer.Next()
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
return out.String()
|
||||
case html.TextToken:
|
||||
out.WriteString(string(tokenizer.Text()))
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
name := strings.ToLower(tokenizer.Token().Data)
|
||||
if name == "br" {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
case html.EndTagToken:
|
||||
name := strings.ToLower(tokenizer.Token().Data)
|
||||
if blockElements[name] {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 允许的标签集合。
|
||||
var allowedTags = map[string]bool{
|
||||
"p": true, "br": true, "hr": true, "div": true, "span": true,
|
||||
"strong": true, "em": true, "b": true, "i": true, "u": true, "s": true,
|
||||
"code": true, "pre": true, "blockquote": true,
|
||||
"ul": true, "ol": true, "li": true,
|
||||
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
|
||||
"a": true, "img": true, "table": true, "thead": true, "tbody": true,
|
||||
"tr": true, "th": true, "td": true, "del": true, "ins": true,
|
||||
// font 仅用于彩色文字(color 属性单独校验),无脚本能力,已废弃但浏览器仍支持
|
||||
"font": true,
|
||||
}
|
||||
|
||||
// sanitizeHTML 白名单过滤:遍历 token,重建安全 HTML。
|
||||
func sanitizeHTML(raw []byte) string {
|
||||
var out bytes.Buffer
|
||||
tokenizer := html.NewTokenizer(bytes.NewReader(raw))
|
||||
|
||||
for {
|
||||
tt := tokenizer.Next()
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
return out.String()
|
||||
case html.TextToken:
|
||||
// Tokenizer.Text returns unescaped text. Escape it again before
|
||||
// rebuilding HTML, otherwise input such as <script> can become
|
||||
// executable markup in the template.HTML result.
|
||||
out.WriteString(stdhtml.EscapeString(string(tokenizer.Text())))
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
// SelfClosingTagToken 覆盖 `<br />` 这类自闭合写法(blackfriday 的硬换行即此形式),
|
||||
// 漏掉会导致 <br> 被丢弃、换行失效。
|
||||
writeSanitizedTag(&out, tokenizer.Token(), false)
|
||||
case html.EndTagToken:
|
||||
writeSanitizedTag(&out, tokenizer.Token(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
|
||||
name := strings.ToLower(tok.Data)
|
||||
if !allowedTags[name] {
|
||||
return
|
||||
}
|
||||
|
||||
if isEnd {
|
||||
out.WriteString("</" + name + ">")
|
||||
return
|
||||
}
|
||||
|
||||
// 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。
|
||||
hasTargetBlank := false
|
||||
if name == "a" {
|
||||
for _, attr := range tok.Attr {
|
||||
if strings.ToLower(attr.Key) == "target" && strings.TrimSpace(attr.Val) == "_blank" {
|
||||
hasTargetBlank = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteString("<" + name)
|
||||
for _, attr := range tok.Attr {
|
||||
key := strings.ToLower(attr.Key)
|
||||
val := strings.TrimSpace(attr.Val)
|
||||
|
||||
// 过滤事件属性与危险属性
|
||||
if strings.HasPrefix(key, "on") {
|
||||
continue
|
||||
}
|
||||
// font 标签仅允许 color 属性,且值需通过颜色校验(防属性注入)
|
||||
if name == "font" {
|
||||
if key != "color" || !safeColorValue(val) {
|
||||
continue
|
||||
}
|
||||
out.WriteString(` color="` + stdhtml.EscapeString(val) + `"`)
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "href", "src":
|
||||
if !safeURL(val) {
|
||||
continue
|
||||
}
|
||||
case "target":
|
||||
// target 仅对 a 标签允许,且仅允许 _blank
|
||||
if name != "a" || val != "_blank" {
|
||||
continue
|
||||
}
|
||||
case "rel":
|
||||
// 若存在 target="_blank",忽略用户提供的 rel,统一由下方输出 noopener noreferrer
|
||||
if hasTargetBlank {
|
||||
continue
|
||||
}
|
||||
case "class", "alt", "title", "width", "height", "colspan", "rowspan", "start":
|
||||
// 保留有限的白名单属性(已移除 style,防止 CSS 注入 / 外联跟踪)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(" " + stdhtml.EscapeString(key) + `="` + stdhtml.EscapeString(val) + `"`)
|
||||
}
|
||||
if hasTargetBlank {
|
||||
out.WriteString(` rel="noopener noreferrer"`)
|
||||
}
|
||||
out.WriteString(">")
|
||||
}
|
||||
|
||||
// safeColorValue 校验颜色值:仅允许颜色名 / #hex / rgb() / rgba() / hsl() 等形式,
|
||||
// 拒绝分号、引号、尖括号、斜杠、冒号等可能用于属性注入或 CSS 逃逸的字符。
|
||||
func safeColorValue(v string) bool {
|
||||
if v == "" || len(v) > 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range v {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
continue
|
||||
case strings.ContainsRune("#,() .%", r):
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// safeURL 仅允许 http/https/mailto 协议(含站内相对路径),拒绝 javascript: 等危险协议。
|
||||
func safeURL(u string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(u))
|
||||
|
||||
// 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头,
|
||||
// 若不单独拦截会被误判为站内相对路径,从而绕过"仅 http/https/mailto"的白名单本意,
|
||||
// 使 markdown 中的 [x](//evil.com) 渲染成指向外站的链接(钓鱼 / 外链埋点)。
|
||||
// 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。
|
||||
if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) ||
|
||||
strings.HasPrefix(lower, `/\`) {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||||
strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "/") ||
|
||||
strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") ||
|
||||
strings.HasPrefix(lower, "#") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownToHTML_Basic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 必须包含
|
||||
}{
|
||||
{"bold", "**bold**", "<strong>bold</strong>"},
|
||||
{"italic", "*italic*", "<em>italic</em>"},
|
||||
{"link", "[baidu](https://baidu.com)", `href="https://baidu.com"`},
|
||||
{"list", "- item1\n- item2", "<li>item1</li>"},
|
||||
{"code", "`code`", "<code>code</code>"},
|
||||
{"newline", "line1\nline2", "<br>"},
|
||||
{"font-red", `<font color="red">文本</font>`, `<font color="red">文本</font>`},
|
||||
{"font-hex", `<font color="#ff0000">红</font>`, `<font color="#ff0000">红</font>`},
|
||||
{"font-rgb", `<font color="rgb(255,0,0)">红</font>`, `<font color="rgb(255,0,0)">红</font>`},
|
||||
{"font-mixed", `**粗体** 和 <font color="blue">蓝</font>`, `<font color="blue">蓝</font>`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := string(MarkdownToHTML(tc.in))
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("MarkdownToHTML(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToHTML_XSS(t *testing.T) {
|
||||
cases := []string{
|
||||
`<script>alert(1)</script>`,
|
||||
`&lt;script&gt;alert(1)&lt;/script&gt;`,
|
||||
`<script>alert(1)</script>`,
|
||||
`<img src=x onerror=alert(1)>`,
|
||||
`[x](javascript:alert(1))`,
|
||||
`<a href="javascript:alert(1)">x</a>`,
|
||||
`<iframe src="https://evil.com"></iframe>`,
|
||||
"<svg onload=alert(1)>",
|
||||
`<div onclick="alert(1)">x</div>`,
|
||||
`<style>body{display:none}</style>`,
|
||||
}
|
||||
for _, in := range cases {
|
||||
got := string(MarkdownToHTML(in))
|
||||
if strings.Contains(got, "<script") || strings.Contains(got, " onerror") ||
|
||||
strings.Contains(got, " onclick") || strings.Contains(got, " onload") ||
|
||||
strings.Contains(got, "<iframe") || strings.Contains(got, "javascript:") ||
|
||||
strings.Contains(got, "<style") || strings.Contains(got, "<svg") {
|
||||
t.Errorf("XSS 防护失败: 输入 %q 输出 %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownToHTML_FontColorInjection font 的 color 属性只放行合法颜色值,
|
||||
// 注入尝试必须被丢弃(标签保留但 color 属性被剥离,且不产生新属性)。
|
||||
func TestMarkdownToHTML_FontColorInjection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 输出必须包含(剥离后仅剩文本)
|
||||
}{
|
||||
{"css-inject", `<font color="red;position:fixed">x</font>`, "x"},
|
||||
{"quote-inject", `<font color="red" onclick="alert(1)">x</font>`, "x"},
|
||||
{"url-inject", `<font color="url(javascript:alert(1))">x</font>`, "x"},
|
||||
{"onattr-inject", `<font color="x" style="background:url(javascript:1)">x</font>`, "x"},
|
||||
{"long-value", `<font color="` + strings.Repeat("a", 50) + `">x</font>`, "x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := string(MarkdownToHTML(tc.in))
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("输出 %q 应包含 %q", got, tc.want)
|
||||
}
|
||||
// 注入的 color 值不得出现
|
||||
for _, evil := range []string{";", "position:", "onclick", "javascript:", "<font color=\"red\" onclick", "<font color=\"x\" style"} {
|
||||
if strings.Contains(got, evil) {
|
||||
t.Errorf("注入残留: %q 包含 %q", got, evil)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToHTML_Empty(t *testing.T) {
|
||||
if got := string(MarkdownToHTML("")); got != "" {
|
||||
t.Errorf("空输入应输出空串, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToPlainText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 必须包含(忽略换行折叠)
|
||||
}{
|
||||
{"plain", "你好世界", "你好世界"},
|
||||
{"bold", "**粗体**", "粗体"},
|
||||
{"font", `<font color="red">红字</font>`, "红字"},
|
||||
{"link", "[百度](https://baidu.com)", "百度"},
|
||||
{"code", "`code`", "code"},
|
||||
{"list", "- 第一项\n- 第二项", "第一项"},
|
||||
{"block", "# 标题\n内容", "标题"},
|
||||
{"mixed", "问题:**重要** 详情 <font color=\"blue\">蓝</font>", "重要"},
|
||||
{"empty", "", ""},
|
||||
// 危险标签被剥离:script 的内容是纯文本(绘制无风险),img 自闭合无文本则输出空
|
||||
{"xss-script", "<script>alert(1)</script>", "alert(1)"},
|
||||
{"xss-img", `<img src=x onerror=alert(1)>`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := strings.Join(strings.Fields(MarkdownToPlainText(tc.in)), " ")
|
||||
if tc.want == "" {
|
||||
if got != "" {
|
||||
t.Errorf("MarkdownToPlainText(%q) = %q, 期望空", tc.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("MarkdownToPlainText(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
|
||||
}
|
||||
// 输出不得残留任何标签(< 必须被剥离)
|
||||
if strings.Contains(got, "<") {
|
||||
t.Errorf("MarkdownToPlainText(%q) = %q, 不应包含标签字符", tc.in, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeURL 是 P2-1 的回归测试:markdown 链接白名单必须拒绝协议相对 URL
|
||||
// (//evil.com 等),否则会被误判为站内相对路径而渲染成指向外站的钓鱼 / 外链埋点链接。
|
||||
// 同时验证 javascript:/data:/ftp: 等危险协议与大小写变体均被拒绝,http/https/mailto
|
||||
// 与站内相对路径被放行。
|
||||
func TestSafeURL(t *testing.T) {
|
||||
allow := []string{
|
||||
"https://example.com",
|
||||
"http://example.com",
|
||||
"HTTPS://EXAMPLE.COM", // 大小写不敏感
|
||||
"mailto:[email protected]",
|
||||
"/path/to/page",
|
||||
"./relative",
|
||||
"../up",
|
||||
"#fragment",
|
||||
}
|
||||
for _, u := range allow {
|
||||
if !safeURL(u) {
|
||||
t.Errorf("safeURL(%q) = false, 期望放行", u)
|
||||
}
|
||||
}
|
||||
|
||||
deny := []string{
|
||||
"//evil.com", // 协议相对 URL(核心回归点)
|
||||
`\\evil.com`, // 反斜杠变体
|
||||
`/\evil.com`, // 反斜杠变体
|
||||
"javascript:alert(1)", // 危险协议
|
||||
"JavaScript:alert(1)", // 大小写变体
|
||||
"data:text/html,<script>",
|
||||
"ftp://example.com",
|
||||
"", // 空串
|
||||
}
|
||||
for _, u := range deny {
|
||||
if safeURL(u) {
|
||||
t.Errorf("safeURL(%q) = true, 期望拒绝", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/flamego/template"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/css"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
"github.com/tdewolff/minify/v2/js"
|
||||
)
|
||||
|
||||
var _ template.FileSystem = (*FileSystem)(nil)
|
||||
|
||||
type file struct {
|
||||
name string
|
||||
data []byte
|
||||
ext string
|
||||
}
|
||||
|
||||
func (f *file) Name() string { return f.name }
|
||||
func (f *file) Data() ([]byte, error) { return f.data, nil }
|
||||
func (f *file) Ext() string { return f.ext }
|
||||
|
||||
type FileSystem struct {
|
||||
files []template.File
|
||||
}
|
||||
|
||||
func (fs *FileSystem) Files() []template.File { return fs.files }
|
||||
|
||||
// getExt returns the extension of given name, prefixed with the dot (".").
|
||||
// 必须用 filepath.Ext:取最后一个点之后的部分。
|
||||
// 旧的 strings.Index 实现会把 "foo.bar.html" 的扩展名误判为 ".bar.html",
|
||||
// 导致模板名截取错误(如 "foo" 丢失子目录结构)。
|
||||
func getExt(name string) string {
|
||||
return filepath.Ext(name)
|
||||
}
|
||||
|
||||
func Minify(embedFS embed.FS, dir string, allowedExtensions []string) (*FileSystem, error) {
|
||||
var beforeSize, afterSize int64
|
||||
|
||||
var files []template.File
|
||||
m := minify.New()
|
||||
m.AddFunc("text/html", html.Minify)
|
||||
m.AddFunc("text/css", css.Minify)
|
||||
m.AddFuncRegexp(regexp.MustCompile(`^(application|text)/(x-)?(java|ecma)script$`), js.Minify)
|
||||
|
||||
err := fs.WalkDir(embedFS, dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relpath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "get relative path")
|
||||
}
|
||||
|
||||
ext := getExt(relpath)
|
||||
for _, allowed := range allowedExtensions {
|
||||
if ext != allowed {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := embedFS.ReadFile(path)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read")
|
||||
}
|
||||
beforeSize += int64(len(data))
|
||||
|
||||
var buf bytes.Buffer
|
||||
switch filepath.Ext(path) {
|
||||
case ".html":
|
||||
if err := html.Minify(m, &buf, bytes.NewReader(data), nil); err != nil {
|
||||
return errors.Wrap(err, "minify html")
|
||||
}
|
||||
default:
|
||||
buf = *bytes.NewBuffer(data)
|
||||
}
|
||||
afterSize += int64(buf.Len())
|
||||
|
||||
name := filepath.ToSlash(relpath[:len(relpath)-len(ext)])
|
||||
files = append(files,
|
||||
&file{
|
||||
name: name,
|
||||
data: buf.Bytes(),
|
||||
ext: ext,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "walk")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"before_size": beforeSize,
|
||||
"after_size": afterSize,
|
||||
"ratio": float64(afterSize) / float64(beforeSize),
|
||||
}).Info("Minify succeeded")
|
||||
|
||||
return &FileSystem{
|
||||
files: files,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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)
|
||||
},
|
||||
// fieldTypeLabelClass 返回表单字段类型的标签配色(题型视觉区分)。
|
||||
"fieldTypeLabelClass": func(fieldType string) string {
|
||||
switch fieldType {
|
||||
case "text", "textarea":
|
||||
return "" // 默认灰
|
||||
case "radio", "select":
|
||||
return "uk-label-primary"
|
||||
case "checkbox":
|
||||
return "uk-label-success"
|
||||
case "rating":
|
||||
return "uk-label-warning"
|
||||
case "rank":
|
||||
return "uk-label-danger"
|
||||
case "number":
|
||||
return "uk-label-secondary"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
},
|
||||
// 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)
|
||||
},
|
||||
}}
|
||||
})
|
||||
return funcMap
|
||||
}
|
||||
|
||||
func Safe(raw string) template.HTML {
|
||||
return template.HTML(raw)
|
||||
}
|
||||
Reference in New Issue
Block a user