This commit is contained in:
tamakyi
2026-10-19 15:23:11 +08:00
parent 86dc8384bc
commit e9fba8b9ed
84 changed files with 34468 additions and 5386 deletions
+6
View File
@@ -89,6 +89,12 @@ func Init() error {
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
sanitizeLimits(&Limits)
// 主题:默认 slug 与启用清单。段缺失时 MapTo 得到空值,运行期回退内置 default,
// 因此老配置文件不写 [theme] 也能正常启动。
if err := File.Section("theme").MapTo(&Theme); err != nil {
return errors.Wrap(err, "map 'theme'")
}
if err := File.Section("mail").MapTo(&Mail); err != nil {
return errors.Wrap(err, "map 'mail'")
}
+12
View File
@@ -8,6 +8,9 @@ package conf
var (
BuildTime string
BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261019e"
)
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
@@ -35,6 +38,15 @@ var (
FontPath string `ini:"font_path"`
}
// Theme 主题系统([theme] 段)。
// default :站点默认主题 slug,留空/非法回退内置 default(= 现在的外观)。
// available :启用哪些主题(逗号分隔),同时是箱主设置页的白名单;
// 列了但缺少 css 文件的会被忽略并告警(见 internal/theme)。
Theme struct {
Default string `ini:"default"`
Available string `ini:"available"`
}
Security struct {
EnableTextCensor bool `ini:"enable_text_censor"`
IPBlacklist string `ini:"ip_blacklist"`
+2
View File
@@ -284,6 +284,8 @@ func Contexter() flamego.Handler {
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
c.Data["ExternalURL"] = conf.App.ExternalURL
// 静态资源版本号:base.css 与主题 css 共用同一个值,改样式只需改 conf.StaticVersion。
c.Data["StaticVersion"] = conf.StaticVersion
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
+38 -24
View File
@@ -40,6 +40,7 @@ type UsersStore interface {
// UpdateModerationStrictness 更新内容审核严格度(lax / standard / strict,空值表示跟随站点默认)。
// 只写 moderation_strictness 列,不影响防骚扰与屏蔽词。
UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error
UpdateTheme(ctx context.Context, id uint, theme string) error
Authenticate(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
UpdatePassword(ctx context.Context, id uint, newPassword string) error
@@ -85,30 +86,33 @@ type users struct {
type User struct {
gorm.Model `json:"-"`
UID string `json:"-"`
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
Avatar string `json:"avatar"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
Background string `json:"background"`
Intro string `json:"intro"`
Introcolor string `json:"introcolor"`
Usernamecolor string `json:"usernamecolor"`
Qrcodebackcolor string `json:"qrcodebackcolor"`
Qrcodecolor string `json:"qrcodecolor"`
BackgroundImage string `json:"backgroundimage"`
Dotscale string `json:"dotscale"`
Acfunlink string `json:"acfunlink"`
Bililink string `json:"bililink"`
Weibolink string `json:"weibolink"`
Bloglink string `json:"bloglink"`
Xiaohongshulink string `json:"xiaohongshulink"`
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
Backgroundimagealpha string `json:"Backgroundimagealpha"`
Notify NotifyType `json:"notify"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
UID string `json:"-"`
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
Avatar string `json:"avatar"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
Background string `json:"background"`
Intro string `json:"intro"`
Introcolor string `json:"introcolor"`
Usernamecolor string `json:"usernamecolor"`
Qrcodebackcolor string `json:"qrcodebackcolor"`
Qrcodecolor string `json:"qrcodecolor"`
BackgroundImage string `json:"backgroundimage"`
Dotscale string `json:"dotscale"`
Acfunlink string `json:"acfunlink"`
Bililink string `json:"bililink"`
Weibolink string `json:"weibolink"`
Bloglink string `json:"bloglink"`
Xiaohongshulink string `json:"xiaohongshulink"`
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
Backgroundimagealpha string `json:"Backgroundimagealpha"`
// Theme 箱主选择的主题 slug(空串=跟随站点默认,default=基础外观)。
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
// 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、
// Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。
@@ -445,6 +449,16 @@ func (db *users) UpdateModerationStrictness(ctx context.Context, id uint, strict
return nil
}
// UpdateTheme 仅更新主题 slug,不动其它个性化字段(背景/颜色各自独立保存)。
func (db *users) UpdateTheme(ctx context.Context, id uint, theme string) error {
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
"Theme": theme,
}).Error; err != nil {
return errors.Wrap(err, "update user")
}
return nil
}
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
u, err := db.GetByEmail(ctx, email)
if err != nil {
+7
View File
@@ -26,6 +26,13 @@ type UpdateProfile struct {
Language string `form:"language"`
}
// UpdateProfileTheme 主题外观。空串表示跟随站点默认主题;
// 取值在白名单之外时保存被拒绝(见 route/user.UpdateTheme),
// 这样箱主不会「选了个不存在的主题,然后看起来什么都没变」。
type UpdateProfileTheme struct {
Theme string `valid:"maxlen:32" label:"主题" msg:"profile.theme.invalid"`
}
// UpdateHarassment 仅「允许注册用户向我提问」开关(防骚扰类型)。
// 全局屏蔽词为独立入口 UpdateBlockWords:二者分处不同设置面板保存,
// 若仍共用一个结构体会互相覆盖对方字段(见 route/user/harassment.go 注释)。
+10
View File
@@ -930,4 +930,14 @@ var enDict = map[string]string{
"form.register.recaptcha": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
"form.register.agreeRules": "Please read and accept the community rules first",
"form.register.repeatPassword": "The two passwords do not match",
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "Theme",
"profile.theme.hint": "Applies to your question box pages only; the rest of the site keeps the default look.",
"profile.theme.followDefault": "Follow site default",
"profile.theme.save": "Save theme",
"profile.theme.saved": "Theme saved",
"profile.theme.invalid": "That theme is unavailable — pick one from the list",
"theme.name.default": "Default look",
"theme.name.sakura": "Sakura",
}
+10
View File
@@ -930,4 +930,14 @@ var jaDict = map[string]string{
"form.register.recaptcha": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
"form.register.agreeRules": "コミュニティルールをお読みのうえ同意してください",
"form.register.repeatPassword": "入力した2つのパスワードが一致しません",
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "テーマ",
"profile.theme.hint": "質問箱のページにのみ適用されます。他のページは既定の外観のままです。",
"profile.theme.followDefault": "サイト既定に従う",
"profile.theme.save": "テーマを保存",
"profile.theme.saved": "テーマを保存しました",
"profile.theme.invalid": "このテーマは利用できません。一覧から選択してください",
"theme.name.default": "標準の外観",
"theme.name.sakura": "さくら",
}
+10
View File
@@ -872,6 +872,16 @@ var zhCNDict = map[string]string{
"profile.saveSocial": "保存社交链接",
"profile.err.cropLibUnavailable": "图片裁剪组件加载失败,请刷新页面后重试",
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "主题外观",
"profile.theme.hint": "仅对提问箱页面生效,站内其它页面保持默认外观。",
"profile.theme.followDefault": "跟随站点默认",
"profile.theme.save": "保存主题",
"profile.theme.saved": "主题已保存",
"profile.theme.invalid": "该主题不可用,请从列表中选择",
"theme.name.default": "基础外观",
"theme.name.sakura": "樱花",
// 验证码 JSON 接口:前端消费(P2)
"captcha.json.badRequest": "请求格式错误",
"captcha.json.idRequired": "验证码 ID 不能为空",
+10
View File
@@ -930,4 +930,14 @@ var zhTWDict = map[string]string{
"form.register.recaptcha": "無法人機驗證,請重新整理頁面或更換網路後重試",
"form.register.agreeRules": "請先閱讀並同意社群規則",
"form.register.repeatPassword": "兩次輸入的密碼不一致",
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
"profile.theme.title": "主題外觀",
"profile.theme.hint": "僅對提問箱頁面生效,站內其他頁面維持預設外觀。",
"profile.theme.followDefault": "跟隨站點預設",
"profile.theme.save": "儲存主題",
"profile.theme.saved": "主題已儲存",
"profile.theme.invalid": "該主題無法使用,請從清單中選擇",
"theme.name.default": "基礎外觀",
"theme.name.sakura": "櫻花",
}
+1
View File
@@ -346,6 +346,7 @@ func New() *flamego.Flame {
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
f.Post("/export", user.ExportProfile)
f.Post("/export-json", user.ExportProfileJSON)
f.Post("/theme", form.Bind(form.UpdateProfileTheme{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateTheme)
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
+176
View File
@@ -0,0 +1,176 @@
// Package theme 维护箱主主题的注册表与取值回退。
//
// 一个主题就是一个 CSS 文件(static/css/themes/<slug>.css),只覆盖设计令牌:
// 配色、页面背景图/图案,不涉及布局。新增主题不需要改任何模板或 Go 代码,
// 只要放文件并在 [theme] available 里登记。
package theme
import (
"regexp"
"strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/static"
)
const (
// DefaultSlug 内置默认主题:不加载任何 css,等价于站点原本的外观。
DefaultSlug = "default"
themeCSSDir = "css/themes/"
)
var (
slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
// 仅接受纯色值,避免把主题文件里的任意内容拼进设置页的 style 属性。
colorRe = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,\s%/]+\))$`)
)
// Theme 是设置页需要的一项。名称由 i18n 键 theme.name.<slug> 提供,
// Swatch 是可直接放进 style 的预览背景(取不到颜色时为空)。
type Theme struct {
Slug string
Swatch string
}
var (
once sync.Once
themes []Theme
bySlug map[string]Theme
siteDef string // 站点默认主题;空串表示「基础外观,不加载主题 css」
)
func load() {
once.Do(func() {
themes, siteDef, bySlug = build(conf.Theme.Default, conf.Theme.Available)
})
}
// build 是注册表的纯函数形态,便于单测直接喂配置。
func build(defaultSlug, available string) ([]Theme, string, map[string]Theme) {
bySlug := make(map[string]Theme)
var list []Theme
add := func(slug string) {
if _, ok := bySlug[slug]; ok {
return
}
css, err := static.FS.ReadFile(themeCSSDir + slug + ".css")
if err != nil {
logrus.WithField("theme", slug).Warn("主题文件不存在,已从可用列表忽略")
return
}
t := Theme{Slug: slug, Swatch: swatchOf(string(css), slug)}
bySlug[slug] = t
list = append(list, t)
}
for _, raw := range strings.Split(available, ",") {
slug := normalize(raw)
if slug == "" {
continue
}
add(slug)
}
def := normalize(defaultSlug)
if def == DefaultSlug || def == "" {
def = ""
} else if _, ok := bySlug[def]; !ok {
logrus.WithField("theme", def).Warn("站点默认主题不可用,回退基础外观")
def = ""
}
return list, def, bySlug
}
func normalize(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
if !slugRe.MatchString(slug) {
if slug != "" {
logrus.WithField("theme", slug).Warn("主题 slug 非法(只允许小写字母/数字/连字符),已忽略")
}
return ""
}
return slug
}
// swatchOf 从主题 css 的浅色块里取底色与主色,拼成设置页的小预览。
// 取不到(或颜色格式可疑)就返回空串,模板退化为纯色占位。
func swatchOf(css, slug string) string {
block := ruleBody(css, `html[data-theme-style="`+slug+`"]`)
if block == "" {
return ""
}
bg := tokenValue(block, "--tb-bg")
primary := tokenValue(block, "--tb-primary")
if !colorRe.MatchString(bg) || !colorRe.MatchString(primary) {
return ""
}
return "linear-gradient(135deg, " + bg + " 0 45%, " + primary + " 45% 100%)"
}
// ruleBody 返回 selector 对应的第一个声明块内容(主题令牌块内不含嵌套规则)。
func ruleBody(css, selector string) string {
i := strings.Index(css, selector)
if i < 0 {
return ""
}
open := strings.IndexByte(css[i:], '{')
if open < 0 {
return ""
}
start := i + open + 1
end := strings.IndexByte(css[start:], '}')
if end < 0 {
return ""
}
return css[start : start+end]
}
func tokenValue(block, name string) string {
for _, line := range strings.Split(block, ";") {
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 || strings.TrimSpace(kv[0]) != name {
continue
}
return strings.TrimSpace(kv[1])
}
return ""
}
// List 返回设置页可选项(顺序与 [theme] available 一致)。
func List() []Theme {
load()
return themes
}
// IsAvailable 判断 slug 是否在白名单内且文件存在。
func IsAvailable(slug string) bool {
load()
_, ok := bySlug[strings.ToLower(strings.TrimSpace(slug))]
return ok
}
// Resolve 返回该页面要加载的主题 slug;空串表示不加载主题 css(基础外观)。
//
// 回退顺序:箱主选择 > 站点默认 > 基础外观。
// 注意 "" 与 "default" 语义不同:"" 是「没选,跟随站点默认」,
// "default" 是箱主明确选择基础外观(即使站点默认是别的主题也不覆盖)。
func Resolve(userTheme string) string {
load()
return resolve(userTheme, siteDef, bySlug)
}
func resolve(userTheme, siteDef string, available map[string]Theme) string {
slug := strings.ToLower(strings.TrimSpace(userTheme))
if slug == DefaultSlug {
return ""
}
if _, ok := available[slug]; ok {
return slug
}
return siteDef
}
+104
View File
@@ -0,0 +1,104 @@
package theme
import (
"io/fs"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/static"
)
// 主题必须自带的一套颜色令牌(形状/字体/阴影允许继承基础层)。
var requiredTokens = []string{
"--tb-bg", "--tb-surface", "--tb-surface-2", "--tb-surface-hover", "--tb-border",
"--tb-text", "--tb-text-secondary", "--tb-text-muted",
"--tb-primary", "--tb-primary-weak",
}
func TestBuildAvailable(t *testing.T) {
list, def, _ := build("sakura", "default, sakura ,bad_Slug,missing")
assert.Equal(t, "sakura", def, "站点默认应取配置值")
var slugs []string
for _, item := range list {
slugs = append(slugs, item.Slug)
}
// bad_Slug 非法、missing 没有 css 文件,都应被忽略
assert.Equal(t, []string{"default", "sakura"}, slugs)
}
func TestBuildInvalidDefault(t *testing.T) {
_, def, _ := build("does-not-exist", "sakura")
assert.Equal(t, "", def, "默认主题不可用时回退基础外观")
_, def, _ = build("default", "default,sakura")
assert.Equal(t, "", def, "default 语义即基础外观,不应产生 css 请求")
}
func TestResolve(t *testing.T) {
// 站点默认是 sakura
themes, siteDef, index := build("sakura", "default,sakura")
require.Equal(t, "sakura", siteDef)
require.Contains(t, index, "sakura")
require.NotEmpty(t, themes)
assert.Equal(t, "sakura", resolve("", siteDef, index), "未选择时跟随站点默认")
assert.Equal(t, "sakura", resolve("SAKURA", siteDef, index), "大小写不敏感")
assert.Equal(t, "", resolve("default", siteDef, index), "显式选 default = 基础外观,不回退站点默认")
assert.Equal(t, "sakura", resolve("nope", siteDef, index), "非法值回退站点默认")
// 站点默认是基础外观
_, def2, index2 := build("default", "default,sakura")
assert.Equal(t, "", resolve("", def2, index2))
assert.Equal(t, "sakura", resolve("sakura", def2, index2))
}
// TestThemeCSSComplete 防止「复制底稿只写了浅色块」这类问题:
// 半套主题不会报错,只会让深色模式变成浅色配色。
func TestThemeCSSComplete(t *testing.T) {
entries, err := fs.ReadDir(static.FS, strings.TrimSuffix(themeCSSDir, "/"))
require.NoError(t, err)
require.NotEmpty(t, entries)
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".css") {
continue
}
slug := strings.TrimSuffix(name, ".css")
// _scaffold.css 是给人复制的模板,default.css 永不加载(仅提供色块预览)
if strings.HasPrefix(slug, "_") || slug == DefaultSlug {
continue
}
raw, err := static.FS.ReadFile(themeCSSDir + name)
require.NoError(t, err)
css := string(raw)
light := ruleBody(css, `html[data-theme-style="`+slug+`"]`)
require.NotEmpty(t, light, "%s:缺少浅色块 html[data-theme-style=\"%s\"]", name, slug)
dark := ruleBody(css, `html[data-theme="dark"][data-theme-style="`+slug+`"]`)
require.NotEmpty(t, dark, "%s:缺少深色块 html[data-theme=\"dark\"][data-theme-style=\"%s\"]", name, slug)
for _, tok := range requiredTokens {
assert.NotEmpty(t, tokenValue(light, tok), "%s 浅色块缺少 %s", name, tok)
assert.NotEmpty(t, tokenValue(dark, tok), "%s 深色块缺少 %s", name, tok)
}
}
}
func TestSwatch(t *testing.T) {
raw, err := static.FS.ReadFile(themeCSSDir + "sakura.css")
require.NoError(t, err)
sw := swatchOf(string(raw), "sakura")
assert.True(t, strings.HasPrefix(sw, "linear-gradient("), "swatch 应为渐变串,实际 %q", sw)
assert.Contains(t, sw, "#e5678f")
assert.Equal(t, "", swatchOf("/* 空文件 */", "sakura"), "取不到颜色时返回空串")
assert.Equal(t, "", swatchOf(`html[data-theme-style="x"] { --tb-bg: url(evil); --tb-primary: #fff; }`, "x"),
"非色值内容不得进入 swatch")
}