Compare commits
2
Commits
main
..
c27ce88877
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27ce88877 | ||
|
|
ed8518739c |
-40
@@ -25,43 +25,3 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# otherfile
|
||||
./workbuddy
|
||||
./scripts
|
||||
# 注意:gitignore 不认 "./" 前缀,/outputs 才是「仓库根下的 outputs」。
|
||||
# outputs 里全是 TestGenPreview / 主题生成器 / 截图这类一次性产物,
|
||||
# 每次跑都会变,跟版本库无关。
|
||||
/outputs/
|
||||
|
||||
# 蜜罐凭据取证文件:明文账号密码,绝不能进版本库。
|
||||
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||
/data/
|
||||
|
||||
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
|
||||
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
|
||||
/.workbuddy/local/
|
||||
/.codeartsdoer/
|
||||
|
||||
# 本地运行配置与 WSL 测试环境模板:含数据库密码与对象存储密钥,绝不能进版本库。
|
||||
# dev/wsl-app.ini 的用法见 dev/sync-wsl.sh;同步时必须排除 conf/app.ini,避免被 rsync --delete 删掉。
|
||||
/conf/app.ini
|
||||
/dev/wsl-app.ini
|
||||
/dev/_*.py
|
||||
|
||||
# 含真实测试凭据的历史记录与巡检脚本:保留本地文件,但不再纳入版本控制。
|
||||
/.workbuddy/memory/MEMORY.md
|
||||
/.workbuddy/memory/topics/build.md
|
||||
/.workbuddy/memory/topics/sec.md
|
||||
/.workbuddy/memory/topics/misc.md
|
||||
/.workbuddy/memory/2026-09-15.md
|
||||
/.workbuddy/memory/2026-10-19.md
|
||||
/.workbuddy/memory/2026-10-22.md
|
||||
/dev/devcheck-e2e.py
|
||||
/dev/devcheck-avatar.py
|
||||
/dev/devcheck-image.py
|
||||
/dev/devcheck-upload.py
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
[app]
|
||||
production = true
|
||||
title = "NekoBox"
|
||||
icp = ""
|
||||
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||
uptrace_dsn = ""
|
||||
qiniu_access_key = ""
|
||||
qiniu_access_secret = ""
|
||||
aliyun_access_key = ""
|
||||
aliyun_access_key_secret = ""
|
||||
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
|
||||
|
||||
[security]
|
||||
enable_text_censor = true
|
||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||
;
|
||||
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
||||
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
||||
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
|
||||
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
|
||||
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
|
||||
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
|
||||
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
|
||||
trusted_proxies =
|
||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
|
||||
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
|
||||
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
||||
; EO-Client-IP(需源站已锁定)。
|
||||
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
||||
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
||||
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
||||
|
||||
[server]
|
||||
port = 80
|
||||
; 绑定监听地址(含端口)。Cloudflare Tunnel 部署时强烈建议设为 127.0.0.1:<port>,
|
||||
; 使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连伪造 CF-Connecting-IP。
|
||||
; 留空(默认)则监听所有网卡 ":port"。
|
||||
listen =
|
||||
salt = ""
|
||||
# ⚠️ 必填:密码哈希盐,留空会导致启动失败(已在 conf.Init 强制校验)
|
||||
xsrf_key = ""
|
||||
# ⚠️ 必填:CSRF token 密钥,留空会导致启动失败(已强制校验)
|
||||
xsrf_expire = 3600
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
user = "nekobox"
|
||||
password = ""
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "nekobox"
|
||||
schema = "public"
|
||||
# 仅在已确认需要自动清理重复 email/domain 时开启;默认关闭,避免启动时改写账号数据。
|
||||
dedupe_unique_fields = false
|
||||
|
||||
[redis]
|
||||
addr = "127.0.0.1:6379"
|
||||
password = ""
|
||||
|
||||
[recaptcha]
|
||||
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||
domain = "https://www.recaptcha.net"
|
||||
site_key = ""
|
||||
server_key = ""
|
||||
turnstile_style = false
|
||||
|
||||
[upload]
|
||||
default_avatar = ""
|
||||
default_background = ""
|
||||
|
||||
# User avatar and background.
|
||||
aliyun_endpoint = ""
|
||||
aliyun_access_id = ""
|
||||
aliyun_access_secret = ""
|
||||
aliyun_bucket = ""
|
||||
aliyun_bucket_cdn_host = ""
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret =""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
# User uploaded images.
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret = ""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
[mail]
|
||||
account = ""
|
||||
password = ""
|
||||
port = 465
|
||||
smtp = ""
|
||||
|
||||
[limits]
|
||||
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
||||
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
||||
; 键留空或填 0/负数时自动回退内置默认值,不会导致启动失败,但也不会按你的意图生效,
|
||||
; 请按需填写正整数。本文件启用 IgnoreInlineComment,行内分号注释会被并入配置值,
|
||||
; 因此下面注释一律独占整行、勿写行内注释。
|
||||
|
||||
; ── 标签与屏蔽词(账号设置页)──────────────────────────────────────────────
|
||||
; 最多标签个数
|
||||
max_tags = 5
|
||||
; 单个标签最大字符数
|
||||
max_tag_len = 5
|
||||
; 最多屏蔽词个数
|
||||
max_block_words = 10
|
||||
; 单个屏蔽词最大字符数
|
||||
max_block_word_len = 10
|
||||
|
||||
; ── 投票 ────────────────────────────────────────────────────────────────────
|
||||
; 参与者填写的「自定义补充选项」(其他/自定义)最大字符数
|
||||
custom_answer_len = 200
|
||||
; 站长创建投票时单个「选项」文本最大字符数(预设选项)
|
||||
vote_option_len = 300
|
||||
|
||||
; ── 提问 / 回答 / 追问 ─────────────────────────────────────────────────────
|
||||
; 提问正文最大字符数(访客提交的问题内容)
|
||||
question_content_len = 1000
|
||||
; 回答正文最大字符数(箱主对提问的回答,及对追问的回复,共用)
|
||||
answer_content_len = 2000
|
||||
; 追问正文最大字符数(提问者被回答后继续追问的内容)
|
||||
followup_content_len = 2000
|
||||
|
||||
; ── 私聊 ────────────────────────────────────────────────────────────────────
|
||||
; 单条消息(发送/回复)最大字符数
|
||||
chat_message_len = 2000
|
||||
; 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
|
||||
; 会话条数上限;新注册用户的默认值即该硬顶。
|
||||
chat_max_messages = 200
|
||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||
daily_message_limit_max = 999
|
||||
@@ -1,266 +0,0 @@
|
||||
// Package autoban 提供 fail2ban 风格的自动临时封禁:检测批量扫描/探测特征后
|
||||
// 临时封禁来源 IP,并可回调发送告警。纯标准库实现,便于独立测试与复用。
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 探测事件类型
|
||||
const (
|
||||
KindNone = 0 // 无探测特征
|
||||
KindSensitive = 1 // 命中敏感路径
|
||||
KindNotFound = 2 // 404 响应(未命中敏感路径)
|
||||
)
|
||||
|
||||
// 默认阈值/窗口(Options 未配置或为 0 时兜底)。
|
||||
const (
|
||||
DefaultWindowSeconds = 60
|
||||
DefaultDurationMinutes = 15
|
||||
DefaultThresholdSensitive = 10
|
||||
DefaultThreshold404 = 20
|
||||
pathSampleMax = 6
|
||||
)
|
||||
|
||||
// Options 自动封禁配置。
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
ThresholdSensitive int // 窗口内敏感路径命中次数
|
||||
Threshold404 int // 窗口内 404 次数
|
||||
Window time.Duration // 统计窗口
|
||||
Duration time.Duration // 封禁时长
|
||||
AlertEmail string // 告警收件人(留空不发送)
|
||||
SendAlert func(ip string, paths []string, durationMinutes int) error // 告警发送函数(nil 则不发送)
|
||||
}
|
||||
|
||||
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,前缀匹配)。
|
||||
var sensitivePaths = []string{
|
||||
"/admin", "/wp-admin", "/wp-login.php", "/wp-content", "/wordpress",
|
||||
"/administrator", "/manager", "/cpanel", "/plesk",
|
||||
"/.env", "/.git", "/.aws", "/.ssh", "/.svn", "/.hg", "/.htaccess",
|
||||
"/config", "/configuration", "/backup", "/database", "/db",
|
||||
"/actuator", "/swagger", "/api-docs", "/openapi", "/v2/api-docs",
|
||||
"/phpmyadmin", "/pma", "/mysql", "/server-status", "/server-info",
|
||||
"/shell", "/webshell", "/cmd", "/eval", "/hack", "/shell.php",
|
||||
"/login.php", "/admin.php", "/index.php", "/console", "/vendor",
|
||||
}
|
||||
|
||||
type probeEvent struct {
|
||||
at time.Time
|
||||
kind int
|
||||
}
|
||||
|
||||
type probeStat struct {
|
||||
events []probeEvent
|
||||
sensitive int
|
||||
notFound int
|
||||
paths []string // 命中路径样本(告警用)
|
||||
}
|
||||
|
||||
// Banner 自动封禁器。方法并发安全。
|
||||
type Banner struct {
|
||||
mu sync.Mutex
|
||||
|
||||
probes map[string]*probeStat
|
||||
banned map[string]time.Time
|
||||
alerted map[string]bool
|
||||
|
||||
thresholdSensitive int
|
||||
threshold404 int
|
||||
window time.Duration
|
||||
duration time.Duration
|
||||
alertEmail string
|
||||
sendAlert func(ip string, paths []string, durationMinutes int) error
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// New 按配置创建封禁器并注册全局清理。
|
||||
func New(opts Options) *Banner {
|
||||
window := opts.Window
|
||||
if window <= 0 {
|
||||
window = DefaultWindowSeconds * time.Second
|
||||
}
|
||||
duration := opts.Duration
|
||||
if duration <= 0 {
|
||||
duration = DefaultDurationMinutes * time.Minute
|
||||
}
|
||||
sensitive := opts.ThresholdSensitive
|
||||
if sensitive <= 0 {
|
||||
sensitive = DefaultThresholdSensitive
|
||||
}
|
||||
notFound := opts.Threshold404
|
||||
if notFound <= 0 {
|
||||
notFound = DefaultThreshold404
|
||||
}
|
||||
b := &Banner{
|
||||
probes: make(map[string]*probeStat),
|
||||
banned: make(map[string]time.Time),
|
||||
alerted: make(map[string]bool),
|
||||
thresholdSensitive: sensitive,
|
||||
threshold404: notFound,
|
||||
window: window,
|
||||
duration: duration,
|
||||
alertEmail: strings.TrimSpace(opts.AlertEmail),
|
||||
sendAlert: opts.SendAlert,
|
||||
enabled: opts.Enabled,
|
||||
}
|
||||
register(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// Record 记录一次探测事件,达到阈值时封禁 IP 并触发一次告警。
|
||||
func (b *Banner) Record(ip, path string, kind int) {
|
||||
if !b.enabled || ip == "" || kind == KindNone {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
st := b.probes[ip]
|
||||
if st == nil {
|
||||
st = &probeStat{}
|
||||
b.probes[ip] = st
|
||||
}
|
||||
|
||||
// 滚动清理过期事件并重算计数
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
valid = append(valid, probeEvent{at: now, kind: kind})
|
||||
if kind == KindSensitive {
|
||||
sensitive++
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
if len(st.paths) < pathSampleMax && path != "" {
|
||||
st.paths = append(st.paths, path)
|
||||
}
|
||||
|
||||
if sensitive < b.thresholdSensitive && notFound < b.threshold404 {
|
||||
return
|
||||
}
|
||||
|
||||
// 已达阈值:封禁(已在封禁中则跳过)
|
||||
if until, ok := b.banned[ip]; ok && until.After(now) {
|
||||
return
|
||||
}
|
||||
until := now.Add(b.duration)
|
||||
b.banned[ip] = until
|
||||
|
||||
// 告警(每 IP 仅一次)
|
||||
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
||||
b.alerted[ip] = true
|
||||
sample := append([]string{}, st.paths...)
|
||||
dur := int(b.duration.Minutes())
|
||||
go func() {
|
||||
_ = b.sendAlert(ip, sample, dur)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
|
||||
func (b *Banner) IsBanned(ip string) bool {
|
||||
if !b.enabled || ip == "" {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if until, ok := b.banned[ip]; ok {
|
||||
if until.After(time.Now()) {
|
||||
return true
|
||||
}
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSensitivePath 判断路径是否命中敏感探测特征(忽略大小写,完整段前缀匹配)。
|
||||
func IsSensitivePath(p string) bool {
|
||||
lower := strings.ToLower(p)
|
||||
for _, s := range sensitivePaths {
|
||||
if lower == s || strings.HasPrefix(lower, s+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- 全局注册与清理 ----------
|
||||
|
||||
var (
|
||||
bannersMu sync.Mutex
|
||||
banners = make(map[*Banner]struct{})
|
||||
bannersOnce sync.Once
|
||||
)
|
||||
|
||||
func register(b *Banner) {
|
||||
bannersMu.Lock()
|
||||
banners[b] = struct{}{}
|
||||
bannersMu.Unlock()
|
||||
|
||||
bannersOnce.Do(func() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(2 * time.Minute)
|
||||
bannersMu.Lock()
|
||||
for b := range banners {
|
||||
b.cleanup()
|
||||
}
|
||||
bannersMu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup 定期清理过期探测记录与已到期封禁(防止内存膨胀)。
|
||||
func (b *Banner) cleanup() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
for ip, st := range b.probes {
|
||||
if len(st.events) == 0 || st.events[len(st.events)-1].at.Before(cutoff) {
|
||||
delete(b.probes, ip)
|
||||
continue
|
||||
}
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
}
|
||||
for ip, until := range b.banned {
|
||||
if !until.After(now) {
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestBanner(thresholdSensitive, threshold404 int, window, duration time.Duration, alertEmail string, alertCalls *int64) *Banner {
|
||||
return New(Options{
|
||||
Enabled: true,
|
||||
ThresholdSensitive: thresholdSensitive,
|
||||
Threshold404: threshold404,
|
||||
Window: window,
|
||||
Duration: duration,
|
||||
AlertEmail: alertEmail,
|
||||
SendAlert: func(ip string, paths []string, dur int) error {
|
||||
if alertCalls != nil {
|
||||
atomic.AddInt64(alertCalls, 1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSensitiveThreshold(t *testing.T) {
|
||||
b := newTestBanner(2, 5, time.Minute, time.Minute, "", nil)
|
||||
b.Record("1.2.3.4", "/admin", KindSensitive)
|
||||
if b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("should not be banned before reaching threshold")
|
||||
}
|
||||
b.Record("1.2.3.4", "/.env", KindSensitive)
|
||||
if !b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("expected ban after reaching sensitive threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundThreshold(t *testing.T) {
|
||||
b := newTestBanner(10, 3, time.Minute, time.Minute, "", nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
b.Record("5.6.7.8", "/random-"+string(rune('a'+i)), KindNotFound)
|
||||
}
|
||||
if !b.IsBanned("5.6.7.8") {
|
||||
t.Fatal("expected ban after reaching 404 threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowExpiry(t *testing.T) {
|
||||
b := newTestBanner(2, 5, 50*time.Millisecond, time.Minute, "", nil)
|
||||
b.Record("1.1.1.1", "/admin", KindSensitive)
|
||||
time.Sleep(80 * time.Millisecond) // 事件滑出窗口
|
||||
b.Record("1.1.1.1", "/.env", KindSensitive)
|
||||
if b.IsBanned("1.1.1.1") {
|
||||
t.Fatal("events outside the window must not accumulate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationExpiry(t *testing.T) {
|
||||
b := newTestBanner(1, 5, time.Minute, 60*time.Millisecond, "", nil)
|
||||
b.Record("2.2.2.2", "/admin", KindSensitive)
|
||||
if !b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("expected ban")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // 封禁到期
|
||||
if b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("ban should expire after duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertOncePerIP(t *testing.T) {
|
||||
var calls int64
|
||||
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
|
||||
b.Record("9.9.9.9", "/wp-admin", KindSensitive)
|
||||
b.Record("9.9.9.9", "/.git", KindSensitive) // 已在封禁中,不应重复告警
|
||||
b.Record("9.9.9.9", "/config", KindSensitive)
|
||||
|
||||
// sendAlert 异步调用,轮询等待最多 500ms
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for atomic.LoadInt64(&calls) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt64(&calls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 alert, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabled(t *testing.T) {
|
||||
b := New(Options{Enabled: false})
|
||||
b.Record("3.3.3.3", "/admin", KindSensitive)
|
||||
if b.IsBanned("3.3.3.3") {
|
||||
t.Fatal("disabled auto-ban must not ban")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
b := New(Options{Enabled: true}) // 全部走默认值
|
||||
if b.thresholdSensitive != DefaultThresholdSensitive || b.threshold404 != DefaultThreshold404 {
|
||||
t.Fatalf("default thresholds wrong: %d/%d", b.thresholdSensitive, b.threshold404)
|
||||
}
|
||||
if b.window != DefaultWindowSeconds*time.Second || b.duration != DefaultDurationMinutes*time.Minute {
|
||||
t.Fatalf("default window/duration wrong: %v/%v", b.window, b.duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitivePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/admin", true},
|
||||
{"/admin/panel", true},
|
||||
{"/WP-ADMIN", true},
|
||||
{"/.env", true},
|
||||
{"/user/questions", false},
|
||||
{"/vote/abc123", false},
|
||||
{"/administratorx", false}, // 前缀必须完整段匹配,避免误伤
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsSensitivePath(c.path); got != c.want {
|
||||
t.Errorf("IsSensitivePath(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
// 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 route
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/cache"
|
||||
cacheRedis "github.com/flamego/cache/redis"
|
||||
"github.com/flamego/csrf"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/recaptcha"
|
||||
"github.com/flamego/session"
|
||||
"github.com/flamego/session/mysql"
|
||||
sessionRedis "github.com/flamego/session/redis"
|
||||
"github.com/flamego/template"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
"github.com/tamakyi/TamaBox/route"
|
||||
"github.com/tamakyi/TamaBox/route/auth"
|
||||
"github.com/tamakyi/TamaBox/route/pixel"
|
||||
"github.com/tamakyi/TamaBox/route/question"
|
||||
"github.com/tamakyi/TamaBox/route/service"
|
||||
"github.com/tamakyi/TamaBox/route/user"
|
||||
"github.com/tamakyi/TamaBox/route/vote"
|
||||
"github.com/tamakyi/TamaBox/static"
|
||||
"github.com/tamakyi/TamaBox/templates"
|
||||
)
|
||||
|
||||
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
|
||||
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
|
||||
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
|
||||
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
//
|
||||
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
|
||||
// CI 失败,避免修复悄悄倒退。
|
||||
const CSRFUserSessionKey = "uid"
|
||||
|
||||
// securityHeaders adds security-related HTTP headers to every response.
|
||||
func securityHeaders() flamego.Handler {
|
||||
return func(ctx flamego.Context) {
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
|
||||
if strings.HasPrefix(conf.App.ExternalURL, "https") {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||
Threshold404: conf.Security.BanThreshold404,
|
||||
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
|
||||
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
|
||||
AlertEmail: conf.Security.SecurityAlertEmail,
|
||||
SendAlert: mail.SendSecurityAlertMail,
|
||||
})
|
||||
// 需要输出详细日志时配置如下
|
||||
// if conf.App.Production {
|
||||
// flamego.SetEnv(flamego.EnvTypeProd)
|
||||
// }
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to embed templates file system")
|
||||
}
|
||||
|
||||
gob.Register(time.Time{})
|
||||
gob.Register(context.Flash{})
|
||||
gob.Register(captcha.StoredAnswer{})
|
||||
var sessionStorage interface{}
|
||||
initer := session.MemoryIniter()
|
||||
if conf.Database.DSN != "" {
|
||||
initer = mysql.Initer()
|
||||
sessionStorage = mysql.Config{
|
||||
DSN: conf.Database.DSN,
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
if conf.Redis.Addr != "" {
|
||||
initer = sessionRedis.Initer()
|
||||
sessionStorage = sessionRedis.Config{
|
||||
Options: &sessionRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 1,
|
||||
},
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
sessioner := session.Sessioner(session.Options{
|
||||
Initer: initer,
|
||||
Config: sessionStorage,
|
||||
// 显式声明 cookie 安全属性,不依赖库默认值:
|
||||
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
|
||||
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
|
||||
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
|
||||
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
|
||||
Cookie: session.CookieOptions{
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
|
||||
},
|
||||
})
|
||||
|
||||
f.Use(flamego.Static(flamego.StaticOptions{
|
||||
FileSystem: http.FS(static.FS),
|
||||
Prefix: "/static",
|
||||
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
|
||||
CacheControl: func() string {
|
||||
return "public, max-age=86400"
|
||||
},
|
||||
}))
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||||
f.Use(requestBodyLimitMiddleware())
|
||||
|
||||
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
|
||||
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Get("/", route.Home)
|
||||
f.Get("/pixel", reqUserSignIn, pixel.Index)
|
||||
f.Get("/sponsor", route.Sponsor)
|
||||
f.Get("/change-logs", route.ChangeLogs)
|
||||
f.Get("/about-me", route.AboutMe)
|
||||
f.Get("/robots.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("robots.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
f.Get("/favicon.ico", func(c context.Context) {
|
||||
fs, _ := static.FS.Open("favicon.ico")
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/sitemap.xml", func(c context.Context) {
|
||||
fs, err := static.FS.Open("sitemap.xml")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms-full.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms-full.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
|
||||
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
|
||||
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
|
||||
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
|
||||
}, reqUserSignOut)
|
||||
|
||||
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
|
||||
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
|
||||
|
||||
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
|
||||
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
|
||||
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Get("/share-card", question.ShareCard)
|
||||
}, question.Pager)
|
||||
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
|
||||
f.Group("/{questionID}", func() {
|
||||
f.Get("", question.Item)
|
||||
f.Get("/share-card", question.QuestionShareCard)
|
||||
f.Post("/delete", question.Delete)
|
||||
f.Post("/set-private", question.SetPrivate)
|
||||
f.Post("/set-public", question.SetPublic)
|
||||
f.Post("/pin", question.TogglePin)
|
||||
f.Post("/read", question.SetRead)
|
||||
f.Post("/unread", question.SetUnread)
|
||||
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
|
||||
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
|
||||
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
||||
}, question.Questioner)
|
||||
|
||||
// 新增:私聊路由(需要登录,使用 Token 标识对话)
|
||||
f.Group("/chat/{token}", func() {
|
||||
f.Get("", chat.ConversationPage)
|
||||
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
|
||||
f.Post("/delete", chat.DeleteConversation)
|
||||
f.Post("/block", chat.ToggleBlockUser)
|
||||
}, reqUserSignIn)
|
||||
}, question.Pager) // Pager 中间件已内置页面密码检查
|
||||
|
||||
f.Get("/explore", user.Explore)
|
||||
|
||||
// 匿名投票参与端(公开):token 链接入口
|
||||
f.Group("/vote/{token}", func() {
|
||||
f.Get("", vote.Page)
|
||||
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
|
||||
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
|
||||
})
|
||||
|
||||
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
|
||||
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
|
||||
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
|
||||
f.Get("/i18n/{lang}", func(c context.Context) {
|
||||
lang := i18n.Resolve(c.Param("lang"))
|
||||
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
|
||||
c.Session.Set("lang", lang)
|
||||
to := c.Query("to")
|
||||
// 仅允许站内相对路径,防开放重定向:
|
||||
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
|
||||
if to == "" || !strings.HasPrefix(to, "/") ||
|
||||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
|
||||
to = "/"
|
||||
}
|
||||
c.Redirect(to)
|
||||
})
|
||||
|
||||
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
|
||||
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
|
||||
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
|
||||
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
|
||||
|
||||
f.Group("/user", func() {
|
||||
f.Get("/questions", user.QuestionList)
|
||||
|
||||
// 匿名投票管理(箱主侧)
|
||||
f.Group("/votes", func() {
|
||||
f.Get("", user.VoteList)
|
||||
f.Get("/new", user.VoteNew)
|
||||
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
|
||||
f.Get("/{voteID}/edit", user.VoteEdit)
|
||||
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
|
||||
f.Post("/{voteID}/publish", user.VotePublish)
|
||||
f.Post("/{voteID}/close", user.VoteClose)
|
||||
f.Post("/{voteID}/delete", user.VoteDelete)
|
||||
f.Get("/{voteID}/results", user.VoteResults)
|
||||
f.Get("/{voteID}/preview", user.VotePreview)
|
||||
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
|
||||
f.Get("/{voteID}/export", user.VoteExportCSV)
|
||||
})
|
||||
|
||||
f.Group("/profile", func() {
|
||||
f.Get("", user.Profile)
|
||||
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.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
|
||||
|
||||
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
|
||||
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
|
||||
|
||||
f.Post("/update-chat-settings", user.UpdateChatSettings)
|
||||
|
||||
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
|
||||
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
|
||||
f.Group("/features", func() {
|
||||
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
|
||||
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
|
||||
})
|
||||
})
|
||||
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
|
||||
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
|
||||
// ?tab= 参数回跳到来源面板。
|
||||
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
|
||||
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
|
||||
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
|
||||
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
|
||||
f.Post("/logout", auth.Logout)
|
||||
}, reqUserSignIn)
|
||||
|
||||
f.Group("/api/v1", func() {
|
||||
f.Group("/user", func() {
|
||||
f.Get("", reqUserSignIn, user.ProfileAPI)
|
||||
f.Group("/{domain}", func() {
|
||||
f.Group("/questions", func() {
|
||||
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
|
||||
})
|
||||
})
|
||||
})
|
||||
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
|
||||
// 并加限流防止被用作高频打点通道。
|
||||
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
|
||||
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
|
||||
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
|
||||
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
|
||||
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
|
||||
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
|
||||
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
|
||||
}, context.APIEndpoint)
|
||||
},
|
||||
cache.Cacher(cache.Options{
|
||||
Initer: cacheRedis.Initer(),
|
||||
Config: cacheRedis.Config{
|
||||
Options: &cacheRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
recaptcha.V3(
|
||||
recaptcha.Options{
|
||||
Secret: conf.Recaptcha.ServerKey,
|
||||
VerifyURL: func() recaptcha.VerifyURL {
|
||||
if conf.Recaptcha.TurnstileStyle {
|
||||
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
return recaptcha.VerifyURLGlobal
|
||||
}(),
|
||||
},
|
||||
),
|
||||
sessioner,
|
||||
csrf.Csrfer(csrf.Options{
|
||||
Secret: conf.Server.XSRFKey,
|
||||
Header: "X-CSRF-Token",
|
||||
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
|
||||
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
|
||||
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
|
||||
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
|
||||
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
|
||||
SessionKey: CSRFUserSessionKey,
|
||||
}),
|
||||
template.Templater(template.Options{
|
||||
FileSystem: templateFS,
|
||||
FuncMaps: templatepkg.FuncMap(),
|
||||
}),
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此计数(仅此处,不把业务 302→/ 误判为扫描)。
|
||||
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
|
||||
// 因此这里在 Redirect 之前记录,确保状态为 404。
|
||||
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
|
||||
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
|
||||
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
||||
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// 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 conf
|
||||
|
||||
// Build time and commit information.
|
||||
//
|
||||
// ⚠️ WARNING: should only be set by "-ldflags".
|
||||
var (
|
||||
BuildTime string
|
||||
BuildCommit = "dev"
|
||||
)
|
||||
|
||||
var (
|
||||
App struct {
|
||||
Production bool `ini:"production"`
|
||||
ExternalURL string `ini:"external_url"`
|
||||
ICP string `ini:"icp"`
|
||||
DefaultLang string `ini:"default_lang"` // 站点默认语言(zh-CN/zh-TW/en/ja),留空回退简体中文
|
||||
UptraceDSN string `ini:"uptrace_dsn"`
|
||||
QiniuAccessKey string `ini:"qiniu_access_key"`
|
||||
QiniuAccessSecret string `ini:"qiniu_access_secret"`
|
||||
AliyunAccessKey string `ini:"aliyun_access_key"`
|
||||
AliyunAccessKeySecret string `ini:"aliyun_access_key_secret"`
|
||||
SentryDSN string `ini:"sentry_dsn"`
|
||||
MaintenanceMode bool `ini:"maintenance_mode"`
|
||||
ShareBackground string `ini:"share_background"`
|
||||
FontPath string `ini:"font_path"`
|
||||
}
|
||||
|
||||
Security struct {
|
||||
EnableTextCensor bool `ini:"enable_text_censor"`
|
||||
IPBlacklist string `ini:"ip_blacklist"`
|
||||
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
|
||||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
|
||||
// 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
TrustProxy bool `ini:"trust_proxy"`
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
|
||||
// (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
|
||||
// X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
|
||||
// EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
|
||||
// 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
|
||||
// Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
|
||||
// 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
|
||||
TrustedProxies string `ini:"trusted_proxies"`
|
||||
// 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"`
|
||||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
|
||||
Server struct {
|
||||
Port int `ini:"port"`
|
||||
Listen string `ini:"listen"` // 绑定监听地址(含端口)。留空则默认 ":port"(监听所有网卡)。
|
||||
Salt string `ini:"salt"`
|
||||
XSRFKey string `ini:"xsrf_key"`
|
||||
}
|
||||
|
||||
Database struct {
|
||||
DSN string
|
||||
|
||||
Type string `ini:"type"`
|
||||
User string `ini:"user"`
|
||||
Password string `ini:"password"`
|
||||
Host string `ini:"host"`
|
||||
Port uint `ini:"port"`
|
||||
Name string `ini:"name"`
|
||||
Schema string `ini:"schema"`
|
||||
DedupeUniqueFields bool `ini:"dedupe_unique_fields"`
|
||||
}
|
||||
|
||||
Redis struct {
|
||||
Addr string `ini:"addr"`
|
||||
Password string `ini:"password"`
|
||||
}
|
||||
|
||||
Recaptcha struct {
|
||||
CaptchaType string `ini:"captcha_type"`
|
||||
Domain string `ini:"domain"`
|
||||
SiteKey string `ini:"site_key"`
|
||||
ServerKey string `ini:"server_key"`
|
||||
TurnstileStyle bool `ini:"turnstile_style"`
|
||||
}
|
||||
|
||||
Pixel struct {
|
||||
Host string `ini:"host"`
|
||||
}
|
||||
|
||||
Upload struct {
|
||||
DefaultAvatarURL string `ini:"default_avatar"`
|
||||
DefaultBackground string `ini:"default_background"`
|
||||
DefaultBackgroundImageURL string `ini:"default_qrbackgroundimage"`
|
||||
AliyunEndpoint string `ini:"aliyun_endpoint"`
|
||||
AliyunAccessID string `ini:"aliyun_access_id"`
|
||||
AliyunAccessSecret string `ini:"aliyun_access_secret"`
|
||||
AliyunBucket string `ini:"aliyun_bucket"`
|
||||
AliyunBucketCDNHost string `ini:"aliyun_bucket_cdn_host"`
|
||||
ImageEndpoint string `ini:"image_endpoint"`
|
||||
ImageAccessID string `ini:"image_access_id"`
|
||||
ImageAccessSecret string `ini:"image_access_secret"`
|
||||
ImageBucket string `ini:"image_bucket"`
|
||||
ImageBucketCDNHost string `ini:"image_bucket_cdn_host"`
|
||||
}
|
||||
|
||||
Mail struct {
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
// SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
|
||||
SkipTLSVerify bool `ini:"skip_tls_verify"`
|
||||
}
|
||||
|
||||
Service struct {
|
||||
Backends []struct {
|
||||
Prefix string `ini:"prefix"`
|
||||
ForwardURL string `ini:"forward_url"`
|
||||
}
|
||||
// TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
|
||||
// (仅内网后端使用自签名证书时需要,默认 false 校验证书)
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
|
||||
}
|
||||
)
|
||||
@@ -1,977 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.LangCode}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{ if .MetaDescription }}{{ .MetaDescription }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}">
|
||||
<meta name="keywords" content="{{ t .LangCode "site.keywords" }}">
|
||||
<!-- 预连接:UIkit 走公共 CDN(cdnjs.snrat.com),其余自托管库仍在本站 R2,均需预连接 -->
|
||||
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cn.vercount.one">
|
||||
<!-- UIkit 3.3.3 切到公共 CDN(cdnjs.snrat.com,国内实测全量下载 ~0.25s,原 R2 自建 ~1.4-2.0s) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
|
||||
<!-- Waline 评论区样式:非阻塞加载,不阻塞首屏渲染 -->
|
||||
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
|
||||
<!-- UIkit 补丁:复刻原自建版 "modify-black-font"(官方版与自建版仅 3 处颜色差异——把有色容器内默认白字/白标题改为黑字)。
|
||||
模板当前只用到 .uk-light 容器(用户背景封面,question/list.html、question-not-found.html;
|
||||
其中 h3/p 已由 #usernamecolor/#introcolor 内联 style 覆盖),故只需补该容器的 3 处投影即可保持渲染一致。
|
||||
若日后新增 .uk-card-primary/.uk-card-secondary/.uk-section-primary/.uk-tile-*/.uk-overlay-primary 等容器且需黑字,须同步扩展。 -->
|
||||
<style>
|
||||
.uk-light { color: rgba(0,0,0,.7); }
|
||||
.uk-light .uk-h1, .uk-light .uk-h2, .uk-light .uk-h3, .uk-light .uk-h4, .uk-light .uk-h5, .uk-light .uk-h6,
|
||||
.uk-light .uk-heading-2xlarge, .uk-light .uk-heading-large, .uk-light .uk-heading-medium, .uk-light .uk-heading-small, .uk-light .uk-heading-xlarge,
|
||||
.uk-light h1, .uk-light h2, .uk-light h3, .uk-light h4, .uk-light h5, .uk-light h6 { color: #000; }
|
||||
.uk-light .uk-table th { color: rgba(0,0,0,.7); }
|
||||
</style>
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/lib/dayjs/1.10.8/dayjs.min.js"></script>
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/lib/alpinejs/3.9.0/cdn.min.js"></script>
|
||||
{{ if .VConsole }}
|
||||
<script src="https://r2.337845818.best/tamabox/cdn/lib/vconsole/3.12.1/vconsole.min.js" type="application/javascript"></script>
|
||||
<script>new window.VConsole()</script>
|
||||
{{ end }}
|
||||
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 -->
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/time.box.js"></script>
|
||||
<!--<script src="https://r2.337845818.best/tamabox/cdn/busuanzi.pure.mini.js"/></script>-->
|
||||
<script defer src="https://cn.vercount.one/js"></script>
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/lib/waline/2.15.8/waline.js"></script>
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码:弹窗模式,不影响原布局 -->
|
||||
<style>
|
||||
.captcha-equation-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f0f2f5;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.captcha-equation-box:focus-within {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
|
||||
}
|
||||
.math-captcha-modal-question {
|
||||
font-size: 1.4rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
width: 80px;
|
||||
padding: 6px 10px;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
#captcha-modal-answer:focus {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
|
||||
}
|
||||
/* 隐藏数字输入框的上下箭头 */
|
||||
#captcha-modal-answer::-webkit-inner-spin-button,
|
||||
#captcha-modal-answer::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
{{ else if .RecaptchaTurnstileStyle }}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = true;
|
||||
});
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = false;
|
||||
})
|
||||
}</script>
|
||||
{{ else }}
|
||||
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
|
||||
<script>function onSubmit() {document.getElementById('form').submit();}</script>
|
||||
{{ end }}
|
||||
{{ if ne SentryDSN "" }}
|
||||
<script
|
||||
src="https://browser.sentry-cdn.com/7.37.2/bundle.tracing.replay.min.js"
|
||||
integrity="sha384-o0mU2P+zsvXua0Y6f/J55PKWZt3sZCv3qDqJe6J5lZklyIQkpgjBVqNnPWYWqGhf"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
Sentry.init({
|
||||
dsn: "{{ SentryDSN }}",
|
||||
integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
<style>
|
||||
.grecaptcha-badge {visibility: hidden;}
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
width: 20px; /* 设置图标的宽度 */
|
||||
height: auto; /* 保持图标的高度自动调整 */
|
||||
}
|
||||
.emoji-placeholder {
|
||||
display: inline-block;
|
||||
animation: jump 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
/* 防止 UIkit Dropbar 初始闪烁 */
|
||||
.uk-dropbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.uk-dropbar.uk-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
a.uk-button.uk-button-link {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* 移动端优化:缩小字体和间距,避免右侧空白 */
|
||||
@media (max-width: 640px) {
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.emoji-placeholder {
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* 移动端用户名限宽省略,避免长英文用户名撑爆顶栏 */
|
||||
.tb-nav-username {
|
||||
max-width: 64px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 深色模式:html[data-theme="dark"] 时覆盖 UIkit 3.3.3 编译主题的关键颜色 -->
|
||||
<style>
|
||||
html[data-theme="dark"] {
|
||||
--tb-bg: #1a1d23;
|
||||
--tb-surface: #242830;
|
||||
--tb-surface-2: #2d323c;
|
||||
--tb-border: #3a404c;
|
||||
--tb-text: #d8dce4;
|
||||
--tb-text-muted: #8b93a1;
|
||||
--tb-primary: #5c8dff;
|
||||
}
|
||||
/* 根元素与 body 一起覆盖,确保整个视口(含内容不足一屏时的空白区)都是深色背景 */
|
||||
html[data-theme="dark"],
|
||||
html[data-theme="dark"] body {
|
||||
background-color: var(--tb-bg) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
/* 通用表面容器 */
|
||||
html[data-theme="dark"] .uk-card,
|
||||
html[data-theme="dark"] .uk-card-default,
|
||||
html[data-theme="dark"] .uk-card-secondary,
|
||||
html[data-theme="dark"] .uk-dropbar,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown,
|
||||
html[data-theme="dark"] .uk-modal-dialog,
|
||||
html[data-theme="dark"] .uk-dropdown,
|
||||
html[data-theme="dark"] .uk-offcanvas-bar,
|
||||
html[data-theme="dark"] .uk-section,
|
||||
html[data-theme="dark"] .uk-tile,
|
||||
html[data-theme="dark"] .uk-panel,
|
||||
html[data-theme="dark"] .uk-background-default,
|
||||
html[data-theme="dark"] .uk-search-default .uk-search-input {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-container {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a,
|
||||
html[data-theme="dark"] .uk-card a,
|
||||
html[data-theme="dark"] .uk-link,
|
||||
html[data-theme="dark"] a.uk-button-link,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-list a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-card a:hover,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input,
|
||||
html[data-theme="dark"] .uk-textarea,
|
||||
html[data-theme="dark"] .uk-select {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input:focus,
|
||||
html[data-theme="dark"] .uk-textarea:focus,
|
||||
html[data-theme="dark"] .uk-select:focus {
|
||||
border-color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-text-muted,
|
||||
html[data-theme="dark"] .uk-text-meta,
|
||||
html[data-theme="dark"] .uk-text-secondary,
|
||||
html[data-theme="dark"] .uk-text-muted a,
|
||||
html[data-theme="dark"] .uk-text-small.uk-text-muted {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-divider-icon::after,
|
||||
html[data-theme="dark"] hr,
|
||||
html[data-theme="dark"] .uk-table th,
|
||||
html[data-theme="dark"] .uk-table td,
|
||||
html[data-theme="dark"] .uk-list-divider > li:nth-child(n+2) {
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-button-default {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-modal-title,
|
||||
html[data-theme="dark"] h1, html[data-theme="dark"] h2, html[data-theme="dark"] h3,
|
||||
html[data-theme="dark"] h4, html[data-theme="dark"] h5, html[data-theme="dark"] h6,
|
||||
html[data-theme="dark"] .uk-card-title {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-alert-warning,
|
||||
html[data-theme="dark"] .uk-alert-danger,
|
||||
html[data-theme="dark"] .uk-alert-success,
|
||||
html[data-theme="dark"] .uk-alert-primary {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-modal-dialog {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .captcha-equation-box {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .math-captcha-modal-question {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer::placeholder {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-text-meta,
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-label {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* 自定义组件(探索页推荐卡片等)深色覆盖 */
|
||||
html[data-theme="dark"] .recommend-card,
|
||||
html[data-theme="dark"] .recommend-card:hover {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-intro {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
/* 切换按钮图标切换 */
|
||||
#theme-toggle .tb-icon-dark, #theme-toggle-mobile .tb-icon-dark { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-light,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-light { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-dark,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-dark { display: inline; }
|
||||
</style>
|
||||
|
||||
<!-- 顶栏自适应:导航全部直接显示,窄屏时分级缩小字号/间距,保证英文/日文等长文字不溢出 -->
|
||||
<style>
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.95rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
/* 未登录移动端占位:桌面端隐藏,仅 ≤640px 显示(见下方 media 查询)。
|
||||
桌面端布局与改动前完全一致,不受影响。 */
|
||||
.tb-mobile-balancer { display: none; }
|
||||
/* 桌面端保持 UIkit 原生居中三列布局(无需额外 CSS) */
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.88rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.82rem;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
/* 登录用户名限宽,过长省略号截断 */
|
||||
.tb-nav-username {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.tb-nav-username {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
/* 手机端(≤640px):沿用 UIkit 桌面端 navbar 的原生三列定位。
|
||||
不再重写 .uk-navbar-center / center-left / center-right 的定位,
|
||||
避免 logo 被挤到下一行;底部语言与深色模式按钮仍由下方独立栏承载。 */
|
||||
@media (max-width: 640px) {
|
||||
.tb-navbar {
|
||||
position: relative;
|
||||
min-height: 56px;
|
||||
overflow: visible;
|
||||
}
|
||||
.tb-navbar > .uk-navbar-center {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left,
|
||||
.tb-navbar .uk-navbar-center-right {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left > div,
|
||||
.tb-navbar .uk-navbar-center-right > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav > li > a,
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 未登录:移动端显示与「我的」等宽的隐藏占位,使左栏「探索+占位」与
|
||||
右栏「注册/登录」两项等宽,logo 真正居中,左右视觉平衡 */
|
||||
.tb-mobile-balancer { display: block; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 标签样式的按钮(管理页置顶等):
|
||||
尺寸完全继承 .uk-label(font-size/padding/line-height/text-transform 等),
|
||||
仅覆盖 button 元素特有的默认样式(边框/字体族/光标等),保证与相邻 span 标签完全一致。 -->
|
||||
<style>
|
||||
.tb-label-btn {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
outline: none;
|
||||
background-image: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.tb-label-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 公开页置顶小图标:仅一个图钉,不占行高、不影响原布局 */
|
||||
.tb-pin-flag {
|
||||
font-size: 0.9em;
|
||||
margin-right: 4px;
|
||||
vertical-align: -1px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 页脚统计加载动画:替代 fontawesome 的 fa-spinner(纯 CSS,不引字体库) -->
|
||||
<style>
|
||||
.tb-spinner {
|
||||
display: inline-block;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border: 2px solid rgba(128, 128, 128, 0.25);
|
||||
border-top-color: #666;
|
||||
border-radius: 50%;
|
||||
vertical-align: -2px;
|
||||
animation: tb-spin 0.8s linear infinite;
|
||||
}
|
||||
html[data-theme="dark"] .tb-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #bbb;
|
||||
}
|
||||
@keyframes tb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Markdown 渲染排版(问题/回答/追问正文,左对齐以保留排版格式) -->
|
||||
<style>
|
||||
/* 纯文本列表:保留换行与连续空格(pre-wrap),不渲染 Markdown 大字号/彩色 */
|
||||
.tb-preline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 纯文本列表:超过 5 行截断并显示省略号(不足 5 行则完整显示) */
|
||||
.tb-clamp-5 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-body {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
.md-body p { margin: 0.5em 0; }
|
||||
.md-body h1, .md-body h2, .md-body h3,
|
||||
.md-body h4, .md-body h5, .md-body h6 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.md-body ul, .md-body ol { padding-left: 1.6em; margin: 0.5em 0; }
|
||||
.md-body li { margin: 0.2em 0; }
|
||||
.md-body blockquote {
|
||||
border-left: 3px solid #d3d1c7;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.2em 1em;
|
||||
color: #5f5e5a;
|
||||
}
|
||||
.md-body pre {
|
||||
background: #f5f5f5;
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.md-body code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.md-body pre code { background: none; padding: 0; }
|
||||
.md-body img { max-width: 100%; height: auto; }
|
||||
.md-body table { border-collapse: collapse; margin: 0.6em 0; }
|
||||
.md-body th, .md-body td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4em 0.8em;
|
||||
}
|
||||
.md-body hr { margin: 1em 0; }
|
||||
html[data-theme="dark"] .md-body blockquote {
|
||||
border-color: #3a404c;
|
||||
color: #8b93a1;
|
||||
}
|
||||
html[data-theme="dark"] .md-body pre,
|
||||
html[data-theme="dark"] .md-body code { background: #2d323c; }
|
||||
html[data-theme="dark"] .md-body th,
|
||||
html[data-theme="dark"] .md-body td { border-color: #3a404c; }
|
||||
</style>
|
||||
|
||||
<!-- 投票结果占比条:UIkit 3 已无 .uk-progressbar 类(那是 UIkit 2 的),
|
||||
模板用 <div class="uk-progress"><div class="uk-progressbar"> 结构,需补一套填充样式,
|
||||
否则内层填充无高度/背景,所有占比条都只剩外层灰色轨道,看起来一模一样。 -->
|
||||
<style>
|
||||
.uk-progress {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #e5e5e5;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
.uk-progressbar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: #1e87f0;
|
||||
border-radius: 6px 0 0 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
html[data-theme="dark"] .uk-progress {
|
||||
background-color: var(--tb-border);
|
||||
}
|
||||
html[data-theme="dark"] .uk-progressbar {
|
||||
background-color: var(--tb-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 -->
|
||||
<style>
|
||||
#tb-mobile-utils {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* 顶栏不再容纳语言/深色按钮,恢复原有导航空间 */
|
||||
.tb-nav-utils {
|
||||
display: none !important;
|
||||
}
|
||||
/* 手机端隐藏登录用户名(左边仅保留"探索"),保证 logo 居中、
|
||||
两侧各不超过 2 个短按钮,避免用户名宽度挤压布局 */
|
||||
.tb-nav-username {
|
||||
display: none !important;
|
||||
}
|
||||
/* 固定底部操作栏 */
|
||||
#tb-mobile-utils {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
#tb-mobile-utils .uk-select {
|
||||
width: 110px;
|
||||
}
|
||||
html[data-theme="dark"] #tb-mobile-utils {
|
||||
background: rgba(36, 40, 48, 0.92);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
|
||||
body {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Commit 6d0a3ad -->
|
||||
<meta property="og:site_name" content="{{ t .LangCode "site.title" }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.title" }}{{ else }}{{ t .LangCode "site.title" }}{{ end }}" />
|
||||
<meta property="og:url" content="{{ .ExternalURL }}{{ .CurrentURI }}" />
|
||||
<meta property="og:description" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.description" }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}" />
|
||||
{{ if .NoIndex }}<meta name="robots" content="noindex">{{ end }}
|
||||
{{ if .IsUserPage }}
|
||||
<meta property="og:image" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
<meta property="og:image:alt" content="{{ .PageUser.Name }}" />
|
||||
<meta itemprop="image" content="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<meta name="msapplication-TileImage" content="{{ .PageUser.Avatar }}"/>
|
||||
<link rel="shortcut icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="apple-touch-icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="icon" sizes="192x192" href="{{ .PageUser.Avatar }}?og-image=1">
|
||||
<meta name="twitter:image:src" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
{{ else }}
|
||||
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
|
||||
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
{{ end }}
|
||||
|
||||
<!-- Google and Bing -->
|
||||
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
|
||||
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
|
||||
<script defer src="https://mami.tama.guru/tamami.js" data-website-id="c42ebe8b-9281-4da0-83a4-8f18b3e710fe"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
|
||||
<div class="uk-navbar-center">
|
||||
<div class="uk-navbar-center-left">
|
||||
<div>
|
||||
<ul class="uk-navbar-nav">
|
||||
<!-- 探索链接,始终显示 -->
|
||||
<li><a href="/explore">{{t .LangCode "nav.explore"}}</a></li>
|
||||
{{ if .IsLogged }}
|
||||
<!-- 我的:进入自己的提问箱主页(与右侧「提问/设置」形成左右均衡) -->
|
||||
<li><a href="/_/{{ .LoggedUser.Domain }}">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ else }}
|
||||
<!-- 移动端占位:未登录时左栏仅「探索」一项,与右栏「注册/登录」两项不等宽,
|
||||
导致 logo 整体被拉向左(偏左)。移动端(≤640px)显示一个与「我的」等宽的隐藏占位,
|
||||
使左右视觉平衡;桌面端由 .tb-mobile-balancer 隐藏,保持原布局完全不变。 -->
|
||||
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>{{t .LangCode "site.logoSuffix"}}</a>
|
||||
|
||||
<div class="uk-navbar-center-right">
|
||||
<div>
|
||||
{{ if .IsLogged }}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/questions">{{t .LangCode "nav.ask"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/profile">{{t .LangCode "nav.settings"}}</a></li>
|
||||
</ul>
|
||||
{{ else}}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/register">{{t .LangCode "nav.register"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/login">{{t .LangCode "nav.login"}}</a></li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{ if not .IsUserPage }}
|
||||
<!-- 语言切换(紧凑图标,点击展开;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" aria-label="{{t .LangCode "common.lang"}}" title="{{t .LangCode "common.lang"}}">🌐</a>
|
||||
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
{{ range .SupportedLangs }}
|
||||
<li class="{{ if eq .Code $.LangCode }}uk-active{{ end }}">
|
||||
<a href="/i18n/{{ .Code }}?to={{ urlquery $.CurrentURI }}">{{ .Name }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
<!-- 深色模式切换(紧凑图标;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" id="theme-toggle" title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
|
||||
const placeholder = document.querySelector('.emoji-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 移动端固定底部操作栏:语言切换 + 深色模式(桌面端隐藏) -->
|
||||
<div id="tb-mobile-utils">
|
||||
{{ if not .IsUserPage }}
|
||||
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="{{t .LangCode "common.lang"}}">
|
||||
{{ range .SupportedLangs }}
|
||||
<option value="{{ .Code }}" {{ if eq .Code $.LangCode }}selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{ end }}
|
||||
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
|
||||
title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 深色模式:读取 cookie 应用主题,按钮切换并持久化
|
||||
var THEME_COOKIE = 'theme';
|
||||
function getTheme() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
function toggleTheme() {
|
||||
var next = getTheme() === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
|
||||
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
|
||||
}
|
||||
// 首屏前应用,避免闪烁
|
||||
applyTheme(getTheme());
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 桌面顶栏按钮与移动端底部按钮都绑定
|
||||
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
|
||||
buttons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
});
|
||||
});
|
||||
|
||||
// 移动端底部语言切换:onchange 跳转到 /i18n/{code},保留当前页面位置
|
||||
var langSel = document.getElementById('mobile-lang-select');
|
||||
if (langSel) {
|
||||
langSel.addEventListener('change', function() {
|
||||
if (!this.value) return;
|
||||
var to = window.location.pathname + window.location.search;
|
||||
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ if .IsPixel }}
|
||||
<div>
|
||||
{{ else }}
|
||||
<div class="uk-container uk-container-xsmall">
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码弹窗 --><div id="math-captcha-modal" uk-modal>
|
||||
<div class="uk-modal-dialog uk-modal-body uk-text-center">
|
||||
<h3 class="uk-modal-title">{{t .LangCode "captcha.title"}}</h3>
|
||||
<p class="uk-text-meta">{{t .LangCode "captcha.hint"}}</p>
|
||||
<div class="captcha-equation-box">
|
||||
<span id="captcha-modal-question" class="math-captcha-modal-question">{{t .LangCode "captcha.loading"}}</span>
|
||||
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">{{t .LangCode "captcha.refresh"}}</button>
|
||||
</div>
|
||||
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
|
||||
<div class="uk-margin-small-top">
|
||||
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">{{t .LangCode "captcha.submit"}}</button>
|
||||
<button type="button" class="uk-button uk-button-default uk-modal-close">{{t .LangCode "common.cancel"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CAPTCHA_I18N = {
|
||||
loading: "{{t .LangCode "captcha.loading"}}",
|
||||
loadFailed: "{{t .LangCode "captcha.loadFailed"}}",
|
||||
loadRetry: "{{t .LangCode "captcha.loadRetry"}}",
|
||||
inputAnswer: "{{t .LangCode "captcha.inputAnswer"}}",
|
||||
wrongAnswer: "{{t .LangCode "captcha.wrongAnswer"}}",
|
||||
verifyFailed: "{{t .LangCode "captcha.verifyFailed"}}",
|
||||
needPassword: "{{t .LangCode "ask.needPassword"}}",
|
||||
needEmail: "{{t .LangCode "ask.pleaseEmail"}}"
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var captchaId = '';
|
||||
var pendingForm = null;
|
||||
var pendingNeedPassword = false;
|
||||
var verifying = false;
|
||||
|
||||
function fetchCaptcha() {
|
||||
var qEl = document.getElementById('captcha-modal-question');
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
var ansEl = document.getElementById('captcha-modal-answer');
|
||||
qEl.textContent = CAPTCHA_I18N.loading;
|
||||
fetch('/api/captcha/generate')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.code === 0 && data.data) {
|
||||
captchaId = data.data.id;
|
||||
qEl.textContent = data.data.question;
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
|
||||
} else {
|
||||
qEl.textContent = CAPTCHA_I18N.loadFailed;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
qEl.textContent = CAPTCHA_I18N.loadRetry;
|
||||
});
|
||||
}
|
||||
|
||||
function getCSRFToken() {
|
||||
var input = document.querySelector('input[name="_csrf"]');
|
||||
return input ? input.value : '';
|
||||
}
|
||||
|
||||
function doSubmit(form) {
|
||||
// 将 captcha 答案填入隐藏字段
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
var hidden = form.querySelector('[name="g-recaptcha-response"]');
|
||||
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
|
||||
|
||||
UIkit.modal('#math-captcha-modal').hide();
|
||||
|
||||
// 处理 AskPassword(提问箱密码)
|
||||
if (pendingNeedPassword) {
|
||||
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
|
||||
.then(function(password) {
|
||||
if (password) {
|
||||
var pwInput = document.getElementById('ask_password_input');
|
||||
if (pwInput) pwInput.value = password;
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// 验证按钮
|
||||
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
|
||||
if (verifying) return;
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
if (!ans) {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.inputAnswer;
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
verifying = true;
|
||||
fetch('/api/captcha/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
verifying = false;
|
||||
if (data.correct) {
|
||||
if (pendingForm) doSubmit(pendingForm);
|
||||
} else {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha(); // 自动刷新题目
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
verifying = false;
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.verifyFailed;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha();
|
||||
});
|
||||
});
|
||||
|
||||
// 回车提交
|
||||
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
document.getElementById('captcha-modal-submit').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新按钮
|
||||
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
|
||||
|
||||
// 拦截所有带 class="math-captcha-trigger" 的按钮
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.math-captcha-trigger');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var form = btn.closest('form');
|
||||
if (!form) return;
|
||||
pendingForm = form;
|
||||
|
||||
// 检查是否需要提问密码
|
||||
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
|
||||
|
||||
// 邮箱校验(提问页面)
|
||||
var notifyCb = document.getElementById('notify-checkbox');
|
||||
var emailInput = document.getElementById('email-input');
|
||||
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
|
||||
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCaptcha();
|
||||
UIkit.modal('#math-captcha-modal').show();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -1,309 +0,0 @@
|
||||
{{template "base/header" .}}
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目(R2 对无 Origin 请求的响应无 Vary,可缓存 31 天) -->
|
||||
<link rel="shortcut icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<style>
|
||||
/* 投票列表:与探索页"全部投票"一致的列表行样式 */
|
||||
.profile-votes-list { display: grid; gap: 0; }
|
||||
.profile-vote-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--tb-border, #e9e9e9);
|
||||
text-decoration: none;
|
||||
}
|
||||
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border, #e9e9e9); }
|
||||
.profile-vote-item:hover { background: var(--tb-surface-hover, #f8fbff); text-decoration: none; }
|
||||
.profile-vote-content { min-width: 0; }
|
||||
.profile-vote-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--tb-text, #333);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profile-vote-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
|
||||
.profile-vote-action {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
|
||||
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
|
||||
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
|
||||
/* 个人主页 Tab:与探索页一致的胶囊切换样式 */
|
||||
/* 个人主页 Tab:全宽等分,置于主体顶部,作为一级导航(移动端整行可点) */
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--tb-border, #e5e5e5);
|
||||
border-radius: 9px;
|
||||
background: var(--tb-surface-2, #fafafa);
|
||||
}
|
||||
.profile-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 9px 15px;
|
||||
background: transparent;
|
||||
color: var(--tb-text-muted, #666);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.profile-tab:hover { color: var(--tb-primary, #1e87f0); }
|
||||
.profile-tab.is-active {
|
||||
background: var(--tb-surface, #fff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 功能关闭时置灰禁用的 Tab(如投票功能关闭) */
|
||||
.profile-tab.is-disabled,
|
||||
.profile-tab:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--tb-text-muted, #999);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.profile-tab.is-disabled:hover { color: var(--tb-text-muted, #999); }
|
||||
.profile-panel[hidden] { display: none !important; }
|
||||
html[data-theme="dark"] .profile-tabs {
|
||||
border-color: var(--tb-border);
|
||||
background: var(--tb-surface-2);
|
||||
}
|
||||
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
||||
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
||||
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
||||
</style>
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = "{{.PageUser.Usernamecolor}}";
|
||||
document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 已改为按需加载:defer 按文档顺序先于 easy.qrcode 执行,保证就绪 -->
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/lib/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">{{t .LangCode "ask.scan" .PageUser.Name}}<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
|
||||
<div class="uk-text-center uk-margin-small-top">
|
||||
<a id="share-btn" href="/_/{{ .PageUser.Domain }}/share-card" download class="uk-button uk-button-default uk-button-small">
|
||||
📤 {{t .LangCode "ask.share"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
|
||||
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
|
||||
<p class="uk-text-large uk-text-muted">{{t .LangCode "ask.generating"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('share-btn');
|
||||
var overlay = document.getElementById('share-loading-overlay');
|
||||
if (!btn || !overlay) return;
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
overlay.style.display = 'block';
|
||||
btn.style.pointerEvents = 'none';
|
||||
fetch(btn.href)
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('{{t .LangCode "ask.networkError"}}');
|
||||
return response.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '{{t .LangCode "ask.shareFilename"}}';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('{{t .LangCode "ask.genFailed"}}');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(function() {
|
||||
overlay.style.display = 'none';
|
||||
btn.style.pointerEvents = 'auto';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
||||
<div class="profile-tabs" role="tablist">
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
||||
{{ if .VoteEnabled }}
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "votes" }} is-active{{ end }}" data-profile-tab="votes" role="tab" aria-selected="{{ if eq .ActiveProfileTab "votes" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
|
||||
{{ else }}
|
||||
<!-- 投票功能关闭:保留 Tab 展示但置灰禁用,hover 提示原因 -->
|
||||
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="{{ .VoteClosedMessage }}">{{t .LangCode "explore.votesTab"}} <span uk-icon="icon: lock; ratio: 0.8"></span></button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="questions"{{ if eq .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="votes"{{ if ne .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{ if .PageVotes }}
|
||||
<!-- 匿名投票板块:箱主"进行中"的公开投票(列表行样式,与探索页"全部投票"一致) -->
|
||||
<div class="profile-votes-list">
|
||||
{{ range .PageVotes }}
|
||||
<a href="/vote/{{ .LinkToken }}" class="profile-vote-item">
|
||||
<div class="profile-vote-content">
|
||||
<span class="profile-vote-title" title="{{ .Title }}">{{ .Title }}</span>
|
||||
<span class="profile-vote-meta">
|
||||
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
|
||||
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
|
||||
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
|
||||
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
|
||||
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
|
||||
<span class="uk-text-meta">{{ .CreatedAt.Format "2006-01-02" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="profile-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="uk-alert uk-alert-primary uk-margin-remove-top">{{t .LangCode "explore.noVotes"}}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tabs = document.querySelectorAll('[data-profile-tab]');
|
||||
var panels = document.querySelectorAll('[data-profile-panel]');
|
||||
function showPanel(name) {
|
||||
tabs.forEach(function(tab) {
|
||||
var active = tab.dataset.profileTab === name;
|
||||
tab.classList.toggle('is-active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach(function(panel) {
|
||||
panel.hidden = panel.dataset.profilePanel !== name;
|
||||
});
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
|
||||
}
|
||||
}
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
|
||||
});
|
||||
// 仅当投票 Tab 可点击(功能开启)时才响应 #votes hash,避免投票关闭时切到空面板
|
||||
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
|
||||
showPanel('votes');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{template "base/footer" .}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
||||
{{template "base/header" .}}
|
||||
<!--<link rel="stylesheet" href="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/uikit/3.3.3/css/uikit.min.css">-->
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目 -->
|
||||
<link rel="shortcut icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = {{.PageUser.Usernamecolor}};
|
||||
document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 按需加载(同步,保证下方内联二维码初始化就绪) -->
|
||||
<script src="https://r2.337845818.best/tamabox/cdn/lib/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">扫码向@{{ .PageUser.Name }}提问<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,102 +0,0 @@
|
||||
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
|
||||
<br>Powered by NekoBox | {{ ICP }}<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
|
||||
<br>
|
||||
<!--<a href="/pixel">画板</a> | -->
|
||||
<a href="https://box.tama.guru/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
|
||||
| <a href="https://blog.tama.guru" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
|
||||
| <a href="https://chat.tama.guru" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
|
||||
| <a href="https://share.tama.guru" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
|
||||
| <a href="https://status.tama.guru" target="_blank">{{t .LangCode "site.footer.status"}}</a>
|
||||
| <a href="https://tama.guru" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
// 页脚「提问箱已成功存活」倒计时:原 R2 cdn/time.box.js(1.6KB)内联于此,
|
||||
// 消除全站每页一个 R2 慢 defer(实测 0.7-1.7s 波动)。相对原脚本两处修复:
|
||||
// 1) 先判空再写入(原版先赋值后判空,box_runtime 缺失时会抛 ReferenceError 中断脚本);
|
||||
// 2) 首帧立即填充(原版 DOMContentLoaded + 首个 1s 间隔后才显示,首秒为空)。
|
||||
(function () {
|
||||
var create = Math.round(Date.UTC(2023, 6, 1, 18, 15, 43) / 1000); // 提问箱创建时刻(UTC)
|
||||
function tick() {
|
||||
var el = document.getElementById("box_runtime");
|
||||
if (!el) return;
|
||||
var s = Math.round((Date.now() + 8 * 3600 * 1000) / 1000) - create; // UTC+8 秒级存活
|
||||
var t = [0, 0, 0, 0, 0];
|
||||
if (s >= 31536000) { t[0] = s / 31536000 | 0; s %= 31536000; }
|
||||
if (s >= 86400) { t[1] = s / 86400 | 0; s %= 86400; }
|
||||
if (s >= 3600) { t[2] = s / 3600 | 0; s %= 3600; }
|
||||
if (s >= 60) { t[3] = s / 60 | 0; s %= 60; }
|
||||
if (s > 0) { t[4] = s; }
|
||||
el.innerHTML = t[0] + '年 ' + t[1] + '天 ' + t[2] + '时 ' + t[3] + '分 ' + t[4] + '秒 ';
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
})();
|
||||
</script>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
|
||||
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
|
||||
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
|
||||
// 纯内联反馈,不使用全屏遮罩。
|
||||
var SUBMIT_I18N = {
|
||||
submitting: '{{t .LangCode "common.submitting"}}',
|
||||
uploading: '{{t .LangCode "common.uploading"}}'
|
||||
};
|
||||
|
||||
function tbResetSubmitButtons() {
|
||||
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
|
||||
b.disabled = false;
|
||||
b.removeAttribute('data-tb-submitting');
|
||||
var orig = b.getAttribute('data-tb-restore');
|
||||
if (orig !== null) {
|
||||
b.innerHTML = orig;
|
||||
b.removeAttribute('data-tb-restore');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
|
||||
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
|
||||
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
|
||||
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
|
||||
form.removeAttribute('data-tb-skip-submit-feedback');
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
|
||||
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
|
||||
if (form.getAttribute('target') === '_blank') return;
|
||||
|
||||
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
|
||||
var trigger = e.submitter || form.querySelector('button[type="submit"]');
|
||||
|
||||
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
|
||||
var hasFile = false;
|
||||
form.querySelectorAll('input[type="file"]').forEach(function(f) {
|
||||
if (f.files && f.files.length > 0) hasFile = true;
|
||||
});
|
||||
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
|
||||
|
||||
if (trigger && trigger.tagName === 'BUTTON') {
|
||||
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
|
||||
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
|
||||
}
|
||||
|
||||
// 禁用表单内所有按钮,防止重复提交
|
||||
form.querySelectorAll('button').forEach(function(b) {
|
||||
b.disabled = true;
|
||||
b.setAttribute('data-tb-submitting', '1');
|
||||
});
|
||||
});
|
||||
|
||||
// bfcache 还原时复位按钮
|
||||
window.addEventListener('pageshow', tbResetSubmitButtons);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
{{template "base/header" .}}
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h1 class="uk-heading-small">{{t .LangCode "site.title"}}</h1>
|
||||
<p><br>⭐一个无需注册即可提问的匿名提问箱。你不需要下载App,不需要绑定社交账号,甚至连邮箱都不需要是真的。<br>⭐本站在原版的基础上新增了问题页面的二维码、私聊、投票、追加提问等功能以及更多自定义内容。各位用户可自行修改二维码背景图、颜色等个性化配置。<br>⭐可以到站长唯一提问提问箱链接<a href="//box.tama.guru/_/tamakyi">小狼的提问箱</a>尝试相关功能或给我提意见。<br>⭐可以加群:<a href="http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=rRy9g5XY34LIzOITGGmQHjX6BIICFpam&authKey=EjyNjp60KyG8BtKZGN8BXJaf1jl8okkCcUhVF2zAnGPRjhvuSdopnVqOSfSymnGg&noverify=0&group_code=777163254">小狼的群</a>一起水群。<br></p>
|
||||
<hr><p style="color: red;">近期将国内段访问交由EdgeOne接管,境外访问照旧由CloudFlare提供,经测试国内访问速度有所提升。但因EdgeOne Free套餐不提供CIDR列表,IP放行采取自动抓取手动维护模式,如果出现521错误请刷新一下页面,过几天应该就好了。</p>
|
||||
<hr><p style="color: red; font-weight: bold;">近期更新:<br>⭐2026.9 增加了提问、投票、私聊的总开关,可以设置定时收集、定量收集或关闭功能,顺便调整了前后端显示,看起来更易用了一些。<br>⭐2026.8 新增了<a href="https://box.tama.guru/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.tama.guru/_/tamakyi/2894">Markdown显示</a>、置顶显示等多个功能,详情见<a href="https://blog.tama.guru/record/tamabox_changelog_20260813">这里</a>。<br>⭐2026.8 放弃使用recaptcha等对国内用户非常不友好的验证方式,更新了密码加密方式,修复了图片上传等一系列问题。<br>⭐2026.5 增加了私聊功能,可进行连续性对话,详见<a href="https://blog.tama.guru/record/tamabox-update-20260513">介绍</a>;另有增加提问箱的卡片分享,更直观和美观。<br>⭐2026.2 新增了用户展示和标签功能,如果你希望陌生人给你提问,可以在设置页面给自己贴上5个标签并选择是否展示在探索页面:<a href="https://box.tama.guru/explore">点我访问</a>,详情见<a href="https://blog.tama.guru/record/tamabox-tags">狼的公告板</a><br></p>
|
||||
<hr><p>一点提醒:<br>⭐本站唯一指定认证站长:<a href="//box.tama.guru/_/tamakyi">小狼</a>,注意不要被骗。
|
||||
<br>⭐本站不允许垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义、灌水提问的出现,如有涉证或不良言论将直接删除。</p>
|
||||
</div>
|
||||
<p>{{t .LangCode "site.stats"}}:<pre style="overflow: hidden;" id="site-stats-text">{{if .SiteStats}}{{.SiteStats.Text}}{{end}}</pre></p>
|
||||
<script type="text/javascript">
|
||||
// 首页统计已改为程序内直接计算(服务端 30 分钟缓存),这里每 30 分钟定时拉取最新值刷新,
|
||||
// 与原来 crontab 生成 question.count.txt 的节奏一致,且不再依赖外部静态文件。
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setInterval(function () {
|
||||
fetch("/api/v1/site-stats")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
var d = resp.data;
|
||||
if (!d || !d.text) return;
|
||||
var el = document.getElementById("site-stats-text");
|
||||
if (el) el.textContent = d.text;
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 30 * 60 * 1000);
|
||||
});
|
||||
</script>
|
||||
<!--<script src="https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline.js"></script>
|
||||
<link href='https://r2.337845818.best/oss-to-r2/nekobox/cdn/waline/client/v2/waline-no-commits.css' rel='stylesheet' />-->
|
||||
<div id="waline"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const waline = Waline.init({
|
||||
el: '#waline',
|
||||
//后端服务器地址
|
||||
serverURL: 'https://waline.337845818.best/',
|
||||
path: window.location.href,
|
||||
// 中文默认
|
||||
locale: {
|
||||
nick: '昵称',
|
||||
nickError: '昵称不能少于3个字符',
|
||||
mail: '邮箱',
|
||||
mailError: '请填写正确的邮件地址',
|
||||
link: '网址',
|
||||
optional: '可选',
|
||||
placeholder: '欢迎评论(可以无需登录,但需要手工审核,不会立即展现)',
|
||||
sofa: '来发评论吧~',
|
||||
submit: '提交',
|
||||
like: '喜欢',
|
||||
cancelLike: '取消喜欢',
|
||||
reply: '回复',
|
||||
cancelReply: '取消回复',
|
||||
comment: '评论',
|
||||
refresh: '刷新',
|
||||
more: '加载更多...',
|
||||
preview: '预览',
|
||||
emoji: '表情',
|
||||
uploadImage: '上传图片',
|
||||
seconds: '秒前',
|
||||
minutes: '分钟前',
|
||||
hours: '小时前',
|
||||
days: '天前',
|
||||
now: '刚刚',
|
||||
uploading: '正在上传',
|
||||
login: '登录',
|
||||
logout: '退出',
|
||||
admin: '博主',
|
||||
sticky: '置顶',
|
||||
word: '字',
|
||||
wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
|
||||
anonymous: '无名小狼',
|
||||
level0: '潜水',
|
||||
level1: '冒泡',
|
||||
level2: '吐槽',
|
||||
level3: '活跃',
|
||||
level4: '话痨',
|
||||
level5: '传说',
|
||||
gif: '表情包',
|
||||
gifSearchPlaceholder: '搜索表情包',
|
||||
profile: '个人资料',
|
||||
approved: '通过',
|
||||
waiting: '待审核',
|
||||
spam: '垃圾',
|
||||
unsticky: '取消置顶',
|
||||
oldest: '按倒序',
|
||||
latest: '按正序',
|
||||
hottest: '按热度',
|
||||
reactionTitle: '',
|
||||
reaction0: '哇哦',
|
||||
reaction1: 'prpr',
|
||||
reaction2: '流汗',
|
||||
reaction3: '生气',
|
||||
reaction4: '流鼻血',
|
||||
reaction5: 'あべ',
|
||||
},
|
||||
reaction: [
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGQ3OTRhYzRjMTBkZmZhZDVmZTA1EIjM1y8.gif',
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwOGEyOTlhYzRjMTBmOWFkZjAwYRCIzNcv.gif',
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGRhOTVhYzRjMTBkNmViYTVlNTA2EIjM1y8.gif',
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGU1OTJhYzRjMTBmMzk3ZmVmNTAyEIjM1y8.gif',
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChYwOGNkODdhYzRjMTBjYmEwOTFjNjAxEIjM1y8.gif',
|
||||
'https://ali-imgs.acfun.cn/kimg/bs2/zt-image-host/ChQwODliOGVhYzRjMTBmOGM0ZWY1ZRCIzNcv.gif',
|
||||
],
|
||||
meta: ['nick','mail'],
|
||||
requiredMeta: ['mail'],
|
||||
pageview: true,
|
||||
search: false,
|
||||
login: 'enable',
|
||||
wordLimit: 50,
|
||||
pageSize: 5,
|
||||
avatar: 'identicon',
|
||||
emoji: [
|
||||
'https://gcore.jsdelivr.net/gh/tamakyi/emoji-collection@main/acfun-color/60x60',
|
||||
],
|
||||
//禁止图片上传按钮
|
||||
imageUploader: false,
|
||||
//不显示版权标志
|
||||
copyright: false,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,121 +0,0 @@
|
||||
# 狼的提问箱 / Wolf's Question Box — 完整说明
|
||||
|
||||
狼的提问箱(Wolf's Question Box)是一个免费、无需注册的匿名提问箱服务,是 NekoBox 的社区分支(community fork)。访客无需下载 App、绑定社交账号或提供邮箱,即可向箱主匿名提问;箱主可公开或私密回答,并与提问者进行追问式的多轮对话。
|
||||
|
||||
> 隐私声明:单个提问箱内的问答内容属于用户隐私,不对外公开索引,也不写入 sitemap。本文件仅使用站点的公开功能信息,不包含任何具体提问箱或提问内容。
|
||||
|
||||
## 1. 服务定位
|
||||
|
||||
- 面向个人创作者、UP 主、博主等,提供一个让粉丝/陌生人匿名提问的入口。
|
||||
- 匿名是核心:提问者不需要任何账号、邮箱或社交登录。
|
||||
- 多语言界面:简体中文、繁体中文、英文、日文。
|
||||
- 内置浅色 / 深色主题切换。
|
||||
|
||||
## 2. 核心功能详解
|
||||
|
||||
### 2.1 匿名提问
|
||||
访客打开任意箱主的提问箱页面即可直接提问,全程无需登录。提问内容以纯文本或 Markdown 提交。
|
||||
|
||||
### 2.2 回答与追问(多轮对话)
|
||||
- 箱主可对问题进行公开回答。
|
||||
- 提问者可就同一问题「继续追问」,箱主可再次回复,形成来回多轮对话,适合连续交流、澄清或访谈场景。
|
||||
- 已回答的追问,箱主可随时「更新回答」。
|
||||
|
||||
### 2.3 Markdown 渲染
|
||||
问题与回答支持 Markdown 显示(标题、列表、引用、代码块、表格、图片等),保留排版格式。
|
||||
|
||||
### 2.4 置顶
|
||||
箱主可将重要或精彩的问题「置顶」,固定展示在提问箱顶部,便于新访客一眼看到。
|
||||
|
||||
### 2.5 访问密码 / 提问密码
|
||||
- **查看密码**:访问提问箱页面时需要输入密码,适合仅对特定人群开放。
|
||||
- **提问密码**:向某提问箱提问时需要输入密码,防止陌生人随意刷屏。
|
||||
- 密码采用加密方式存储,不保存明文。
|
||||
|
||||
### 2.6 二维码分享
|
||||
每个问题页均可生成分享二维码,箱主可自定义二维码的背景图与颜色,方便线下或社媒直接扫码访问。
|
||||
|
||||
### 2.7 标签与探索页
|
||||
- 箱主最多可设置 5 个标签,并选择是否展示在「探索」页面。
|
||||
- 「探索」页(`/explore`)按用户与标签发现其他公开提问箱,是站内唯一的公开内容发现入口。
|
||||
|
||||
### 2.8 私聊
|
||||
箱主与访客之间可进行一对一的连续私密对话(私聊),区别于公开问答,内容不对外展示。
|
||||
|
||||
### 2.9 多语言
|
||||
界面支持简体中文、繁体中文、英文、日文四种语言,跟随用户选择或浏览器/站点偏好。
|
||||
|
||||
### 2.10 深色模式
|
||||
全站内置浅色 / 深色主题切换,偏好记忆在本地 cookie。
|
||||
|
||||
### 2.11 验证码与图片上传
|
||||
- 为兼顾国内外用户,已弃用对国内不友好的 reCAPTCHA,改用更友好的验证方式(如算数验证码 / Turnstile)。
|
||||
- 提问与回答支持图片上传,上传前会做像素总量校验以防解压炸弹。
|
||||
|
||||
## 3. 使用流程
|
||||
|
||||
### 创建提问箱(箱主)
|
||||
1. 访问 `/register` 注册一个免费账号,获得专属提问箱与二级域名(如 `/_/你的用户名`)。
|
||||
2. 在 `/user/profile` 设置用户名、头像、简介与标签。
|
||||
3. 按需开启「查看密码」或「提问密码」,并设置是否参与探索页。
|
||||
|
||||
### 匿名提问(访客)
|
||||
1. 打开某箱主的提问箱页面(如 `/_/用户名`)。
|
||||
2. 若箱主设置了提问密码,先输入密码。
|
||||
3. 填写问题并提交(可勾选邮件通知,但邮箱非必填)。
|
||||
4. 等待箱主公开回答;如需补充,可使用「追问」。
|
||||
|
||||
### 分享
|
||||
- 复制提问箱链接,或使用问题页二维码分享。
|
||||
- 推荐国内用户使用主域名 `https://box.tama.guru`。
|
||||
|
||||
## 4. 常见问题(FAQ)
|
||||
|
||||
### 4.1 给提问者(访客)
|
||||
|
||||
- **提问需要注册吗?** 不需要。任何人都能匿名提问;只有创建与管理提问箱(注册账号)才需要登录。
|
||||
- **我的匿名性能保证吗?** 提问者无需提供账号或邮箱即可提问;是否填写通知邮箱完全可选,不填也能正常使用。
|
||||
- **我可以追问吗?** 可以。对箱主已公开回答的问题,你可「继续追问」,箱主再次回复后形成来回多轮对话,适合澄清、访谈或连续交流。
|
||||
- **我能收到回答通知吗?** 可以。提问时若留下邮箱(可选),箱主首次公开回答后你会收到邮件通知;邮件内含一条私密链接,可凭其查看该问题、接收回复并继续追问。
|
||||
- **提问有数量限制吗?** 有基础频率限制(约 30 条/分钟/IP)以防刷屏滥用,正常使用基本无感。
|
||||
- **能上传图片吗?** 能。提问与回答均支持图片上传,上传前会做像素总量等安全校验以防解压炸弹。
|
||||
- **需要验证码吗?** 需要。提问前需通过验证码(算数验证码 / Turnstile),已弃用对国内不友好的 reCAPTCHA,兼顾国内外用户。
|
||||
- **提问内容会被搜索引擎收录吗?** 不会。单个提问箱及其问答内容属于用户隐私,已排除在索引与 sitemap 之外;仅首页、注册、登录、探索页等公开页可被收录。
|
||||
|
||||
### 4.2 给箱主(创作者)
|
||||
|
||||
- **如何创建提问箱?** 注册一个免费账号,即可获得专属路径(如 `/_/你的用户名`),作为你的匿名提问箱。
|
||||
- **如何设置个人资料?** 在 `/user/profile` 设置用户名、头像、简介与标签。
|
||||
- **如何限制谁能访问或提问?** 可开启「查看密码」(访问页面需密码)与「提问密码」(提问需密码),均以加密形式存储,服务端不保存明文。
|
||||
- **我能删除某条提问吗?** 可以。箱主可删除提问箱内的任意提问。
|
||||
- **我能隐藏某条提问/回答吗?** 可以。单条问题可设为「私密」不对外公开展示,也可随时改回公开。
|
||||
- **如何把精彩问题置顶?** 使用「置顶」功能,将重要或精彩的问题固定在提问箱顶部,便于新访客一眼看到。
|
||||
- **如何出现在探索页?** 设置最多 5 个标签,并选择是否展示在「探索」页(`/explore`),这是站内唯一的公开内容发现入口。
|
||||
- **我能和访客私聊吗?** 可以。支持一对一连续私密对话(区别于公开问答);可拉黑对方、或删除整个对话,被拉黑方将无法继续发送。
|
||||
- **有新提问/新消息会通知我吗?** 会。若你已设置邮箱,有新提问、新私聊消息时会收到邮件通知。
|
||||
- **分享卡片 / 二维码能自定义吗?** 可以。问题页与提问箱页面均可生成分享卡片与二维码,并自定义二维码背景图与颜色,方便线下或社媒直接扫码访问。
|
||||
- **内容审核如何运作?** 采用自动与人工结合的审核,对垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁及无意义灌水内容进行处置与删除。
|
||||
|
||||
### 4.3 通用
|
||||
|
||||
- **支持哪些语言?** 简体中文、繁体中文、英文、日文;可在页面切换,偏好记忆于本地 cookie。
|
||||
- **支持深色模式吗?** 支持。全站内置浅色 / 深色主题切换,偏好同样记忆于本地 cookie。
|
||||
- **这是 NekoBox 吗?** 不是。TamaBox(狼的提问箱)是 NekoBox 的社区分支(community fork),在功能与多语言方面做了扩展。
|
||||
- **收费吗?** 免费。
|
||||
- **数据安全吗?** 密码加密存储,图片上传前后均做安全校验,单个提问箱内容不对外索引。
|
||||
- **如何分享我的提问箱?** 复制提问箱链接,或使用页面二维码、分享卡片;国内用户推荐使用主域名 `https://box.tama.guru`。
|
||||
- **忘记密码怎么办?** 登录页提供找回密码流程(通过注册邮箱重置),具体以登录页提示为准。
|
||||
|
||||
## 5. 隐私与使用政策
|
||||
|
||||
- **隐私**:单个提问箱内容属于用户隐私,不对外索引、不写入 sitemap。
|
||||
- **内容规范**:禁止垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义或灌水内容;违规内容将被删除。
|
||||
- **数据安全**:图片上传前后均做安全校验,密码加密存储。
|
||||
|
||||
## 6. 资源
|
||||
|
||||
- 更新日志(功能更新记录):https://blog.tama.guru/record/tamabox_changelog_20260813
|
||||
- 站点首页:https://box.tama.guru/
|
||||
- 探索页:https://box.tama.guru/explore
|
||||
- 注册:https://box.tama.guru/register
|
||||
- 登录:https://box.tama.guru/login
|
||||
@@ -1,29 +0,0 @@
|
||||
# 狼的提问箱 / Wolf's Question Box
|
||||
|
||||
> 狼的提问箱(Wolf's Question Box)是一个免费、无需注册的匿名提问箱服务。访客无需下载 App、绑定社交账号或提供邮箱,即可向箱主匿名提问;箱主可公开或私密回答,并与提问者进行追问式的多轮对话。本站为 NekoBox 的社区分支(community fork)。单个提问箱内的问答内容属于用户隐私,不对外公开索引。
|
||||
|
||||
## 概述
|
||||
- [首页](https://box.tama.guru/): 站点介绍与多语言落地页。
|
||||
- [探索](https://box.tama.guru/explore): 按用户与标签发现公开的提问箱。
|
||||
- [注册](https://box.tama.guru/register): 免费创建自己的提问箱。
|
||||
- [登录](https://box.tama.guru/login): 登录以管理你的提问箱。
|
||||
|
||||
## 核心功能
|
||||
- 匿名提问:无需账号、邮箱或社交登录即可提问。
|
||||
- 回答与追问:箱主可公开回答;提问者可继续追问,箱主可回复,形成多轮来回对话。
|
||||
- Markdown 渲染:问题与回答支持 Markdown 显示。
|
||||
- 访问控制:可分别设置「查看提问箱密码」与「提问密码」,密码加密存储。
|
||||
- 置顶:将重要问题置顶展示。
|
||||
- 二维码分享:每个问题页生成可分享二维码,背景图与颜色可自定义。
|
||||
- 标签与发现:最多添加 5 个标签,展示于探索页。
|
||||
- 私聊:与箱主一对一的连续私密对话。
|
||||
- 多语言界面:简体中文、繁体中文、英文、日文。
|
||||
- 深色模式:内置浅色 / 深色主题切换。
|
||||
|
||||
## 资源
|
||||
- [完整说明 (llms-full.txt)](https://box.tama.guru/llms-full.txt): 站点完整功能说明、使用流程、FAQ 与隐私政策。
|
||||
- [更新日志](https://blog.tama.guru/record/tamabox_changelog_20260813): 近期功能更新(追问、多语言、Markdown、置顶等)。
|
||||
|
||||
## 使用政策
|
||||
- 隐私:单个提问箱内容属于隐私,已排除在搜索引擎索引与 sitemap 之外。
|
||||
- 内容规范:禁止垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义或灌水内容,违规内容将被删除。
|
||||
@@ -1,92 +0,0 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"github.com/flamego/recaptcha"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
func Register(ctx context.Context) {
|
||||
ctx.Success("auth/register")
|
||||
}
|
||||
|
||||
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
|
||||
if ctx.HasError() {
|
||||
ctx.Success("auth/register")
|
||||
return
|
||||
}
|
||||
|
||||
// Check captcha code (unified: recaptcha, turnstile, or math).
|
||||
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Redirect("/register")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
|
||||
ctx.Redirect("/register")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
|
||||
Name: f.Name,
|
||||
Password: f.Password,
|
||||
Email: f.Email,
|
||||
Avatar: conf.Upload.DefaultAvatarURL,
|
||||
Domain: f.Domain,
|
||||
Background: conf.Upload.DefaultBackground,
|
||||
Intro: "问你想问的",
|
||||
Qrcodebackcolor: "#FDF8F0",
|
||||
Qrcodecolor: "#C45500",
|
||||
Usernamecolor: "#000000",
|
||||
Introcolor: "#000000",
|
||||
Dotscale: "0.35",
|
||||
BackgroundImage: conf.Upload.DefaultBackgroundImageURL,
|
||||
Backgroundimagealpha: "0.8",
|
||||
Qrcodepdpcolor: "#C45500",
|
||||
Acfunlink: "none",
|
||||
Bililink: "none",
|
||||
Weibolink: "none",
|
||||
Bloglink: "box.tama.guru",
|
||||
Xiaohongshulink: "none",
|
||||
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
|
||||
}); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrDuplicateEmail):
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateEmail")))
|
||||
case errors.Is(err, db.ErrDuplicateDomain):
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
|
||||
case errors.Is(err, db.ErrBadCredential):
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential")))
|
||||
|
||||
default:
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new user")
|
||||
ctx.SetInternalError()
|
||||
}
|
||||
|
||||
ctx.Success("auth/register")
|
||||
return
|
||||
}
|
||||
|
||||
// 成功提示跟随用户注册时选择的语言
|
||||
lang := "zh-CN"
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
lang = l
|
||||
}
|
||||
ctx.SetSuccessFlash(i18n.T(lang, "auth.register.success"))
|
||||
ctx.Redirect("/login")
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /_/
|
||||
Disallow: /user/
|
||||
Allow: /explore
|
||||
Allow: /llms.txt
|
||||
Allow: /llms-full.txt
|
||||
Sitemap: https://box.tama.guru/sitemap.xml
|
||||
@@ -1,414 +0,0 @@
|
||||
// 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 route
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/cache"
|
||||
cacheRedis "github.com/flamego/cache/redis"
|
||||
"github.com/flamego/csrf"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/recaptcha"
|
||||
"github.com/flamego/session"
|
||||
"github.com/flamego/session/mysql"
|
||||
sessionRedis "github.com/flamego/session/redis"
|
||||
"github.com/flamego/template"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
"github.com/tamakyi/TamaBox/route"
|
||||
"github.com/tamakyi/TamaBox/route/auth"
|
||||
"github.com/tamakyi/TamaBox/route/pixel"
|
||||
"github.com/tamakyi/TamaBox/route/question"
|
||||
"github.com/tamakyi/TamaBox/route/service"
|
||||
"github.com/tamakyi/TamaBox/route/user"
|
||||
"github.com/tamakyi/TamaBox/route/vote"
|
||||
"github.com/tamakyi/TamaBox/static"
|
||||
"github.com/tamakyi/TamaBox/templates"
|
||||
)
|
||||
|
||||
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
|
||||
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
|
||||
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
|
||||
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
//
|
||||
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
|
||||
// CI 失败,避免修复悄悄倒退。
|
||||
const CSRFUserSessionKey = "uid"
|
||||
|
||||
// securityHeaders adds security-related HTTP headers to every response.
|
||||
func securityHeaders() flamego.Handler {
|
||||
return func(ctx flamego.Context) {
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
|
||||
if strings.HasPrefix(conf.App.ExternalURL, "https") {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
|
||||
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
|
||||
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
TrackNotFound: conf.Security.BanTrackNotFound,
|
||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||
Threshold404: conf.Security.BanThreshold404,
|
||||
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
|
||||
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
|
||||
AlertEmail: conf.Security.SecurityAlertEmail,
|
||||
SendAlert: mail.SendSecurityAlertMail,
|
||||
})
|
||||
// 需要输出详细日志时配置如下
|
||||
// if conf.App.Production {
|
||||
// flamego.SetEnv(flamego.EnvTypeProd)
|
||||
// }
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to embed templates file system")
|
||||
}
|
||||
|
||||
gob.Register(time.Time{})
|
||||
gob.Register(context.Flash{})
|
||||
gob.Register(captcha.StoredAnswer{})
|
||||
var sessionStorage interface{}
|
||||
initer := session.MemoryIniter()
|
||||
if conf.Database.DSN != "" {
|
||||
initer = mysql.Initer()
|
||||
sessionStorage = mysql.Config{
|
||||
DSN: conf.Database.DSN,
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
if conf.Redis.Addr != "" {
|
||||
initer = sessionRedis.Initer()
|
||||
sessionStorage = sessionRedis.Config{
|
||||
Options: &sessionRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 1,
|
||||
},
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
sessioner := session.Sessioner(session.Options{
|
||||
Initer: initer,
|
||||
Config: sessionStorage,
|
||||
// 显式声明 cookie 安全属性,不依赖库默认值:
|
||||
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
|
||||
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
|
||||
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
|
||||
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
|
||||
Cookie: session.CookieOptions{
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
|
||||
},
|
||||
})
|
||||
|
||||
f.Use(flamego.Static(flamego.StaticOptions{
|
||||
FileSystem: http.FS(static.FS),
|
||||
Prefix: "/static",
|
||||
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
|
||||
CacheControl: func() string {
|
||||
return "public, max-age=86400"
|
||||
},
|
||||
}))
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||||
f.Use(requestBodyLimitMiddleware())
|
||||
|
||||
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
|
||||
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Get("/", route.Home)
|
||||
f.Get("/pixel", reqUserSignIn, pixel.Index)
|
||||
f.Get("/sponsor", route.Sponsor)
|
||||
f.Get("/change-logs", route.ChangeLogs)
|
||||
f.Get("/about-me", route.AboutMe)
|
||||
f.Get("/robots.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("robots.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
f.Get("/favicon.ico", func(c context.Context) {
|
||||
fs, _ := static.FS.Open("favicon.ico")
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/sitemap.xml", func(c context.Context) {
|
||||
fs, err := static.FS.Open("sitemap.xml")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms-full.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms-full.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
|
||||
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
|
||||
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
|
||||
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
|
||||
}, reqUserSignOut)
|
||||
|
||||
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
|
||||
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
|
||||
|
||||
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
|
||||
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
|
||||
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Get("/share-card", question.ShareCard)
|
||||
}, question.Pager)
|
||||
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
|
||||
f.Group("/{questionID}", func() {
|
||||
f.Get("", question.Item)
|
||||
f.Get("/share-card", question.QuestionShareCard)
|
||||
f.Post("/delete", question.Delete)
|
||||
f.Post("/set-private", question.SetPrivate)
|
||||
f.Post("/set-public", question.SetPublic)
|
||||
f.Post("/pin", question.TogglePin)
|
||||
f.Post("/read", question.SetRead)
|
||||
f.Post("/unread", question.SetUnread)
|
||||
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
|
||||
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
|
||||
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
||||
}, question.Questioner)
|
||||
|
||||
// 新增:私聊路由(需要登录,使用 Token 标识对话)
|
||||
f.Group("/chat/{token}", func() {
|
||||
f.Get("", chat.ConversationPage)
|
||||
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
|
||||
f.Post("/delete", chat.DeleteConversation)
|
||||
f.Post("/block", chat.ToggleBlockUser)
|
||||
}, reqUserSignIn)
|
||||
}, question.Pager) // Pager 中间件已内置页面密码检查
|
||||
|
||||
f.Get("/explore", user.Explore)
|
||||
|
||||
// 匿名投票参与端(公开):token 链接入口
|
||||
f.Group("/vote/{token}", func() {
|
||||
f.Get("", vote.Page)
|
||||
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
|
||||
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
|
||||
})
|
||||
|
||||
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
|
||||
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
|
||||
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
|
||||
f.Get("/i18n/{lang}", func(c context.Context) {
|
||||
lang := i18n.Resolve(c.Param("lang"))
|
||||
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
|
||||
c.Session.Set("lang", lang)
|
||||
to := c.Query("to")
|
||||
// 仅允许站内相对路径,防开放重定向:
|
||||
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
|
||||
if to == "" || !strings.HasPrefix(to, "/") ||
|
||||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
|
||||
to = "/"
|
||||
}
|
||||
c.Redirect(to)
|
||||
})
|
||||
|
||||
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
|
||||
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
|
||||
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
|
||||
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
|
||||
|
||||
f.Group("/user", func() {
|
||||
f.Get("/questions", user.QuestionList)
|
||||
|
||||
// 匿名投票管理(箱主侧)
|
||||
f.Group("/votes", func() {
|
||||
f.Get("", user.VoteList)
|
||||
f.Get("/new", user.VoteNew)
|
||||
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
|
||||
f.Get("/{voteID}/edit", user.VoteEdit)
|
||||
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
|
||||
f.Post("/{voteID}/publish", user.VotePublish)
|
||||
f.Post("/{voteID}/close", user.VoteClose)
|
||||
f.Post("/{voteID}/delete", user.VoteDelete)
|
||||
f.Get("/{voteID}/results", user.VoteResults)
|
||||
f.Get("/{voteID}/preview", user.VotePreview)
|
||||
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
|
||||
f.Get("/{voteID}/export", user.VoteExportCSV)
|
||||
})
|
||||
|
||||
f.Group("/profile", func() {
|
||||
f.Get("", user.Profile)
|
||||
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.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
|
||||
|
||||
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
|
||||
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
|
||||
|
||||
f.Post("/update-chat-settings", user.UpdateChatSettings)
|
||||
|
||||
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
|
||||
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
|
||||
f.Group("/features", func() {
|
||||
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
|
||||
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
|
||||
})
|
||||
})
|
||||
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
|
||||
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
|
||||
// ?tab= 参数回跳到来源面板。
|
||||
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
|
||||
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
|
||||
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
|
||||
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
|
||||
f.Post("/logout", auth.Logout)
|
||||
}, reqUserSignIn)
|
||||
|
||||
f.Group("/api/v1", func() {
|
||||
f.Group("/user", func() {
|
||||
f.Get("", reqUserSignIn, user.ProfileAPI)
|
||||
f.Group("/{domain}", func() {
|
||||
f.Group("/questions", func() {
|
||||
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
|
||||
})
|
||||
})
|
||||
})
|
||||
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
|
||||
// 并加限流防止被用作高频打点通道。
|
||||
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
|
||||
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
|
||||
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
|
||||
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
|
||||
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
|
||||
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
|
||||
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
|
||||
}, context.APIEndpoint)
|
||||
},
|
||||
cache.Cacher(cache.Options{
|
||||
Initer: cacheRedis.Initer(),
|
||||
Config: cacheRedis.Config{
|
||||
Options: &cacheRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
recaptcha.V3(
|
||||
recaptcha.Options{
|
||||
Secret: conf.Recaptcha.ServerKey,
|
||||
VerifyURL: func() recaptcha.VerifyURL {
|
||||
if conf.Recaptcha.TurnstileStyle {
|
||||
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
return recaptcha.VerifyURLGlobal
|
||||
}(),
|
||||
},
|
||||
),
|
||||
sessioner,
|
||||
csrf.Csrfer(csrf.Options{
|
||||
Secret: conf.Server.XSRFKey,
|
||||
Header: "X-CSRF-Token",
|
||||
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
|
||||
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
|
||||
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
|
||||
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
|
||||
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
|
||||
SessionKey: CSRFUserSessionKey,
|
||||
}),
|
||||
template.Templater(template.Options{
|
||||
FileSystem: templateFS,
|
||||
FuncMaps: templatepkg.FuncMap(),
|
||||
}),
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
|
||||
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
|
||||
// 因此这里在 Redirect 之前记录,确保状态为 404。
|
||||
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
|
||||
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
|
||||
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
|
||||
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
|
||||
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
||||
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
|
||||
>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/explore</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/register</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/login</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/_/tamakyi</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://box.tama.guru/change-logs</loc>
|
||||
<priority>0.3</priority>
|
||||
<lastmod>2026-02-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>
|
||||
|
||||
</urlset>
|
||||
@@ -1,118 +0,0 @@
|
||||
// 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 censor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ForbiddenType string
|
||||
|
||||
const (
|
||||
ForbiddenTypeSpam ForbiddenType = "spam"
|
||||
ForbiddenTypeAd ForbiddenType = "ad"
|
||||
ForbiddenTypePolitics ForbiddenType = "politics"
|
||||
ForbiddenTypeTerrorism ForbiddenType = "terrorism"
|
||||
ForbiddenTypeAbuse ForbiddenType = "abuse"
|
||||
ForbiddenTypePorn ForbiddenType = "porn"
|
||||
ForbiddenTypeFlood ForbiddenType = "flood"
|
||||
ForbiddenTypeContraband ForbiddenType = "contraband"
|
||||
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
|
||||
ForbiddenTypeHarmful ForbiddenType = "harmful"
|
||||
)
|
||||
|
||||
func (f ForbiddenType) String() string {
|
||||
return map[ForbiddenType]string{
|
||||
ForbiddenTypeSpam: "含垃圾信息",
|
||||
ForbiddenTypeAd: "广告",
|
||||
ForbiddenTypePolitics: "涉政",
|
||||
ForbiddenTypeTerrorism: "暴恐",
|
||||
ForbiddenTypeAbuse: "辱骂",
|
||||
ForbiddenTypePorn: "色情",
|
||||
ForbiddenTypeFlood: "灌水",
|
||||
ForbiddenTypeContraband: "违禁",
|
||||
ForbiddenTypeMeaningless: "无意义",
|
||||
ForbiddenTypeHarmful: "不良场景",
|
||||
}[f]
|
||||
}
|
||||
|
||||
type TextCensorResponse struct {
|
||||
SourceName string `json:"source_name"`
|
||||
Pass bool `json:"pass"`
|
||||
ForbiddenType ForbiddenType `json:"forbidden_type"`
|
||||
Hint string `json:"hint"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
RawResponse json.RawMessage `json:"raw_response"`
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ToJSON() []byte {
|
||||
jsonBytes, _ := json.Marshal(r)
|
||||
return jsonBytes
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ErrorMessage() string {
|
||||
errorMessage := "内容安全检查不通过"
|
||||
if r.ForbiddenType != "" {
|
||||
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
|
||||
}
|
||||
if r.Hint != "" {
|
||||
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
|
||||
}
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
type TextCensor interface {
|
||||
Censor(ctx context.Context, text string) (*TextCensorResponse, error)
|
||||
String() string
|
||||
}
|
||||
|
||||
var TrustedHosts = []string{
|
||||
"xhslink.com", // 小红书
|
||||
"weibo.com", // 微博
|
||||
"b23.tv", // Bilibili
|
||||
"acfun.cn",
|
||||
"aixifan.com",
|
||||
"github.com",
|
||||
"tama.guru",
|
||||
"337845818.best",
|
||||
"x.com",
|
||||
"bilibili.com",
|
||||
}
|
||||
|
||||
// RemoveTrustedURL removes the trusted URL from the text,
|
||||
// in order to bypass the URL censoring.
|
||||
// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信,
|
||||
// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。
|
||||
func RemoveTrustedURL(str string) string {
|
||||
for _, host := range TrustedHosts {
|
||||
for _, protocol := range []string{"http://", "https://"} {
|
||||
needle := protocol + host
|
||||
var b strings.Builder
|
||||
rest := str
|
||||
for {
|
||||
idx := strings.Index(rest, needle)
|
||||
if idx < 0 {
|
||||
b.WriteString(rest)
|
||||
break
|
||||
}
|
||||
b.WriteString(rest[:idx])
|
||||
after := rest[idx+len(needle):]
|
||||
if len(after) == 0 || after[0] == '/' || after[0] == '?' || after[0] == '#' {
|
||||
// 可信域名边界:跳过该前缀,从域名之后继续(域名本身被移除,不进入审核)。
|
||||
rest = after
|
||||
} else {
|
||||
// 非可信域名边界(如 github.com.evil):保留前缀,从其后继续扫描。
|
||||
b.WriteString(needle)
|
||||
rest = after
|
||||
}
|
||||
}
|
||||
str = b.String()
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
// Copyright 2024 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 storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/thanhpk/randstr"
|
||||
"github.com/wuhan005/gadget"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
)
|
||||
|
||||
// MaxUploadBytes 是单次图片上传的内存读取上限(8MB)。
|
||||
// 调用方虽在 HTTP 层对 fileHeader.Size 做了校验,但该值可被客户端伪造,
|
||||
// 因此在读取前用 io.LimitReader 兜底,避免超大文件将内存撑爆(DoS)。
|
||||
const MaxUploadBytes = 8 * 1024 * 1024
|
||||
|
||||
// aspect 约束一次上传的宽高比(wantW:wantH,容差 DefaultAspectTolerance)。
|
||||
// 头像固定 1:1、页面背景固定 16:9:客户端裁剪后即为精确比例,
|
||||
// 服务端复校验用于拦截绕过前端直接 POST 的越界图片。
|
||||
type aspect struct {
|
||||
W, H float64
|
||||
}
|
||||
|
||||
func (a *aspect) check(data []byte) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance)
|
||||
}
|
||||
|
||||
// UploadAvatarToS3 上传头像:除通用校验/转换外强制 1:1 宽高比。
|
||||
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1})
|
||||
}
|
||||
|
||||
// UploadBackgroundToS3 上传页面背景:除通用校验/转换外强制 16:9 宽高比。
|
||||
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9})
|
||||
}
|
||||
|
||||
// UploadPictureToS3 上传通用图片到 Cloudflare R2,自动转换为 WebP(动画 GIF 可选转换)
|
||||
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, nil)
|
||||
}
|
||||
|
||||
// uploadPictureToS3 是上传管线实现:读入 → 魔数 → (可选)比例校验 → 像素总量校验
|
||||
// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验复用同一管线,
|
||||
// 仅通过 aspect 参数区分,避免为一种用途复制一套上传代码。
|
||||
func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect) (string, error) {
|
||||
// 1. 限制读取大小,防止超大上传撑爆内存(DoS)。
|
||||
// 先用 LimitReader 截断到 MaxUploadBytes+1 字节;若实际读到的字节数超过上限则直接拒绝。
|
||||
limited := io.LimitReader(file, MaxUploadBytes+1)
|
||||
fileData, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "read file data")
|
||||
}
|
||||
if int64(len(fileData)) > MaxUploadBytes {
|
||||
return "", errors.New("uploaded file is too large")
|
||||
}
|
||||
|
||||
// 2. 校验文件魔数,仅允许 JPEG / PNG / GIF / WebP
|
||||
if !isSupportedImage(fileData) {
|
||||
return "", errors.New("unsupported file type: not a valid image")
|
||||
}
|
||||
|
||||
// 2.5 用途宽高比校验(头像 1:1 / 背景 16:9),绕过前端裁剪的直传在此被拦截
|
||||
if err := asp.check(fileData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fileReader := bytes.NewReader(fileData)
|
||||
|
||||
// 3. 解码前先校验像素总量(防解压炸弹),再解码图片获取格式;解码失败则直接返回错误。
|
||||
if err := imagecheck.CheckPixelLimit(bytes.NewReader(fileData)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, format, err := image.Decode(fileReader)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "image decode failed, file may be corrupted")
|
||||
}
|
||||
|
||||
var uploadData []byte
|
||||
var contentType string
|
||||
|
||||
// 重置 reader,以便后续可能的 resize 操作
|
||||
if _, err := fileReader.Seek(0, io.SeekStart); err != nil {
|
||||
return "", errors.Wrap(err, "reset reader")
|
||||
}
|
||||
|
||||
// 4. 根据格式进行转换或直接使用原图
|
||||
switch format {
|
||||
case "gif":
|
||||
webpData, err := convertGifToAnimatedWebP(fileData)
|
||||
if err != nil {
|
||||
// 转换失败则回退到原始 GIF
|
||||
uploadData = fileData
|
||||
contentType = "image/gif"
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
}
|
||||
default:
|
||||
// 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP
|
||||
webpData, err := ConvertStaticImageToWebP(fileReader)
|
||||
if err != nil {
|
||||
// 转换失败时回退到原始图片
|
||||
uploadData = fileData
|
||||
contentType = mimeTypeFromFormat(format)
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成存储路径(无后缀名)
|
||||
now := time.Now()
|
||||
year := now.Year()
|
||||
month := int(now.Month())
|
||||
day := now.Day()
|
||||
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
|
||||
|
||||
// 6. 获取共享 S3 客户端
|
||||
client, err := GetS3Client()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "get S3 client")
|
||||
}
|
||||
|
||||
// 7. 上传到 R2
|
||||
if err := gadget.Retry(5, func() error {
|
||||
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(uploadData),
|
||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return "", errors.Wrap(err, "put object after retry")
|
||||
}
|
||||
|
||||
// 8. 返回 CDN URL
|
||||
return fmt.Sprintf("https://%s/%s", conf.Upload.ImageBucketCDNHost, key), nil
|
||||
}
|
||||
|
||||
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG等)转换为 WebP 格式
|
||||
// 限制最大宽度为 1920px,保持比例
|
||||
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) {
|
||||
// 先整体读入并校验像素总量(防解压炸弹),再完整解码。
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read image")
|
||||
}
|
||||
if err := imagecheck.CheckPixelLimit(bytes.NewReader(data)); err != nil {
|
||||
return nil, errors.Wrap(err, "check image pixel limit")
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "decode image")
|
||||
}
|
||||
|
||||
maxWidth := 1920
|
||||
if img.Bounds().Dx() > maxWidth {
|
||||
img = imaging.Resize(img, maxWidth, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
|
||||
return nil, errors.Wrap(err, "webp encode")
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// convertGifToAnimatedWebP 将 GIF 文件数据转换为动画 WebP(需要系统安装 cwebp 工具)
|
||||
// 如果 cwebp 不可用,返回错误,调用方应回退到原始 GIF
|
||||
func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
|
||||
_, err := exec.LookPath("cwebp")
|
||||
if err != nil {
|
||||
return nil, errors.New("cwebp not found in PATH")
|
||||
}
|
||||
|
||||
tmpGif, err := os.CreateTemp("", "input_*.gif")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create temp gif")
|
||||
}
|
||||
defer os.Remove(tmpGif.Name())
|
||||
if _, err := tmpGif.Write(gifData); err != nil {
|
||||
return nil, errors.Wrap(err, "write temp gif")
|
||||
}
|
||||
tmpGif.Close()
|
||||
|
||||
tmpWebp, err := os.CreateTemp("", "output_*.webp")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create temp webp")
|
||||
}
|
||||
defer os.Remove(tmpWebp.Name())
|
||||
tmpWebp.Close()
|
||||
|
||||
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.Wrap(err, "run cwebp")
|
||||
}
|
||||
|
||||
webpData, err := os.ReadFile(tmpWebp.Name())
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read webp output")
|
||||
}
|
||||
return webpData, nil
|
||||
}
|
||||
|
||||
// mimeTypeFromFormat 根据图片格式返回 MIME 类型
|
||||
func mimeTypeFromFormat(format string) string {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// isSupportedImage 通过文件头魔数判断是否为支持的图片格式
|
||||
func isSupportedImage(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return true
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47
|
||||
if bytes.Equal(data[:4], []byte{0x89, 0x50, 0x4E, 0x47}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// GIF: GIF87a / GIF89a
|
||||
if len(data) >= 6 {
|
||||
if bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WebP: RIFF ... WEBP
|
||||
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,977 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.LangCode}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{ if .MetaDescription }}{{ .MetaDescription }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}">
|
||||
<meta name="keywords" content="{{ t .LangCode "site.keywords" }}">
|
||||
<!-- 预连接:UIkit 走公共 CDN(cdnjs.snrat.com),其余自托管库仍在本站 R2,均需预连接 -->
|
||||
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cn.vercount.one">
|
||||
<!-- UIkit 3.3.3 切到公共 CDN(cdnjs.snrat.com,国内实测全量下载 ~0.25s,原 R2 自建 ~1.4-2.0s) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
|
||||
<!-- Waline 评论区样式:非阻塞加载,不阻塞首屏渲染 -->
|
||||
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
|
||||
<!-- UIkit 补丁:复刻原自建版 "modify-black-font"(官方版与自建版仅 3 处颜色差异——把有色容器内默认白字/白标题改为黑字)。
|
||||
模板当前只用到 .uk-light 容器(用户背景封面,question/list.html、question-not-found.html;
|
||||
其中 h3/p 已由 #usernamecolor/#introcolor 内联 style 覆盖),故只需补该容器的 3 处投影即可保持渲染一致。
|
||||
若日后新增 .uk-card-primary/.uk-card-secondary/.uk-section-primary/.uk-tile-*/.uk-overlay-primary 等容器且需黑字,须同步扩展。 -->
|
||||
<style>
|
||||
.uk-light { color: rgba(0,0,0,.7); }
|
||||
.uk-light .uk-h1, .uk-light .uk-h2, .uk-light .uk-h3, .uk-light .uk-h4, .uk-light .uk-h5, .uk-light .uk-h6,
|
||||
.uk-light .uk-heading-2xlarge, .uk-light .uk-heading-large, .uk-light .uk-heading-medium, .uk-light .uk-heading-small, .uk-light .uk-heading-xlarge,
|
||||
.uk-light h1, .uk-light h2, .uk-light h3, .uk-light h4, .uk-light h5, .uk-light h6 { color: #000; }
|
||||
.uk-light .uk-table th { color: rgba(0,0,0,.7); }
|
||||
</style>
|
||||
<!-- dayjs/alpinejs/vconsole/waline.js 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
|
||||
{{ if .VConsole }}
|
||||
<script src="https://cdnjs.snrat.com/ajax/libs/vConsole/3.12.1/vconsole.min.js" type="application/javascript"></script>
|
||||
<script>new window.VConsole()</script>
|
||||
{{ end }}
|
||||
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 -->
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/time.box.js"></script>
|
||||
<!--<script src="https://r2.337845818.best/tamabox/cdn/busuanzi.pure.mini.js"/></script>-->
|
||||
<script defer src="https://cn.vercount.one/js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码:弹窗模式,不影响原布局 -->
|
||||
<style>
|
||||
.captcha-equation-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f0f2f5;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.captcha-equation-box:focus-within {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
|
||||
}
|
||||
.math-captcha-modal-question {
|
||||
font-size: 1.4rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
width: 80px;
|
||||
padding: 6px 10px;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
#captcha-modal-answer:focus {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
|
||||
}
|
||||
/* 隐藏数字输入框的上下箭头 */
|
||||
#captcha-modal-answer::-webkit-inner-spin-button,
|
||||
#captcha-modal-answer::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
{{ else if .RecaptchaTurnstileStyle }}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = true;
|
||||
});
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = false;
|
||||
})
|
||||
}</script>
|
||||
{{ else }}
|
||||
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
|
||||
<script>function onSubmit() {document.getElementById('form').submit();}</script>
|
||||
{{ end }}
|
||||
{{ if ne SentryDSN "" }}
|
||||
<script
|
||||
src="https://browser.sentry-cdn.com/7.37.2/bundle.tracing.replay.min.js"
|
||||
integrity="sha384-o0mU2P+zsvXua0Y6f/J55PKWZt3sZCv3qDqJe6J5lZklyIQkpgjBVqNnPWYWqGhf"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
Sentry.init({
|
||||
dsn: "{{ SentryDSN }}",
|
||||
integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
<style>
|
||||
.grecaptcha-badge {visibility: hidden;}
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
width: 20px; /* 设置图标的宽度 */
|
||||
height: auto; /* 保持图标的高度自动调整 */
|
||||
}
|
||||
.emoji-placeholder {
|
||||
display: inline-block;
|
||||
animation: jump 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
/* 防止 UIkit Dropbar 初始闪烁 */
|
||||
.uk-dropbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.uk-dropbar.uk-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
a.uk-button.uk-button-link {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* 移动端优化:缩小字体和间距,避免右侧空白 */
|
||||
@media (max-width: 640px) {
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.emoji-placeholder {
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* 移动端用户名限宽省略,避免长英文用户名撑爆顶栏 */
|
||||
.tb-nav-username {
|
||||
max-width: 64px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 深色模式:html[data-theme="dark"] 时覆盖 UIkit 3.3.3 编译主题的关键颜色 -->
|
||||
<style>
|
||||
html[data-theme="dark"] {
|
||||
--tb-bg: #1a1d23;
|
||||
--tb-surface: #242830;
|
||||
--tb-surface-2: #2d323c;
|
||||
--tb-border: #3a404c;
|
||||
--tb-text: #d8dce4;
|
||||
--tb-text-muted: #8b93a1;
|
||||
--tb-primary: #5c8dff;
|
||||
}
|
||||
/* 根元素与 body 一起覆盖,确保整个视口(含内容不足一屏时的空白区)都是深色背景 */
|
||||
html[data-theme="dark"],
|
||||
html[data-theme="dark"] body {
|
||||
background-color: var(--tb-bg) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
/* 通用表面容器 */
|
||||
html[data-theme="dark"] .uk-card,
|
||||
html[data-theme="dark"] .uk-card-default,
|
||||
html[data-theme="dark"] .uk-card-secondary,
|
||||
html[data-theme="dark"] .uk-dropbar,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown,
|
||||
html[data-theme="dark"] .uk-modal-dialog,
|
||||
html[data-theme="dark"] .uk-dropdown,
|
||||
html[data-theme="dark"] .uk-offcanvas-bar,
|
||||
html[data-theme="dark"] .uk-section,
|
||||
html[data-theme="dark"] .uk-tile,
|
||||
html[data-theme="dark"] .uk-panel,
|
||||
html[data-theme="dark"] .uk-background-default,
|
||||
html[data-theme="dark"] .uk-search-default .uk-search-input {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-container {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a,
|
||||
html[data-theme="dark"] .uk-card a,
|
||||
html[data-theme="dark"] .uk-link,
|
||||
html[data-theme="dark"] a.uk-button-link,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-list a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-card a:hover,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input,
|
||||
html[data-theme="dark"] .uk-textarea,
|
||||
html[data-theme="dark"] .uk-select {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input:focus,
|
||||
html[data-theme="dark"] .uk-textarea:focus,
|
||||
html[data-theme="dark"] .uk-select:focus {
|
||||
border-color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-text-muted,
|
||||
html[data-theme="dark"] .uk-text-meta,
|
||||
html[data-theme="dark"] .uk-text-secondary,
|
||||
html[data-theme="dark"] .uk-text-muted a,
|
||||
html[data-theme="dark"] .uk-text-small.uk-text-muted {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-divider-icon::after,
|
||||
html[data-theme="dark"] hr,
|
||||
html[data-theme="dark"] .uk-table th,
|
||||
html[data-theme="dark"] .uk-table td,
|
||||
html[data-theme="dark"] .uk-list-divider > li:nth-child(n+2) {
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-button-default {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-modal-title,
|
||||
html[data-theme="dark"] h1, html[data-theme="dark"] h2, html[data-theme="dark"] h3,
|
||||
html[data-theme="dark"] h4, html[data-theme="dark"] h5, html[data-theme="dark"] h6,
|
||||
html[data-theme="dark"] .uk-card-title {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-alert-warning,
|
||||
html[data-theme="dark"] .uk-alert-danger,
|
||||
html[data-theme="dark"] .uk-alert-success,
|
||||
html[data-theme="dark"] .uk-alert-primary {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-modal-dialog {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .captcha-equation-box {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .math-captcha-modal-question {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer::placeholder {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-text-meta,
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-label {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* 自定义组件(探索页推荐卡片等)深色覆盖 */
|
||||
html[data-theme="dark"] .recommend-card,
|
||||
html[data-theme="dark"] .recommend-card:hover {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-intro {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
/* 切换按钮图标切换 */
|
||||
#theme-toggle .tb-icon-dark, #theme-toggle-mobile .tb-icon-dark { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-light,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-light { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-dark,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-dark { display: inline; }
|
||||
</style>
|
||||
|
||||
<!-- 顶栏自适应:导航全部直接显示,窄屏时分级缩小字号/间距,保证英文/日文等长文字不溢出 -->
|
||||
<style>
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.95rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
/* 未登录移动端占位:桌面端隐藏,仅 ≤640px 显示(见下方 media 查询)。
|
||||
桌面端布局与改动前完全一致,不受影响。 */
|
||||
.tb-mobile-balancer { display: none; }
|
||||
/* 桌面端保持 UIkit 原生居中三列布局(无需额外 CSS) */
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.88rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.82rem;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
/* 登录用户名限宽,过长省略号截断 */
|
||||
.tb-nav-username {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.tb-nav-username {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
/* 手机端(≤640px):沿用 UIkit 桌面端 navbar 的原生三列定位。
|
||||
不再重写 .uk-navbar-center / center-left / center-right 的定位,
|
||||
避免 logo 被挤到下一行;底部语言与深色模式按钮仍由下方独立栏承载。 */
|
||||
@media (max-width: 640px) {
|
||||
.tb-navbar {
|
||||
position: relative;
|
||||
min-height: 56px;
|
||||
overflow: visible;
|
||||
}
|
||||
.tb-navbar > .uk-navbar-center {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left,
|
||||
.tb-navbar .uk-navbar-center-right {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left > div,
|
||||
.tb-navbar .uk-navbar-center-right > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav > li > a,
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 未登录:移动端显示与「我的」等宽的隐藏占位,使左栏「探索+占位」与
|
||||
右栏「注册/登录」两项等宽,logo 真正居中,左右视觉平衡 */
|
||||
.tb-mobile-balancer { display: block; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 标签样式的按钮(管理页置顶等):
|
||||
尺寸完全继承 .uk-label(font-size/padding/line-height/text-transform 等),
|
||||
仅覆盖 button 元素特有的默认样式(边框/字体族/光标等),保证与相邻 span 标签完全一致。 -->
|
||||
<style>
|
||||
.tb-label-btn {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
outline: none;
|
||||
background-image: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.tb-label-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 公开页置顶小图标:仅一个图钉,不占行高、不影响原布局 */
|
||||
.tb-pin-flag {
|
||||
font-size: 0.9em;
|
||||
margin-right: 4px;
|
||||
vertical-align: -1px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 页脚统计加载动画:替代 fontawesome 的 fa-spinner(纯 CSS,不引字体库) -->
|
||||
<style>
|
||||
.tb-spinner {
|
||||
display: inline-block;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border: 2px solid rgba(128, 128, 128, 0.25);
|
||||
border-top-color: #666;
|
||||
border-radius: 50%;
|
||||
vertical-align: -2px;
|
||||
animation: tb-spin 0.8s linear infinite;
|
||||
}
|
||||
html[data-theme="dark"] .tb-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #bbb;
|
||||
}
|
||||
@keyframes tb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Markdown 渲染排版(问题/回答/追问正文,左对齐以保留排版格式) -->
|
||||
<style>
|
||||
/* 纯文本列表:保留换行与连续空格(pre-wrap),不渲染 Markdown 大字号/彩色 */
|
||||
.tb-preline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 纯文本列表:超过 5 行截断并显示省略号(不足 5 行则完整显示) */
|
||||
.tb-clamp-5 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-body {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
.md-body p { margin: 0.5em 0; }
|
||||
.md-body h1, .md-body h2, .md-body h3,
|
||||
.md-body h4, .md-body h5, .md-body h6 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.md-body ul, .md-body ol { padding-left: 1.6em; margin: 0.5em 0; }
|
||||
.md-body li { margin: 0.2em 0; }
|
||||
.md-body blockquote {
|
||||
border-left: 3px solid #d3d1c7;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.2em 1em;
|
||||
color: #5f5e5a;
|
||||
}
|
||||
.md-body pre {
|
||||
background: #f5f5f5;
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.md-body code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.md-body pre code { background: none; padding: 0; }
|
||||
.md-body img { max-width: 100%; height: auto; }
|
||||
.md-body table { border-collapse: collapse; margin: 0.6em 0; }
|
||||
.md-body th, .md-body td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4em 0.8em;
|
||||
}
|
||||
.md-body hr { margin: 1em 0; }
|
||||
html[data-theme="dark"] .md-body blockquote {
|
||||
border-color: #3a404c;
|
||||
color: #8b93a1;
|
||||
}
|
||||
html[data-theme="dark"] .md-body pre,
|
||||
html[data-theme="dark"] .md-body code { background: #2d323c; }
|
||||
html[data-theme="dark"] .md-body th,
|
||||
html[data-theme="dark"] .md-body td { border-color: #3a404c; }
|
||||
</style>
|
||||
|
||||
<!-- 投票结果占比条:UIkit 3 已无 .uk-progressbar 类(那是 UIkit 2 的),
|
||||
模板用 <div class="uk-progress"><div class="uk-progressbar"> 结构,需补一套填充样式,
|
||||
否则内层填充无高度/背景,所有占比条都只剩外层灰色轨道,看起来一模一样。 -->
|
||||
<style>
|
||||
.uk-progress {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #e5e5e5;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
.uk-progressbar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: #1e87f0;
|
||||
border-radius: 6px 0 0 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
html[data-theme="dark"] .uk-progress {
|
||||
background-color: var(--tb-border);
|
||||
}
|
||||
html[data-theme="dark"] .uk-progressbar {
|
||||
background-color: var(--tb-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 -->
|
||||
<style>
|
||||
#tb-mobile-utils {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* 顶栏不再容纳语言/深色按钮,恢复原有导航空间 */
|
||||
.tb-nav-utils {
|
||||
display: none !important;
|
||||
}
|
||||
/* 手机端隐藏登录用户名(左边仅保留"探索"),保证 logo 居中、
|
||||
两侧各不超过 2 个短按钮,避免用户名宽度挤压布局 */
|
||||
.tb-nav-username {
|
||||
display: none !important;
|
||||
}
|
||||
/* 固定底部操作栏 */
|
||||
#tb-mobile-utils {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
#tb-mobile-utils .uk-select {
|
||||
width: 110px;
|
||||
}
|
||||
html[data-theme="dark"] #tb-mobile-utils {
|
||||
background: rgba(36, 40, 48, 0.92);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
|
||||
body {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Commit 6d0a3ad -->
|
||||
<meta property="og:site_name" content="{{ t .LangCode "site.title" }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.title" }}{{ else }}{{ t .LangCode "site.title" }}{{ end }}" />
|
||||
<meta property="og:url" content="{{ .ExternalURL }}{{ .CurrentURI }}" />
|
||||
<meta property="og:description" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.description" }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}" />
|
||||
{{ if .NoIndex }}<meta name="robots" content="noindex">{{ end }}
|
||||
{{ if .IsUserPage }}
|
||||
<meta property="og:image" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
<meta property="og:image:alt" content="{{ .PageUser.Name }}" />
|
||||
<meta itemprop="image" content="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<meta name="msapplication-TileImage" content="{{ .PageUser.Avatar }}"/>
|
||||
<link rel="shortcut icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="apple-touch-icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="icon" sizes="192x192" href="{{ .PageUser.Avatar }}?og-image=1">
|
||||
<meta name="twitter:image:src" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
{{ else }}
|
||||
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
|
||||
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
{{ end }}
|
||||
|
||||
<!-- Google and Bing -->
|
||||
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
|
||||
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
|
||||
<script defer src="https://mami.tama.guru/tamami.js" data-website-id="c42ebe8b-9281-4da0-83a4-8f18b3e710fe"></script>
|
||||
</head>
|
||||
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
|
||||
<div class="uk-navbar-center">
|
||||
<div class="uk-navbar-center-left">
|
||||
<div>
|
||||
<ul class="uk-navbar-nav">
|
||||
<!-- 探索链接,始终显示 -->
|
||||
<li><a href="/explore">{{t .LangCode "nav.explore"}}</a></li>
|
||||
{{ if .IsLogged }}
|
||||
<!-- 我的:进入自己的提问箱主页(与右侧「提问/设置」形成左右均衡) -->
|
||||
<li><a href="/_/{{ .LoggedUser.Domain }}">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ else }}
|
||||
<!-- 移动端占位:未登录时左栏仅「探索」一项,与右栏「注册/登录」两项不等宽,
|
||||
导致 logo 整体被拉向左(偏左)。移动端(≤640px)显示一个与「我的」等宽的隐藏占位,
|
||||
使左右视觉平衡;桌面端由 .tb-mobile-balancer 隐藏,保持原布局完全不变。 -->
|
||||
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>{{t .LangCode "site.logoSuffix"}}</a>
|
||||
|
||||
<div class="uk-navbar-center-right">
|
||||
<div>
|
||||
{{ if .IsLogged }}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/questions">{{t .LangCode "nav.ask"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/profile">{{t .LangCode "nav.settings"}}</a></li>
|
||||
</ul>
|
||||
{{ else}}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/register">{{t .LangCode "nav.register"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/login">{{t .LangCode "nav.login"}}</a></li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{ if not .IsUserPage }}
|
||||
<!-- 语言切换(紧凑图标,点击展开;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" aria-label="{{t .LangCode "common.lang"}}" title="{{t .LangCode "common.lang"}}">🌐</a>
|
||||
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
{{ range .SupportedLangs }}
|
||||
<li class="{{ if eq .Code $.LangCode }}uk-active{{ end }}">
|
||||
<a href="/i18n/{{ .Code }}?to={{ urlquery $.CurrentURI }}">{{ .Name }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
<!-- 深色模式切换(紧凑图标;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" id="theme-toggle" title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
|
||||
const placeholder = document.querySelector('.emoji-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 移动端固定底部操作栏:语言切换 + 深色模式(桌面端隐藏) -->
|
||||
<div id="tb-mobile-utils">
|
||||
{{ if not .IsUserPage }}
|
||||
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="{{t .LangCode "common.lang"}}">
|
||||
{{ range .SupportedLangs }}
|
||||
<option value="{{ .Code }}" {{ if eq .Code $.LangCode }}selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{ end }}
|
||||
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
|
||||
title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 深色模式:读取 cookie 应用主题,按钮切换并持久化
|
||||
var THEME_COOKIE = 'theme';
|
||||
function getTheme() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
function toggleTheme() {
|
||||
var next = getTheme() === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
|
||||
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
|
||||
}
|
||||
// 首屏前应用,避免闪烁
|
||||
applyTheme(getTheme());
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 桌面顶栏按钮与移动端底部按钮都绑定
|
||||
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
|
||||
buttons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
});
|
||||
});
|
||||
|
||||
// 移动端底部语言切换:onchange 跳转到 /i18n/{code},保留当前页面位置
|
||||
var langSel = document.getElementById('mobile-lang-select');
|
||||
if (langSel) {
|
||||
langSel.addEventListener('change', function() {
|
||||
if (!this.value) return;
|
||||
var to = window.location.pathname + window.location.search;
|
||||
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ if .IsPixel }}
|
||||
<div>
|
||||
{{ else }}
|
||||
<div class="uk-container uk-container-xsmall">
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码弹窗 --><div id="math-captcha-modal" uk-modal>
|
||||
<div class="uk-modal-dialog uk-modal-body uk-text-center">
|
||||
<h3 class="uk-modal-title">{{t .LangCode "captcha.title"}}</h3>
|
||||
<p class="uk-text-meta">{{t .LangCode "captcha.hint"}}</p>
|
||||
<div class="captcha-equation-box">
|
||||
<span id="captcha-modal-question" class="math-captcha-modal-question">{{t .LangCode "captcha.loading"}}</span>
|
||||
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">{{t .LangCode "captcha.refresh"}}</button>
|
||||
</div>
|
||||
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
|
||||
<div class="uk-margin-small-top">
|
||||
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">{{t .LangCode "captcha.submit"}}</button>
|
||||
<button type="button" class="uk-button uk-button-default uk-modal-close">{{t .LangCode "common.cancel"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CAPTCHA_I18N = {
|
||||
loading: "{{t .LangCode "captcha.loading"}}",
|
||||
loadFailed: "{{t .LangCode "captcha.loadFailed"}}",
|
||||
loadRetry: "{{t .LangCode "captcha.loadRetry"}}",
|
||||
inputAnswer: "{{t .LangCode "captcha.inputAnswer"}}",
|
||||
wrongAnswer: "{{t .LangCode "captcha.wrongAnswer"}}",
|
||||
verifyFailed: "{{t .LangCode "captcha.verifyFailed"}}",
|
||||
needPassword: "{{t .LangCode "ask.needPassword"}}",
|
||||
needEmail: "{{t .LangCode "ask.pleaseEmail"}}"
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var captchaId = '';
|
||||
var pendingForm = null;
|
||||
var pendingNeedPassword = false;
|
||||
var verifying = false;
|
||||
|
||||
function fetchCaptcha() {
|
||||
var qEl = document.getElementById('captcha-modal-question');
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
var ansEl = document.getElementById('captcha-modal-answer');
|
||||
qEl.textContent = CAPTCHA_I18N.loading;
|
||||
fetch('/api/captcha/generate')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.code === 0 && data.data) {
|
||||
captchaId = data.data.id;
|
||||
qEl.textContent = data.data.question;
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
|
||||
} else {
|
||||
qEl.textContent = CAPTCHA_I18N.loadFailed;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
qEl.textContent = CAPTCHA_I18N.loadRetry;
|
||||
});
|
||||
}
|
||||
|
||||
function getCSRFToken() {
|
||||
var input = document.querySelector('input[name="_csrf"]');
|
||||
return input ? input.value : '';
|
||||
}
|
||||
|
||||
function doSubmit(form) {
|
||||
// 将 captcha 答案填入隐藏字段
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
var hidden = form.querySelector('[name="g-recaptcha-response"]');
|
||||
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
|
||||
|
||||
UIkit.modal('#math-captcha-modal').hide();
|
||||
|
||||
// 处理 AskPassword(提问箱密码)
|
||||
if (pendingNeedPassword) {
|
||||
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
|
||||
.then(function(password) {
|
||||
if (password) {
|
||||
var pwInput = document.getElementById('ask_password_input');
|
||||
if (pwInput) pwInput.value = password;
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// 验证按钮
|
||||
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
|
||||
if (verifying) return;
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
if (!ans) {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.inputAnswer;
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
verifying = true;
|
||||
fetch('/api/captcha/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
verifying = false;
|
||||
if (data.correct) {
|
||||
if (pendingForm) doSubmit(pendingForm);
|
||||
} else {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha(); // 自动刷新题目
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
verifying = false;
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.verifyFailed;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha();
|
||||
});
|
||||
});
|
||||
|
||||
// 回车提交
|
||||
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
document.getElementById('captcha-modal-submit').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新按钮
|
||||
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
|
||||
|
||||
// 拦截所有带 class="math-captcha-trigger" 的按钮
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.math-captcha-trigger');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var form = btn.closest('form');
|
||||
if (!form) return;
|
||||
pendingForm = form;
|
||||
|
||||
// 检查是否需要提问密码
|
||||
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
|
||||
|
||||
// 邮箱校验(提问页面)
|
||||
var notifyCb = document.getElementById('notify-checkbox');
|
||||
var emailInput = document.getElementById('email-input');
|
||||
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
|
||||
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCaptcha();
|
||||
UIkit.modal('#math-captcha-modal').show();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -1,309 +0,0 @@
|
||||
{{template "base/header" .}}
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目(R2 对无 Origin 请求的响应无 Vary,可缓存 31 天) -->
|
||||
<link rel="shortcut icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<style>
|
||||
/* 投票列表:与探索页"全部投票"一致的列表行样式 */
|
||||
.profile-votes-list { display: grid; gap: 0; }
|
||||
.profile-vote-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--tb-border, #e9e9e9);
|
||||
text-decoration: none;
|
||||
}
|
||||
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border, #e9e9e9); }
|
||||
.profile-vote-item:hover { background: var(--tb-surface-hover, #f8fbff); text-decoration: none; }
|
||||
.profile-vote-content { min-width: 0; }
|
||||
.profile-vote-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--tb-text, #333);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profile-vote-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
|
||||
.profile-vote-action {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
|
||||
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
|
||||
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
|
||||
/* 个人主页 Tab:与探索页一致的胶囊切换样式 */
|
||||
/* 个人主页 Tab:全宽等分,置于主体顶部,作为一级导航(移动端整行可点) */
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--tb-border, #e5e5e5);
|
||||
border-radius: 9px;
|
||||
background: var(--tb-surface-2, #fafafa);
|
||||
}
|
||||
.profile-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 9px 15px;
|
||||
background: transparent;
|
||||
color: var(--tb-text-muted, #666);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.profile-tab:hover { color: var(--tb-primary, #1e87f0); }
|
||||
.profile-tab.is-active {
|
||||
background: var(--tb-surface, #fff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 功能关闭时置灰禁用的 Tab(如投票功能关闭) */
|
||||
.profile-tab.is-disabled,
|
||||
.profile-tab:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--tb-text-muted, #999);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.profile-tab.is-disabled:hover { color: var(--tb-text-muted, #999); }
|
||||
.profile-panel[hidden] { display: none !important; }
|
||||
html[data-theme="dark"] .profile-tabs {
|
||||
border-color: var(--tb-border);
|
||||
background: var(--tb-surface-2);
|
||||
}
|
||||
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
||||
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
||||
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
||||
</style>
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = "{{.PageUser.Usernamecolor}}";
|
||||
document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 已改为按需加载:defer 按文档顺序先于 easy.qrcode 执行,保证就绪 -->
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">{{t .LangCode "ask.scan" .PageUser.Name}}<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
|
||||
<div class="uk-text-center uk-margin-small-top">
|
||||
<a id="share-btn" href="/_/{{ .PageUser.Domain }}/share-card" download class="uk-button uk-button-default uk-button-small">
|
||||
📤 {{t .LangCode "ask.share"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
|
||||
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
|
||||
<p class="uk-text-large uk-text-muted">{{t .LangCode "ask.generating"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('share-btn');
|
||||
var overlay = document.getElementById('share-loading-overlay');
|
||||
if (!btn || !overlay) return;
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
overlay.style.display = 'block';
|
||||
btn.style.pointerEvents = 'none';
|
||||
fetch(btn.href)
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('{{t .LangCode "ask.networkError"}}');
|
||||
return response.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '{{t .LangCode "ask.shareFilename"}}';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('{{t .LangCode "ask.genFailed"}}');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(function() {
|
||||
overlay.style.display = 'none';
|
||||
btn.style.pointerEvents = 'auto';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
||||
<div class="profile-tabs" role="tablist">
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
||||
{{ if .VoteEnabled }}
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "votes" }} is-active{{ end }}" data-profile-tab="votes" role="tab" aria-selected="{{ if eq .ActiveProfileTab "votes" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
|
||||
{{ else }}
|
||||
<!-- 投票功能关闭:保留 Tab 展示但置灰禁用,hover 提示原因 -->
|
||||
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="{{ .VoteClosedMessage }}">{{t .LangCode "explore.votesTab"}} <span uk-icon="icon: lock; ratio: 0.8"></span></button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="questions"{{ if eq .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="votes"{{ if ne .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{ if .PageVotes }}
|
||||
<!-- 匿名投票板块:箱主"进行中"的公开投票(列表行样式,与探索页"全部投票"一致) -->
|
||||
<div class="profile-votes-list">
|
||||
{{ range .PageVotes }}
|
||||
<a href="/vote/{{ .LinkToken }}" class="profile-vote-item">
|
||||
<div class="profile-vote-content">
|
||||
<span class="profile-vote-title" title="{{ .Title }}">{{ .Title }}</span>
|
||||
<span class="profile-vote-meta">
|
||||
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
|
||||
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
|
||||
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
|
||||
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
|
||||
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
|
||||
<span class="uk-text-meta">{{ .CreatedAt.Format "2006-01-02" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="profile-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="uk-alert uk-alert-primary uk-margin-remove-top">{{t .LangCode "explore.noVotes"}}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tabs = document.querySelectorAll('[data-profile-tab]');
|
||||
var panels = document.querySelectorAll('[data-profile-panel]');
|
||||
function showPanel(name) {
|
||||
tabs.forEach(function(tab) {
|
||||
var active = tab.dataset.profileTab === name;
|
||||
tab.classList.toggle('is-active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach(function(panel) {
|
||||
panel.hidden = panel.dataset.profilePanel !== name;
|
||||
});
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
|
||||
}
|
||||
}
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
|
||||
});
|
||||
// 仅当投票 Tab 可点击(功能开启)时才响应 #votes hash,避免投票关闭时切到空面板
|
||||
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
|
||||
showPanel('votes');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{template "base/footer" .}}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
{{template "base/header" .}}
|
||||
<!--<link rel="stylesheet" href="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/uikit/3.3.3/css/uikit.min.css">-->
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目 -->
|
||||
<link rel="shortcut icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = {{.PageUser.Usernamecolor}};
|
||||
document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 按需加载(同步,保证下方内联二维码初始化就绪) -->
|
||||
<script src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">扫码向@{{ .PageUser.Name }}提问<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,842 +0,0 @@
|
||||
// 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 i18n
|
||||
|
||||
// enDict English copy.
|
||||
var enDict = map[string]string{
|
||||
// Navigation
|
||||
"nav.explore": "Explore",
|
||||
"nav.mine": "Mine",
|
||||
"nav.register": "Sign up",
|
||||
"nav.login": "Log in",
|
||||
"nav.ask": "Ask",
|
||||
"nav.settings": "Settings",
|
||||
"nav.lang": "Language",
|
||||
|
||||
// Common
|
||||
"common.login": "Log in",
|
||||
"common.forgot": "Forgot password",
|
||||
"common.submit": "Submit",
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
"common.close": "Close",
|
||||
"common.delete": "Delete",
|
||||
"common.moveUp": "Move up",
|
||||
"common.moveDown": "Move down",
|
||||
"common.share": "Share",
|
||||
"common.verifying": "Verifying...",
|
||||
"common.submitting": "Submitting...",
|
||||
"common.uploading": "Uploading...",
|
||||
"common.lang": "Language",
|
||||
"common.menu": "Menu",
|
||||
|
||||
"common.pleaseWait": "Please wait...",
|
||||
"common.backHome": "Back to Home",
|
||||
"common.theme": "Toggle dark/light mode",
|
||||
|
||||
// Site
|
||||
"site.title": "Wolf's Question Box | Anonymous Question Box",
|
||||
"site.description": "Wolf's Question Box is an anonymous question box — ask me anything without signing up, a safe space to share and respond.",
|
||||
"site.keywords": "Anonymous Question Box,question box,anonymous ask,ask anonymously,Wolf's Question Box,anonymous Q&A",
|
||||
"site.footer.visitor": "You are visitor #%s",
|
||||
"site.footer.pageview": "This page has been viewed %s times",
|
||||
"site.footer.runtime": "The box has been alive for",
|
||||
"site.footer.admin": "Contact admin",
|
||||
"site.footer.notice": "Announcements",
|
||||
"site.footer.chat": "Chat room",
|
||||
"site.footer.drive": "Drive",
|
||||
"site.footer.status": "Status",
|
||||
"site.footer.nav": "Links",
|
||||
"site.maintenance": "The site is under maintenance. Please come back later ~",
|
||||
|
||||
// Auth
|
||||
"auth.login.title": "User Log in",
|
||||
"auth.login.email": "Email",
|
||||
"auth.login.password": "Password",
|
||||
"auth.login.submit": "Log in",
|
||||
"auth.login.forgot": "Forgot password",
|
||||
"auth.register.title": "User Sign up",
|
||||
"auth.register.name": "Nickname",
|
||||
"auth.register.email": "Email",
|
||||
"auth.register.password": "Password",
|
||||
"auth.register.submit": "Sign up",
|
||||
"auth.forgot.title": "Forgot password",
|
||||
"auth.forgot.email": "Email",
|
||||
"auth.forgot.submit": "Send reset email",
|
||||
"auth.forgot.submitBtn": "Recover password",
|
||||
"auth.forgot.sent": "An email has been sent to %s, please check your inbox.",
|
||||
"auth.recover.title": "Reset password",
|
||||
"auth.recover.greeting": "%s, you are resetting your password:",
|
||||
"auth.recover.password": "New password",
|
||||
"auth.recover.confirm": "Confirm password",
|
||||
"auth.recover.submit": "Reset password",
|
||||
|
||||
// Explore
|
||||
"explore.title": "Explore User Tags",
|
||||
"explore.notice": "Here you can find users who enabled <span class=\"uk-text-bold\">\"Show my tags on the homepage\"</span>. This feature is off by default. To get discovered here, go to <a href=\"/user/profile\" class=\"uk-text-bold\">your profile settings</a> and enable it.",
|
||||
"explore.shuffle": "Shuffle",
|
||||
"explore.empty": "No users to show yet. Enable it and be the first!",
|
||||
"explore.hotVotes": "Hot polls",
|
||||
"explore.questionsTab": "Questions",
|
||||
"explore.votesTab": "Polls",
|
||||
"explore.votesTitle": "Public polls",
|
||||
"explore.votesDesc": "Browse active public polls and join the topics that interest you.",
|
||||
"explore.randomVote": "Random pick",
|
||||
"explore.allVotes": "All polls",
|
||||
"explore.noVotes": "There are no public polls available right now.",
|
||||
|
||||
// Ask page
|
||||
"ask.scan": "Scan to ask @%s",
|
||||
"ask.anonymous": "Anyone can ask anonymously",
|
||||
"ask.registerOnly": "Only registered users can ask",
|
||||
"ask.nextOpenAt": "Next opening: %s",
|
||||
"ask.lastCloseAt": "Last closed: %s",
|
||||
"weekday.0": "Sun",
|
||||
"weekday.1": "Mon",
|
||||
"weekday.2": "Tue",
|
||||
"weekday.3": "Wed",
|
||||
"weekday.4": "Thu",
|
||||
"weekday.5": "Fri",
|
||||
"weekday.6": "Sat",
|
||||
"ask.pinned": "Pinned",
|
||||
"ask.share": "Share this box",
|
||||
"ask.placeholder": "Write your question here...",
|
||||
"ask.chatPlaceholder": "Write your chat message here...",
|
||||
"ask.addImage": "Add image",
|
||||
"ask.private": "Don't publish after reply",
|
||||
"ask.notify": "Notify me",
|
||||
"ask.emailPlaceholder": "Enter your email address",
|
||||
"ask.submit": "Send question",
|
||||
"ask.chat": "Start a chat",
|
||||
"ask.chatDisabled": "Chat is disabled on this box",
|
||||
"ask.chatLimit": "Message limit: %d",
|
||||
"ask.history": "Previously answered by @%s",
|
||||
"ask.loginRequired": "Log in required",
|
||||
"ask.loginRequiredChat": "You need to log in to start a chat",
|
||||
"ask.goLogin": "Go to login",
|
||||
"ask.needPassword": "This box requires a password",
|
||||
"ask.needLogin": "The owner only allows registered users to ask questions. Please log in first.",
|
||||
"ask.pleaseEmail": "Please enter your email to receive notifications",
|
||||
"ask.generating": "Generating share card…",
|
||||
"ask.genFailed": "Failed to generate share card. Please try again later",
|
||||
"ask.networkError": "Network error",
|
||||
"ask.shareFilename": "share.png",
|
||||
|
||||
// Question item
|
||||
"qitem.answer": "Answer",
|
||||
"qitem.delete": "Delete",
|
||||
"qitem.private": "Make private",
|
||||
"qitem.public": "Make public",
|
||||
"qitem.asked": "Asked",
|
||||
"qitem.pending": "Pending reply",
|
||||
"qitem.answeredBy": "Answer by @%s on %s",
|
||||
"qitem.danger": "Danger!",
|
||||
"qitem.deleteConfirm": "Are you sure you want to delete this question? This cannot be undone.",
|
||||
"qitem.deleteConfirmBtn": "Yes, delete",
|
||||
"qitem.backToBox": "Back to %s's question box",
|
||||
"qitem.answerTitle": "Answer the question",
|
||||
"qitem.answerPlaceholder": "Write your answer here...",
|
||||
"qitem.emailNotifyHint": "The asker left their email. Once you answer this question for the first time, they will receive an email notification.",
|
||||
"qitem.submitAnswer": "Answer",
|
||||
"qitem.updateAnswer": "Update answer",
|
||||
"qitem.askMore": "Ask something else?",
|
||||
"qitem.markRead": "Mark as read",
|
||||
"qitem.markUnread": "Mark as unread",
|
||||
"qitem.readLocked": "Answered, locked as read",
|
||||
|
||||
// Admin / profile page
|
||||
"admin.profile": "Profile",
|
||||
"admin.lang": "Language",
|
||||
"admin.langHint": "Choose the language visitors see when they open your question box (admin pages follow this too)",
|
||||
"admin.email": "Email",
|
||||
"admin.name": "Nickname",
|
||||
"admin.nameColor": "Nickname color",
|
||||
"admin.oldPassword": "Old password",
|
||||
"admin.newPassword": "New password",
|
||||
"admin.notify": "New question notifications",
|
||||
"admin.personalize": "Customization",
|
||||
"admin.intro": "Box introduction",
|
||||
"admin.introColor": "Introduction color",
|
||||
"admin.qrBgColor": "QR background color (light part)",
|
||||
"admin.qrPdColor": "QR finder pattern color (corner squares)",
|
||||
"admin.qrFgColor": "QR foreground color",
|
||||
"admin.qrDotSize": "QR dot size (←small large→)",
|
||||
"admin.qrBgAlpha": "QR background opacity (←opaque transparent→)",
|
||||
"admin.avatar": "Avatar",
|
||||
"admin.background": "Box background",
|
||||
"admin.qrBackground": "QR background image",
|
||||
"admin.social": "Social links",
|
||||
"admin.harassment": "Anti-harassment",
|
||||
"admin.askLimit": "Asking restriction",
|
||||
"admin.blockWords": "Blocked words (press Enter or comma to add, up to 10 words, 10 characters each)",
|
||||
"admin.chat": "Chat settings",
|
||||
"admin.chatMaxMessages": "Max messages per chat",
|
||||
"admin.chatDailyLimit": "Daily message limit per user",
|
||||
"admin.tags": "Tags",
|
||||
"admin.tagsHint": "Tags (up to 5, 5 characters each, press Enter or comma to add)",
|
||||
"admin.tagsPlaceholder": "e.g. anime, music, games",
|
||||
"admin.tagInputHint": "Type and press Enter or comma to add",
|
||||
"admin.tagInputDuplicate": "Already exists, ignored",
|
||||
"admin.pagePassword": "Page access password",
|
||||
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
|
||||
"admin.askPassword": "Ask password",
|
||||
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
|
||||
"admin.saveProfile": "Save changes",
|
||||
"admin.updated": "Profile updated successfully",
|
||||
"admin.logout": "Log out",
|
||||
"admin.export": "Export data",
|
||||
|
||||
// Admin settings (additional)
|
||||
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
|
||||
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
|
||||
"admin.notifyEmail": "Email",
|
||||
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
|
||||
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
|
||||
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
|
||||
"admin.backgroundPlaceholder": "Upload box background",
|
||||
"admin.qrBackgroundPlaceholder": "Upload QR background (a background that is too dark may make the QR code unreadable)",
|
||||
"admin.qrPreviewHint": "The preview updates instantly after choosing an image",
|
||||
"admin.preview": "Live preview",
|
||||
"admin.themePreset": "Preset themes",
|
||||
"admin.themeDefault": "Default",
|
||||
"admin.themeDark": "Dark",
|
||||
"admin.themeOcean": "Ocean",
|
||||
"admin.themeSunset": "Sunset",
|
||||
"admin.resetDefaults": "🔄 Reset to defaults",
|
||||
"admin.previewHint": "Adjustments are for preview only; click \"Save changes\" to apply",
|
||||
"admin.socialHint": "Check the box and fill in the profile URL (no https:// needed). Unchecked items won't be shown on your page.",
|
||||
"admin.enableAcfun": "Enable AcFun",
|
||||
"admin.enableBili": "Enable Bilibili",
|
||||
"admin.enableWeibo": "Enable Weibo",
|
||||
"admin.enableXiaohongshu": "Enable Xiaohongshu",
|
||||
"admin.enableBlog": "Enable Personal Blog",
|
||||
"admin.registerOnly": "Only allow registered users to ask me questions",
|
||||
"admin.updateHarassment": "Update anti-harassment settings",
|
||||
"admin.globalBlockWords": "Global blocked words",
|
||||
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
|
||||
"admin.updateBlockWords": "Save blocked words",
|
||||
"admin.allowChat": "Allow registered users to start a chat with me",
|
||||
"admin.dailyLimitHint": "0 means unlimited",
|
||||
"admin.updateChat": "Update chat settings",
|
||||
"admin.showInHome": "Show my tags on the home page (let other users find me by tags)",
|
||||
"admin.updateTags": "Update tags",
|
||||
"admin.updatePagePassword": "Update page password",
|
||||
"admin.pagePasswordPlaceholder": "Enter page access password",
|
||||
"admin.updateAskPassword": "Update ask password",
|
||||
"admin.askPasswordPlaceholder": "Enter ask password",
|
||||
"admin.account": "Account settings",
|
||||
"admin.exportHint": "You can export all your personal data from %s, including your basic information, received questions and answers.",
|
||||
"admin.exportJSON": "Export all data (JSON)",
|
||||
"admin.exportJSONHint": "Includes questions, answers, follow-ups and chats for full backup or migration.",
|
||||
"admin.deactivate": "Deactivate my account",
|
||||
"admin.deactivateHint": "You can deactivate your account at any time. After deactivation, your account can no longer log in, your question box page and questions will be inaccessible, and others won't be able to send you new questions.<b>This action cannot be undone. Please be careful!</b>",
|
||||
"admin.socialRequired": "Please fill in the link or uncheck the box",
|
||||
|
||||
// Question management
|
||||
"qlist.title": "My Questions",
|
||||
"qlist.answered": "Answered",
|
||||
"qlist.pending": "Pending",
|
||||
"qlist.private": "Private",
|
||||
"qlist.empty": "No questions yet. Share your box and get some!",
|
||||
"qlist.action": "Actions",
|
||||
"qlist.answerNow": "Answer",
|
||||
"qlist.sort": "Sort",
|
||||
"qlist.status": "Status",
|
||||
"qlist.type": "Type",
|
||||
"qlist.latest": "Latest",
|
||||
"qlist.oldest": "Oldest",
|
||||
"qlist.all": "All",
|
||||
"qlist.public": "Normal question",
|
||||
"qlist.privateQuestion": "Private question",
|
||||
"qlist.received": "Questions I received",
|
||||
"qlist.myChats": "Chats I started",
|
||||
"qlist.statsTotal": "Total questions",
|
||||
"qlist.statsAnswered": "Answered",
|
||||
"qlist.statsUnanswered": "Unanswered",
|
||||
"qlist.statsPrivate": "Private",
|
||||
"qlist.statsPinned": "Pinned",
|
||||
"qlist.statsChats": "Chats",
|
||||
"qlist.searchPlaceholder": "Search questions or answers...",
|
||||
"qlist.searchBtn": "Search",
|
||||
"qlist.searchClear": "Clear",
|
||||
"qlist.questionsTab": "Questions",
|
||||
"qlist.votesTab": "Votes",
|
||||
"qlist.chatsTab": "Chats",
|
||||
"qlist.receivedChats": "Chats I received",
|
||||
"qlist.chatEmpty": "No chats yet. Share your question box to start chatting!",
|
||||
"qlist.pinnedShort": "Pin",
|
||||
"qlist.privateShort": "Pvt",
|
||||
"qlist.chatStatsTotal": "Total chats",
|
||||
"qlist.chatStatsReceived": "Received",
|
||||
"qlist.chatStatsInitiated": "Initiated",
|
||||
"qlist.chatStatsClosed": "Closed",
|
||||
"qlist.unansweredShort": "Open",
|
||||
"qlist.readShort": "Read",
|
||||
"qlist.unreadShort": "Unread",
|
||||
"qitem.markReadShort": "Mark",
|
||||
"qlist.pinShort": "Pin",
|
||||
"qlist.unpinShort": "Unpin",
|
||||
"qlist.myVotes": "My votes",
|
||||
"qlist.loadMore": "Load more",
|
||||
"qlist.newVote": "New vote",
|
||||
"qlist.edit": "Edit",
|
||||
"qlist.untitledVote": "Untitled vote",
|
||||
"qlist.blocked": "Blocked",
|
||||
"qlist.unanswered": "Unanswered",
|
||||
|
||||
// Site logo suffix (random animal emoji + suffix)
|
||||
"site.logoSuffix": "'s Question Box",
|
||||
"site.stats": "Question Box Statistics",
|
||||
|
||||
// Captcha modal
|
||||
"captcha.title": "Verification",
|
||||
"captcha.hint": "Solve the math problem below to prove you are not a robot",
|
||||
"captcha.refresh": "New problem",
|
||||
"captcha.submit": "Verify & submit",
|
||||
"captcha.loading": "Loading...",
|
||||
"captcha.loadFailed": "Load failed",
|
||||
"captcha.loadRetry": "Load failed, please retry",
|
||||
"captcha.inputAnswer": "Please enter the answer",
|
||||
"captcha.wrongAnswer": "Wrong answer, please recalculate",
|
||||
"captcha.verifyFailed": "Verification failed, please retry",
|
||||
|
||||
// Register page extras
|
||||
"auth.register.domain.prefix": "Custom domain (your box URL will be: %s + ",
|
||||
"auth.register.domain.suffix": " + what you fill in below)",
|
||||
"auth.register.confirm": "Confirm password",
|
||||
"auth.register.language": "Language",
|
||||
"auth.register.languageDefault": "Use site default",
|
||||
"auth.register.success": "Registration successful, welcome to TamaBox!",
|
||||
|
||||
// Explore page extras
|
||||
"explore.recommend": "Random picks",
|
||||
"explore.search": "Search tags...",
|
||||
"explore.sortLatest": "Newest",
|
||||
"explore.sortOldest": "Oldest",
|
||||
"explore.noTags": "No tags",
|
||||
"explore.noRecommend": "No recommendations yet",
|
||||
"explore.hotTags": "Hot Tags",
|
||||
"explore.hotQuestions": "Hot Questions",
|
||||
"explore.noResult": "No users match your criteria.",
|
||||
"explore.lazy": "This user is too lazy to write anything~",
|
||||
"explore.prevPage": "Previous",
|
||||
"explore.nextPage": "Next",
|
||||
"explore.loading": "Loading...",
|
||||
"explore.loadFailed": "Load failed",
|
||||
|
||||
// Question box password page
|
||||
"password.title": "This question box is password protected",
|
||||
"password.desc": "This question box is password protected. Enter the password to view its contents.",
|
||||
"password.input": "Enter the access password",
|
||||
"password.enter": "Enter",
|
||||
"password.submit": "Confirm",
|
||||
|
||||
// Question not found
|
||||
"qitem.notFound": "Question not found or has been deleted",
|
||||
|
||||
// Chat page
|
||||
"chat.title": "Chat",
|
||||
"chat.input": "Type a message...",
|
||||
"chat.send": "Send",
|
||||
"chat.delete": "Delete conversation",
|
||||
"chat.block": "Block user",
|
||||
"chat.unblock": "Unblock user",
|
||||
"chat.empty": "No messages yet. Say something~",
|
||||
"chat.withAsker": "Chat with the asker",
|
||||
"chat.withUser": "Chat with %s",
|
||||
"chat.closed": "Conversation closed",
|
||||
"chat.closedNoSend": "The conversation is closed. You cannot send new messages.",
|
||||
"chat.remaining": "Messages left: %d (of %d total)",
|
||||
"chat.me": "Me",
|
||||
"chat.other": "Other",
|
||||
"chat.userBlocked": "This user has been blocked",
|
||||
"chat.addImage": "Add image (optional, max 5MB)",
|
||||
"chat.confirm": "Confirm",
|
||||
"chat.confirmBlock": "Block this user? They won't be able to send you messages anymore.",
|
||||
"chat.confirmUnblock": "Unblock this user? They will be able to send you messages again.",
|
||||
"chat.deleteConfirm": "Delete this conversation? The other party will no longer be able to see it, and this cannot be undone.",
|
||||
"maintenance.title": "NekoBox is under maintenance...",
|
||||
"maintenance.desc": "NekoBox is currently under maintenance and temporarily unavailable. Please come back later~",
|
||||
|
||||
// Single-question share (v4)
|
||||
"qitem.shareCard": "Share this question",
|
||||
|
||||
// Follow-ups (v4)
|
||||
"followup.title": "Follow-ups",
|
||||
"followup.content": "Follow-up: ",
|
||||
"followup.answerPlaceholder": "Answer this follow-up...",
|
||||
"followup.answerBtn": "Answer",
|
||||
"followup.waiting": "Waiting for answer...",
|
||||
"followup.askMore": "Add a follow-up",
|
||||
"followup.remaining": "%d / %d follow-ups remaining",
|
||||
"followup.contentPlaceholder": "Add more details...",
|
||||
"followup.waitingReply": "Your follow-up has been sent. Waiting for the box owner to reply...",
|
||||
"followup.submit": "Submit",
|
||||
|
||||
// Pinning (v4)
|
||||
"qlist.pinned": "Pinned",
|
||||
"qlist.pin": "Pin",
|
||||
"qlist.unpin": "Unpin",
|
||||
"qlist.readStatus": "Read status",
|
||||
"qlist.read": "Read",
|
||||
"qlist.unread": "Unread",
|
||||
"qlist.statsRead": "Read",
|
||||
"qlist.statsUnread": "Unread",
|
||||
|
||||
// Question submitted page (v4)
|
||||
"success.title": "Question sent successfully!",
|
||||
"success.yourQuestion": "Your question:",
|
||||
"success.emailSent": "A private link to this question was sent to your email. You can also use the link below to view the question, receive replies, and follow up.",
|
||||
"success.saveLink": "<strong>Please save the private link below!</strong> Since you didn't leave an email, this link (including the token) is your <strong>only credential</strong> to view the question, receive replies, and follow up. If you lose it, it cannot be recovered.",
|
||||
"success.privateLink": "Your private link (click to copy)",
|
||||
"success.copy": "Copy",
|
||||
"success.copied": "Copied ✓",
|
||||
"success.copyFailed": "Copy failed",
|
||||
"success.token": "Token: %s (the asker uses this token to access the private link)",
|
||||
"success.viewQuestion": "View my question",
|
||||
"success.backToBox": "Back to %s's box",
|
||||
"success.pageTitle": "Question Sent - %s",
|
||||
|
||||
// Deactivate page
|
||||
"deactivate.title": "Deactivate account",
|
||||
"deactivate.desc": "After deactivation your question box will be inaccessible and all data will be deleted. This cannot be undone!",
|
||||
"deactivate.confirm": "I confirm I want to deactivate my account",
|
||||
"deactivate.submit": "Deactivate account",
|
||||
|
||||
// Anonymous voting
|
||||
"nav.votes": "Polls",
|
||||
|
||||
"common.back": "Back",
|
||||
|
||||
"vote.listTitle": "Poll Management",
|
||||
"vote.newTitle": "New Poll",
|
||||
"vote.editTitle": "Edit Poll",
|
||||
"vote.resultsTitle": "Poll Results",
|
||||
"vote.statsTotal": "Total Polls",
|
||||
"vote.statsPublished": "Active",
|
||||
"vote.createNew": "New Poll",
|
||||
"vote.typeSingle": "Single choice",
|
||||
"vote.typeMultiple": "Multiple choice",
|
||||
"vote.typeRating": "Rating",
|
||||
"vote.typeRank": "Ranking",
|
||||
"vote.typeForm": "Form",
|
||||
"vote.statusNotStarted": "Not started",
|
||||
"vote.statusDraft": "Draft",
|
||||
"vote.statusPublished": "Active",
|
||||
"vote.statusEnded": "Ended",
|
||||
"vote.statusArchived": "Archived",
|
||||
"vote.participants": "Participants",
|
||||
"vote.remainLabel": "Remaining",
|
||||
"vote.perpetual": "No deadline",
|
||||
"vote.durationDay": "d",
|
||||
"vote.durationHour": "h",
|
||||
"vote.durationMinute": "m",
|
||||
"vote.createdAt": "Created",
|
||||
"vote.edit": "Edit",
|
||||
"vote.results": "Results",
|
||||
"vote.preview": "Preview",
|
||||
"vote.page.mustComplete": "Please complete",
|
||||
"vote.page.rankHintRequired": "Rank 1-N (required)",
|
||||
"vote.page.rankHintOptional": "Rank 1-N (optional, partial OK)",
|
||||
"vote.page.previewHint": "Preview mode: this is a sample of the live page; submitting is disabled",
|
||||
"vote.page.previewSubmitHint": "Preview mode; submitting is enabled after publish",
|
||||
"vote.publish": "Publish",
|
||||
"vote.close": "End",
|
||||
"vote.share": "Copy link",
|
||||
"vote.copied": "Copied",
|
||||
"vote.shareURL": "Poll share link",
|
||||
"vote.confirmPublish": "Publish this poll? A shareable link will be generated.",
|
||||
"vote.confirmClose": "End this poll? Participants will no longer be able to vote.",
|
||||
"vote.confirmDelete": "Delete this poll? All vote data will be removed and cannot be recovered!",
|
||||
"vote.manageEntry": "Manage polls",
|
||||
|
||||
"vote.manageTitle": "Manage polls",
|
||||
"vote.manageDesc": "Create and manage your anonymous polls", "vote.empty": "No polls yet. Click \"New Poll\" in the top right to get started.",
|
||||
"vote.formLockedHint": "This poll is live. Only basic info can be edited; type and options are locked to protect collected votes.",
|
||||
"vote.formBasic": "Basic Settings",
|
||||
"vote.formTitle": "Poll Title",
|
||||
"vote.formTitlePlaceholder": "e.g. What should we eat today?",
|
||||
"vote.formDescription": "Description",
|
||||
"vote.formDescriptionPlaceholder": "Optional details",
|
||||
"vote.formType": "Poll Type",
|
||||
"vote.formVisibility": "Visibility",
|
||||
"vote.formVisibilityPublic": "Public (Explore + question box)",
|
||||
"vote.formVisibilityLinkOnly": "Link only",
|
||||
"vote.formVoterScope": "Voting Scope",
|
||||
"vote.formVoterScopeAnyone": "Anyone (including anonymous)",
|
||||
"vote.formVoterScopeRegistered": "Registered users only",
|
||||
"vote.formShowResult": "Result Visibility",
|
||||
"vote.formShowResultAfterVote": "Visible right after voting",
|
||||
"vote.formShowResultAfterEnd": "Visible after the poll ends",
|
||||
"vote.formShowResultOwnerOnly": "Owner only",
|
||||
"vote.formStartAt": "Start time (empty = now)",
|
||||
"vote.formEndAt": "End time (empty = no limit)",
|
||||
"vote.formMaxChoices": "Max choices",
|
||||
"vote.formMaxVotesPerFingerprint": "One vote per device per poll; repeated submissions from the same browser are blocked.",
|
||||
"vote.formMaxVotesPerAccount": "One vote per registered account per poll; repeated submissions from the same account are blocked.",
|
||||
"vote.formAllowCustom": "Allow participants to add custom options",
|
||||
"vote.formRequireCaptcha": "Require captcha for anonymous voting",
|
||||
"vote.formOptions": "Poll Options",
|
||||
"vote.formRatingHint": "Rating polls need no options (1-10).",
|
||||
"vote.addOption": "Add option",
|
||||
"vote.optionPlaceholder": "Option text",
|
||||
"vote.formFields": "Form Fields",
|
||||
"vote.addField": "Add field",
|
||||
"vote.field": "Field",
|
||||
"vote.formFieldType": "Field Type",
|
||||
"vote.fieldTypeText": "Single-line text",
|
||||
"vote.fieldTypeTextarea": "Multi-line text",
|
||||
"vote.fieldTypeSelect": "Dropdown select",
|
||||
"vote.fieldTypeRadio": "Single choice",
|
||||
"vote.fieldTypeCheckbox": "Multiple choice",
|
||||
"vote.fieldTypeNumber": "Number",
|
||||
"vote.fieldTypeRank": "Ranking",
|
||||
"vote.page.selectPlaceholder": "Select...",
|
||||
"vote.page.avgNumber": "Average",
|
||||
"vote.page.fieldFilled": "Filled",
|
||||
"vote.fieldTypeRating": "Rating",
|
||||
"vote.formFieldLabel": "Field Label",
|
||||
"vote.formFieldRequired": "Required",
|
||||
"vote.formFieldMaxLength": "Max length (0 = unlimited)",
|
||||
"vote.formFieldOptions": "Options",
|
||||
"vote.formFieldOptionsHint": "Click \"Add option\" to add each option; no comma needed",
|
||||
"vote.fieldAllowCustom": "Allow participants to add a custom option",
|
||||
"vote.formFieldOptionsPlaceholder": "e.g. Great,Okay,Poor",
|
||||
"vote.createDraft": "Save draft",
|
||||
"vote.builderHintOptions": "At least 2 options are required",
|
||||
"vote.builderHintFields": "Add at least 1 field",
|
||||
"vote.builderHintFieldOptions": "Choice/rank fields need options (rank needs at least 2)",
|
||||
"vote.resultsAnswered": "Answered",
|
||||
"vote.page.rankedTimes": "Ranked",
|
||||
"vote.resultsTotal": "Total participants",
|
||||
"vote.resultsSubmissions": "Form responses",
|
||||
"vote.resultsViewTexts": "View text responses",
|
||||
"vote.resultsToggleHint": "Click to expand or collapse text responses",
|
||||
"vote.resultsOptions": "Poll results",
|
||||
"vote.resultsVotes": "votes",
|
||||
"vote.export": "Export CSV",
|
||||
"vote.resultsEmpty": "No participation yet. Share the link!",
|
||||
|
||||
// Poll participation page
|
||||
"vote.inlineTitle": "Polls",
|
||||
"vote.page.by": "poll",
|
||||
"vote.page.goVote": "Vote",
|
||||
"vote.page.notStarted": "Not started",
|
||||
"vote.page.notStartedHint": "This poll has not started yet.",
|
||||
"vote.page.ended": "Ended",
|
||||
"vote.page.endedHint": "This poll has ended.",
|
||||
"vote.page.active": "Active",
|
||||
"vote.page.participated": "Participated",
|
||||
"vote.page.loginRequired": "This poll is limited to registered users. Please log in first.",
|
||||
"vote.page.anonymousHint": "Anonymous participation is allowed and your identity is not recorded (max 1 per device).",
|
||||
"vote.page.submit": "Submit vote",
|
||||
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
|
||||
"vote.page.resultsTitle": "Poll Results",
|
||||
"vote.page.totalVotes": "Total",
|
||||
"vote.page.avgRating": "Average",
|
||||
"vote.page.avgRank": "Avg rank",
|
||||
"vote.page.optionColumn": "Option",
|
||||
"vote.page.ratingLabel": "Rate",
|
||||
"vote.page.ratingUnit": "pts",
|
||||
"vote.page.rankHint": "Rank each option 1-N (smaller = higher, no duplicates)",
|
||||
"vote.page.rankPlaceholder": "Rank",
|
||||
"vote.page.multipleHint": "Multiple choice",
|
||||
"vote.page.customOption": "Custom option (optional)",
|
||||
"vote.page.customPlaceholder": "Type your own option",
|
||||
"vote.page.customOther": "Other",
|
||||
"vote.page.customFieldPlaceholder": "Type a custom option...",
|
||||
"vote.page.customStats": "Custom options",
|
||||
"vote.page.formCount": "Responses received",
|
||||
"vote.page.ownerManage": "Manage this poll (results visible only to you)",
|
||||
|
||||
// Per-poll quota collection
|
||||
"vote.formQuotaLimit": "Collection quota (responses)",
|
||||
"vote.formQuotaHint": "0 means unlimited; the poll ends automatically once the quota is reached.",
|
||||
"vote.flash.notFound": "This poll does not exist or is no longer available",
|
||||
"vote.flash.notPublished": "This poll has not been published yet",
|
||||
"vote.flash.archived": "This poll has been archived and is closed",
|
||||
"vote.flash.ended": "This poll has ended",
|
||||
"vote.flash.notStarted": "This poll has not started yet",
|
||||
"vote.flash.success": "Vote submitted. Thanks for participating!",
|
||||
"vote.err.noChoice": "Please select at least one option",
|
||||
"vote.err.tooManyChoices": "You can select at most %d options",
|
||||
"vote.err.pickOne": "Please select an option",
|
||||
"vote.err.invalidOption": "Invalid option",
|
||||
"vote.err.optionDuplicate": "Options cannot be duplicated",
|
||||
"vote.err.ratingRequired": "Please provide a rating from 1 to 10",
|
||||
"vote.err.rankRequired": "Please rank every option from 1 to %d",
|
||||
"vote.err.rankDuplicate": "Rankings must not repeat",
|
||||
"vote.err.fieldRequired": "Please fill in \"%s\"",
|
||||
"vote.err.fieldCustomRequired": "Please fill in the custom option for \"%s\"",
|
||||
"vote.err.fieldCustomTooLong": "The custom option for \"%s\" must be at most %d characters",
|
||||
"vote.err.fieldInvalidOption": "Invalid option for \"%s\"",
|
||||
"vote.err.fieldConfigError": "Misconfigured options for \"%s\"",
|
||||
"vote.err.fieldRankIncomplete": "Please provide a complete ranking for \"%s\"",
|
||||
"vote.err.fieldRankRange": "The ranking for \"%s\" must be a number between 1 and %d",
|
||||
"vote.err.fieldRankDuplicate": "Rankings for \"%s\" must not repeat",
|
||||
"vote.err.fieldTooLong": "\"%s\" exceeds the length limit (max %d characters)",
|
||||
"vote.err.fieldNotNumber": "\"%s\" must be a number",
|
||||
"vote.err.fieldRating": "\"%s\" requires a rating from 1 to 10",
|
||||
"vote.err.unknownType": "Unknown poll type",
|
||||
"vote.err.customTooLong": "The custom option must be at most %d characters",
|
||||
"vote.err.blockWords": "Your submission contains a blocked word set by the poll creator and could not be submitted",
|
||||
"vote.err.serialize": "Failed to serialize the submission",
|
||||
"captcha.error": "Captcha verification failed",
|
||||
"captcha.errEmpty": "Captcha cannot be empty",
|
||||
"captcha.errFormat": "Invalid captcha format, please refresh and try again",
|
||||
"captcha.errAnswerFormat": "Invalid captcha answer format",
|
||||
"captcha.errExpired": "Captcha expired, please refresh and try again",
|
||||
"captcha.errService": "Captcha service error, please try again later",
|
||||
"captcha.errAnswerWrong": "Incorrect captcha answer",
|
||||
"censor.unavailable": "The content moderation service is temporarily unavailable. Please try again later.",
|
||||
|
||||
// Feature management: switch / schedule / quota for questions, chats and polls
|
||||
"admin.tabAccount": "Settings",
|
||||
"admin.featuresHint": "Manage the switch, opening hours and collection quota of each feature on the Questions / Chats / Polls panels. A feature closes automatically once its quota is reached.",
|
||||
"admin.featuresEntry": "Settings",
|
||||
"admin.featureHint.question": "Visitors cannot submit new questions while closed; collection stops automatically once the quota is reached (private questions included).",
|
||||
"admin.featureHint.chat": "Visitors cannot start new chats while closed; existing conversations are unaffected.",
|
||||
"admin.featureHint.vote": "Your polls are hidden from your box page and cannot be answered while closed. Each poll can also define its own quota on the edit page.",
|
||||
"admin.featureEnabled": "Enable this feature",
|
||||
"admin.featureOpenAt": "Open at",
|
||||
"admin.featureCloseAt": "Close at",
|
||||
"admin.featureTimeHint": "Leave blank for no limit; the feature is unavailable before the open time and after the close time.",
|
||||
"admin.featureSchedule": "Recurring schedule (weekly)",
|
||||
"admin.featureScheduleStart": "Daily start",
|
||||
"admin.featureScheduleEnd": "Daily end",
|
||||
"admin.featureScheduleHint": "Select the weekdays and a daily time window to open every week. Leave all empty to disable. Days without a time window open all day; a time window with no weekday selected opens every day.",
|
||||
"admin.featureMode": "Schedule mode",
|
||||
"admin.featureModeAlways": "Always open",
|
||||
"admin.featureModeWindow": "One-time window",
|
||||
"admin.featureModeRecurring": "Weekly schedule",
|
||||
"admin.featureModeHint": "Always open: no time limit until manually disabled. One-time window: a single open/close time window. Weekly schedule: recurring weekly open days and hours. Modes are mutually exclusive; settings of unselected modes are cleared on save.",
|
||||
"admin.featureQuotaLimit": "Collection quota (items)",
|
||||
"admin.featureQuotaHint": "0 means unlimited; the feature closes automatically once the quota is reached. Reset the counter to start a new round.",
|
||||
"admin.featureQuotaCount": "Collected",
|
||||
"admin.featureQuotaUnlimited": "Unlimited",
|
||||
"admin.featureQuotaResetAt": "Last reset: ",
|
||||
"admin.featureResetQuota": "Reset counter",
|
||||
"admin.featureSave": "Save this feature",
|
||||
|
||||
// Feature management: states and messages
|
||||
"feature.name.question": "Questions",
|
||||
"feature.name.chat": "Chats",
|
||||
"feature.name.vote": "Polls",
|
||||
"feature.statusOpen": "Open",
|
||||
"feature.status.disabled": "Closed",
|
||||
"feature.status.not_started": "Not started",
|
||||
"feature.status.ended": "Ended",
|
||||
"feature.status.quota_reached": "Quota reached",
|
||||
"feature.saved": "Feature settings saved",
|
||||
"feature.errTime": "Invalid date for %s, please use the date-time picker",
|
||||
"feature.errTimeRange": "The close time of %s must be later than the open time",
|
||||
"feature.errQuota": "The collection quota of %s must be an integer between 0 and 1000000",
|
||||
"feature.errInvalid": "Unknown feature",
|
||||
"feature.quotaReset": "The collection counter of %s has been reset",
|
||||
"feature.errScheduleHalf": "The recurring start and end times of %s must be filled in together",
|
||||
"feature.errScheduleTime": "Invalid recurring time for %s, please use the time picker",
|
||||
"feature.errScheduleRange": "The recurring end time of %s must be later than the start time",
|
||||
|
||||
// Messages shown to visitors when a feature is closed
|
||||
"feature.disabled.question": "The box owner has closed questions",
|
||||
"feature.disabled.chat": "This box does not accept chats",
|
||||
"feature.disabled.vote": "This box has closed polls",
|
||||
"feature.not_started.question": "Questions are not open yet",
|
||||
"feature.not_started.chat": "Chats are not open yet",
|
||||
"feature.not_started.vote": "Polls are not open yet",
|
||||
"feature.ended.question": "Question collection has ended",
|
||||
"feature.ended.chat": "Chats have ended",
|
||||
"feature.ended.vote": "Polls have ended",
|
||||
"feature.quota_reached.question": "The question quota has been reached",
|
||||
"feature.quota_reached.chat": "The chat quota has been reached",
|
||||
"feature.quota_reached.vote": "The poll quota has been reached",
|
||||
|
||||
// Auth: visitor-visible messages (P0)
|
||||
"auth.errBadCredential": "Incorrect email or password",
|
||||
"auth.errUserNotFound": "User does not exist",
|
||||
"auth.errEmailFrequent": "Emails are sent too frequently, please try again later",
|
||||
"auth.errEmailFailed": "Failed to send email, please try again later",
|
||||
"auth.errCodeExpired": "The verification code has expired",
|
||||
"auth.errPasswordChanged": "Password changed successfully",
|
||||
"auth.errLoginRequired": "Please log in first",
|
||||
"auth.errDuplicateEmail": "This email is already registered",
|
||||
"auth.errDuplicateDomain": "This custom domain is already taken",
|
||||
|
||||
// Question box page: visitor-visible (P0)
|
||||
"question.errWrongPassword": "Wrong password",
|
||||
"question.errAskPasswordWrong": "Wrong ask password",
|
||||
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
|
||||
"question.errSelfChat": "You cannot start a chat with yourself",
|
||||
"question.errBlockedChat": "You have been blocked by the other party and cannot start a chat",
|
||||
"question.errChatDailyLimit": "You have reached today's message limit (%d). Please try again tomorrow",
|
||||
"question.errDuplicateSubmit": "Duplicate submission detected, please try again later",
|
||||
"question.errImageTooLarge": "Image file size cannot exceed 5Mb",
|
||||
"question.errUploadImageFailed": "Failed to upload image",
|
||||
"question.errBlockWords": "The question contains a blocked word set by the box owner, and could not be sent",
|
||||
"question.errAnsweredLocked": "An answered question cannot be marked as unread",
|
||||
"question.flash.chatSent": "Chat request sent! You can view the conversation via this link: %s",
|
||||
"question.flash.answered": "Answer published successfully!",
|
||||
"question.flash.operated": "Operation successful",
|
||||
"question.flash.markedRead": "Marked as read",
|
||||
"question.flash.markedUnread": "Marked as unread",
|
||||
|
||||
// Follow-ups: visitor/owner visible (P0)
|
||||
"followup.errNoPermission": "Permission denied",
|
||||
"followup.errNotAnswered": "This question has not been answered yet, so you cannot follow up",
|
||||
"followup.errLimit": "Each question allows at most %d follow-ups, and the limit has been reached",
|
||||
"followup.errWaitReply": "Please wait for the owner to reply to your previous follow-up before continuing",
|
||||
"followup.errNotFound": "The follow-up does not exist",
|
||||
"followup.flash.sent": "Follow-up sent successfully!",
|
||||
"followup.flash.answered": "Answer posted successfully!",
|
||||
|
||||
// Admin settings: owner visible (P1)
|
||||
"admin.flash.profileUpdated": "Profile updated successfully",
|
||||
"admin.flash.chatUpdated": "Chat settings updated successfully",
|
||||
"admin.flash.pagePwdOff": "Page password access disabled",
|
||||
"admin.flash.pagePwdUpdated": "Page access password updated",
|
||||
"admin.flash.askPwdOff": "Ask password disabled",
|
||||
"admin.flash.askPwdUpdated": "Ask password updated",
|
||||
"admin.flash.tagsTooMany": "You can have at most 5 tags",
|
||||
"admin.flash.tagsUpdated": "Tags updated successfully",
|
||||
"admin.flash.tagTooLong": "Each tag cannot exceed 5 characters: %s",
|
||||
"admin.flash.blockWordsTooMany": "You can have at most 10 blocked words",
|
||||
"admin.flash.blockWordTooLong": "A blocked word cannot exceed 10 characters: %s",
|
||||
"admin.flash.harassmentUpdated": "Anti-harassment settings updated successfully",
|
||||
"admin.flash.blockWordsUpdated": "Blocked words updated",
|
||||
"admin.flash.deactivated": "Your account has been deactivated. Thank you for using Wolf's Question Box. We hope to see you again 👋🏻",
|
||||
"admin.export.errUser": "Export failed: could not get user information",
|
||||
"admin.export.errQuestion": "Export failed: could not get question information",
|
||||
|
||||
// Vote management: owner visible (P1)
|
||||
"vote.err.invalidID": "Invalid vote ID",
|
||||
"vote.err.notExist": "The vote does not exist",
|
||||
"vote.err.noPermission": "You are not allowed to operate this vote",
|
||||
"vote.err.type": "Unknown poll type",
|
||||
"vote.err.titleRequired": "Please fill in the poll title",
|
||||
"vote.err.titleTooLong": "The poll title cannot exceed 100 characters",
|
||||
"vote.err.visibility": "Unknown visibility setting",
|
||||
"vote.err.voterScope": "Unknown voting scope setting",
|
||||
"vote.err.showResult": "Unknown result visibility setting",
|
||||
"vote.err.timeRange": "The end time must be later than the start time",
|
||||
"vote.err.quota": "The collection quota must be an integer between 0 and 1000000",
|
||||
"vote.err.optionsTooFew": "Please add at least 2 options",
|
||||
"vote.err.optionsFormat": "Invalid options data format",
|
||||
"vote.err.optionTooLong": "A single option cannot exceed %d characters",
|
||||
"vote.err.maxChoicesExceed": "The max choices cannot exceed the total number of options",
|
||||
"vote.err.fieldsTooFew": "Please add at least 1 form field",
|
||||
"vote.err.fieldsFormat": "Invalid form fields data format",
|
||||
"vote.err.fieldsTooMany": "You can have at most 50 form fields",
|
||||
"vote.err.fieldType": "Unknown form field type",
|
||||
"vote.err.fieldLabelMissing": "Field #%d is missing a label",
|
||||
"vote.err.fieldLabelTooLong": "Field #%d label cannot exceed 100 characters",
|
||||
"vote.err.fieldLabelDuplicate": "The field label \"%s\" is duplicated, please use a different label",
|
||||
"vote.err.fieldMaxLength": "Field #%d max length must be between 0 and 2000",
|
||||
"vote.err.fieldReservedOption": "Field #%d (%s) contains a reserved option value, please change it",
|
||||
"vote.err.fieldOptionsTooFew": "Field #%d (%s) needs at least 1 option",
|
||||
"vote.err.fieldRankTooFew": "Field #%d (%s) ranking questions need at least 2 options",
|
||||
"vote.err.fieldSerialize": "Failed to serialize field options",
|
||||
"vote.err.editClosed": "Votes that are ended or archived cannot be edited",
|
||||
"vote.err.publishDraftOnly": "Only draft polls can be published",
|
||||
"vote.err.closePublishedOnly": "Only active polls can be closed",
|
||||
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
|
||||
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
|
||||
"vote.err.publishOptionsRequired": "Choice polls need at least 2 options",
|
||||
"vote.err.optionUpdateFailed": "Failed to update options, please retry",
|
||||
"vote.err.fieldUpdateFailed": "Failed to update form fields, please retry",
|
||||
"vote.flash.published": "Poll published! Share link: %s",
|
||||
"vote.flash.updated": "Poll info updated (active polls cannot change type or options)",
|
||||
"vote.flash.draftSaved": "Saved to draft",
|
||||
"vote.flash.closed": "Poll ended",
|
||||
"vote.flash.deleted": "Poll deleted",
|
||||
"vote.err.timeFormat": "Invalid time format, please use the datetime picker",
|
||||
"vote.err.maxChoicesLimit": "Maximum number of choices cannot exceed 20",
|
||||
"vote.err.descriptionTooLong": "Vote description cannot exceed 5000 characters",
|
||||
"profile.err.avatarTooLarge": "Avatar file is too large, maximum 2MB",
|
||||
"profile.err.backgroundTooLarge": "Background image is too large, maximum 2MB",
|
||||
"profile.err.newPasswordTooShort": "New password must be at least 8 characters",
|
||||
"profile.err.badOldPassword": "Incorrect old password",
|
||||
"admin.export.errCreateExcel": "Export failed: could not create Excel file",
|
||||
"admin.export.errWriteExcel": "Export failed: could not write Excel file",
|
||||
|
||||
// Profile page UI rework: card sections / avatar & background cropping / force re-login after password change
|
||||
"profile.card.basic": "Basic info",
|
||||
"profile.card.avatarBg": "Avatar & background",
|
||||
"profile.card.qr": "QR code style",
|
||||
"profile.card.password": "Change password",
|
||||
"profile.avatarCropHint": "Crop frame is locked to 1:1 (square)",
|
||||
"profile.backgroundCropHint": "Crop frame is locked to 16:9",
|
||||
"profile.cropTitle": "Crop image",
|
||||
"profile.cropConfirm": "Apply crop",
|
||||
"profile.err.avatarRatio": "Avatar image must be a 1:1 square. Please crop it and upload again.",
|
||||
"profile.err.backgroundRatio": "Background image must have a 16:9 aspect ratio. Please crop it and upload again.",
|
||||
"profile.flash.passwordChanged": "Password changed. You have been signed out; please sign in again with your new password.",
|
||||
"profile.pwdNote": "After saving, your current session will end immediately and you will need to sign in again with the new password.",
|
||||
"profile.pickAvatar": "Change avatar",
|
||||
"profile.pickBackground": "Change background",
|
||||
"profile.saveBasic": "Save basic info",
|
||||
"profile.saveMedia": "Save avatar & background",
|
||||
"profile.savePassword": "Update password",
|
||||
"profile.saveQR": "Save QR code style",
|
||||
"profile.saveSocial": "Save social links",
|
||||
"profile.err.cropLibUnavailable": "The image cropper failed to load. Please refresh the page and try again.",
|
||||
|
||||
// Captcha JSON endpoint: consumed by frontend (P2)
|
||||
"captcha.json.badRequest": "Invalid request format",
|
||||
"captcha.json.idRequired": "Captcha ID cannot be empty",
|
||||
"captcha.json.genFailed": "Failed to generate captcha",
|
||||
"chat.errDeletedByOwner": "This private conversation has been deleted by the other party.",
|
||||
"chat.errBlockedView": "You have been blocked by the other party and cannot view this chat",
|
||||
"chat.errEmpty": "Message cannot be empty",
|
||||
"chat.errTooLong": "Message is too long, please keep it within %d characters",
|
||||
"chat.errConvDeletedSend": "This conversation has been deleted and messages cannot be sent",
|
||||
"chat.errBlockedSend": "You have been blocked by the other party and cannot send messages",
|
||||
"chat.errBlockWords": "Your message contains a blocked word set by the box owner and could not be sent",
|
||||
"chat.errDailyLimit": "You have reached today's message limit (%d), please try again tomorrow",
|
||||
"chat.errLimitReached": "The conversation has reached its limit and no new messages can be sent",
|
||||
"chat.errChatClosed": "The box owner has disabled private chat and messages cannot be sent",
|
||||
"chat.errImageTooLarge": "Image size cannot exceed %dMB",
|
||||
"chat.errUploadFailed": "Image upload failed, please try again later",
|
||||
"chat.errNoPermission": "You are not authorized to perform this action",
|
||||
"chat.flash.sent": "Message sent successfully",
|
||||
"chat.flash.deleted": "Private conversation deleted",
|
||||
"chat.flash.operated": "Operation successful",
|
||||
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
|
||||
"internal.errFlash": "Internal server error, please try again later.",
|
||||
"internal.errServer": "Internal server error",
|
||||
"home.errStats": "Failed to retrieve statistics",
|
||||
"pixel.errPath": "Invalid request path",
|
||||
"proxy.errNotFound": "Page not found",
|
||||
"proxy.errInternal": "Service gateway internal error",
|
||||
"explore.errRandomUser": "Failed to retrieve random users",
|
||||
// 表单校验
|
||||
"form.answer.content": "Answer content is required",
|
||||
"form.answer.contentTooLong": "Answer content must be within %d characters",
|
||||
"form.email.required": "Please enter a valid email address",
|
||||
"form.followup.answer": "Follow-up answer is required",
|
||||
"form.followup.answerTooLong": "Follow-up answer must be within %d characters",
|
||||
"form.followup.content": "Follow-up content is required",
|
||||
"form.followup.contentTooLong": "Follow-up content must be within %d characters",
|
||||
"form.login.password": "Please enter your password",
|
||||
"form.profile.backgroundimagealpha": "Please enter the QR code background opacity",
|
||||
"form.profile.colorHex": "Please enter a 7-character hex color code (including #)",
|
||||
"form.profile.dotscale": "Please enter the QR code dot size",
|
||||
"form.profile.intro": "Bio is required and must be at most 100 characters",
|
||||
"form.profile.linkMax": "Profile link must be at most 30 characters",
|
||||
"form.profile.name": "Nickname is required and must be at most 20 characters",
|
||||
"form.profile.newPassword": "New password must be at most 30 characters",
|
||||
"form.profile.passwordMax": "Password must be at most 30 characters",
|
||||
"form.question.content": "Question content is required",
|
||||
"form.question.contentTooLong": "Question content must be within %d characters",
|
||||
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
|
||||
"form.recaptcha": "Please complete the CAPTCHA verification",
|
||||
"form.recover.newPassword": "Please set a new password of 8–30 characters",
|
||||
"form.recover.repeatPassword": "The two new passwords do not match",
|
||||
"form.register.domain": "The custom domain may only contain letters, digits and underscores, and must be 3–20 characters long",
|
||||
"form.register.email": "Please enter a valid email address (max 100 characters)",
|
||||
"form.register.name": "Nickname is required and must be at most 20 characters",
|
||||
"form.register.password": "Please set a password of 8–30 characters",
|
||||
"form.register.recaptcha": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
|
||||
"form.register.repeatPassword": "The two passwords do not match",
|
||||
}
|
||||
@@ -1,842 +0,0 @@
|
||||
// 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 i18n
|
||||
|
||||
// jaDict 日本語文案。
|
||||
var jaDict = map[string]string{
|
||||
// ナビゲーション
|
||||
"nav.explore": "探索",
|
||||
"nav.mine": "マイ",
|
||||
"nav.register": "登録",
|
||||
"nav.login": "ログイン",
|
||||
"nav.ask": "質問",
|
||||
"nav.settings": "設定",
|
||||
"nav.lang": "言語",
|
||||
|
||||
// 共通
|
||||
"common.login": "ログイン",
|
||||
"common.forgot": "パスワードを忘れた",
|
||||
"common.submit": "送信",
|
||||
"common.cancel": "キャンセル",
|
||||
"common.save": "保存",
|
||||
"common.close": "閉じる",
|
||||
"common.delete": "削除",
|
||||
"common.moveUp": "上へ移動",
|
||||
"common.moveDown": "下へ移動",
|
||||
"common.share": "共有",
|
||||
"common.verifying": "認証中...",
|
||||
"common.submitting": "送信中...",
|
||||
"common.uploading": "アップロード中...",
|
||||
"common.lang": "言語",
|
||||
"common.menu": "メニュー",
|
||||
|
||||
"common.pleaseWait": "お待ちください...",
|
||||
"common.backHome": "ホームに戻る",
|
||||
"common.theme": "ダーク/ライトモード切替",
|
||||
|
||||
// サイト
|
||||
"site.title": "狼の質問箱 | 匿名質問箱",
|
||||
"site.description": "狼の質問箱は登録不要で使える匿名質問箱。誰でも匿名で質問でき、安心してやり取りできる場所です。",
|
||||
"site.keywords": "匿名質問箱,質問箱,匿名質問,狼の質問箱,匿名で質問,匿名Q&A",
|
||||
"site.footer.visitor": "あなたは %s 人目の訪問者です",
|
||||
"site.footer.pageview": "このページは %s 回閲覧されました",
|
||||
"site.footer.runtime": "質問箱は稼働中",
|
||||
"site.footer.admin": "管理者へ",
|
||||
"site.footer.notice": "お知らせ",
|
||||
"site.footer.chat": "チャット",
|
||||
"site.footer.drive": "ドライブ",
|
||||
"site.footer.status": "ステータス",
|
||||
"site.footer.nav": "リンク",
|
||||
"site.maintenance": "メンテナンス中です。しばらくお待ちください~",
|
||||
|
||||
// 認証
|
||||
"auth.login.title": "ログイン",
|
||||
"auth.login.email": "メールアドレス",
|
||||
"auth.login.password": "パスワード",
|
||||
"auth.login.submit": "ログイン",
|
||||
"auth.login.forgot": "パスワードを忘れた",
|
||||
"auth.register.title": "ユーザー登録",
|
||||
"auth.register.name": "ニックネーム",
|
||||
"auth.register.email": "メールアドレス",
|
||||
"auth.register.password": "パスワード",
|
||||
"auth.register.submit": "登録",
|
||||
"auth.forgot.title": "パスワードを忘れた",
|
||||
"auth.forgot.email": "メールアドレス",
|
||||
"auth.forgot.submit": "再設定メールを送信",
|
||||
"auth.forgot.submitBtn": "パスワードを再取得",
|
||||
"auth.forgot.sent": "%s にメールを送信しました。ご確認ください。",
|
||||
"auth.recover.title": "パスワード再設定",
|
||||
"auth.recover.greeting": "%s さん、パスワードを再設定しています:",
|
||||
"auth.recover.password": "新しいパスワード",
|
||||
"auth.recover.confirm": "パスワード(確認)",
|
||||
"auth.recover.submit": "パスワードを再設定",
|
||||
|
||||
// 探索ページ
|
||||
"explore.title": "ユーザータグを探索",
|
||||
"explore.notice": "ここでは<span class=\"uk-text-bold\">「ホームでタグを表示」</span>を有効にしたユーザーを紹介しています。この機能は初期状態でオフです。自分の質問箱を見つけてもらいたい場合は <a href=\"/user/profile\" class=\"uk-text-bold\">個人設定</a> から有効にしてください。",
|
||||
"explore.shuffle": "シャッフル",
|
||||
"explore.empty": "表示できるユーザーがいません。有効にしてみましょう~",
|
||||
"explore.hotVotes": "人気の投票",
|
||||
"explore.questionsTab": "質問",
|
||||
"explore.votesTab": "投票",
|
||||
"explore.votesTitle": "公開投票",
|
||||
"explore.votesDesc": "開催中の公開投票を見て、興味のあるテーマに参加できます。",
|
||||
"explore.randomVote": "ランダムおすすめ",
|
||||
"explore.allVotes": "すべての投票",
|
||||
"explore.noVotes": "現在、参加できる公開投票はありません。",
|
||||
|
||||
// 質問ページ
|
||||
"ask.scan": "QRコードで@%sに質問",
|
||||
"ask.anonymous": "誰でも匿名で質問できます",
|
||||
"ask.registerOnly": "登録ユーザーのみ質問できます",
|
||||
"ask.nextOpenAt": "次回開放:%s",
|
||||
"ask.lastCloseAt": "前回終了:%s",
|
||||
"weekday.0": "日",
|
||||
"weekday.1": "月",
|
||||
"weekday.2": "火",
|
||||
"weekday.3": "水",
|
||||
"weekday.4": "木",
|
||||
"weekday.5": "金",
|
||||
"weekday.6": "土",
|
||||
"ask.pinned": "ピン留め済み",
|
||||
"ask.share": "質問箱を共有",
|
||||
"ask.placeholder": "ここに質問を書いてください...",
|
||||
"ask.chatPlaceholder": "ここにチャットメッセージを書いてください...",
|
||||
"ask.addImage": "画像を追加",
|
||||
"ask.private": "返信後に公開しない",
|
||||
"ask.notify": "通知を受け取る",
|
||||
"ask.emailPlaceholder": "メールアドレスを入力してください",
|
||||
"ask.submit": "質問を送信",
|
||||
"ask.chat": "チャットを開始",
|
||||
"ask.chatDisabled": "この質問箱ではチャットが無効です",
|
||||
"ask.chatLimit": "メッセージ上限:%d件",
|
||||
"ask.history": "@%s が以前回答した質問",
|
||||
"ask.loginRequired": "ログインが必要",
|
||||
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
|
||||
"ask.goLogin": "ログインへ",
|
||||
"ask.needPassword": "この質問箱にはパスワードが必要です",
|
||||
"ask.needLogin": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
|
||||
"ask.pleaseEmail": "通知を受け取るメールアドレスを入力してください",
|
||||
"ask.generating": "共有カードを生成中…",
|
||||
"ask.genFailed": "共有カードの生成に失敗しました。しばらくしてからお試しください",
|
||||
"ask.networkError": "ネットワークエラー",
|
||||
"ask.shareFilename": "share.png",
|
||||
|
||||
// 質問アイテム
|
||||
"qitem.answer": "回答",
|
||||
"qitem.delete": "削除",
|
||||
"qitem.private": "非公開にする",
|
||||
"qitem.public": "公開にする",
|
||||
"qitem.asked": "質問",
|
||||
"qitem.pending": "回答待ち",
|
||||
"qitem.answeredBy": "@%s による %s の回答",
|
||||
"qitem.danger": "危険!",
|
||||
"qitem.deleteConfirm": "この質問を削除してもよろしいですか?この操作は取り消せません。",
|
||||
"qitem.deleteConfirmBtn": "削除する",
|
||||
"qitem.backToBox": "%sの質問箱に戻る",
|
||||
"qitem.answerTitle": "質問に回答する",
|
||||
"qitem.answerPlaceholder": "ここに回答を入力...",
|
||||
"qitem.emailNotifyHint": "質問者はメールアドレスを残しています。最初に回答すると、質問者にメール通知が送信されます。",
|
||||
"qitem.submitAnswer": "回答",
|
||||
"qitem.updateAnswer": "回答を更新",
|
||||
"qitem.askMore": "ほかの質問は?",
|
||||
"qitem.markRead": "既読にする",
|
||||
"qitem.markUnread": "未読にする",
|
||||
"qitem.readLocked": "回答済みのため既読に固定",
|
||||
|
||||
// 個人設定ページ
|
||||
"admin.profile": "プロフィール",
|
||||
"admin.lang": "表示言語",
|
||||
"admin.langHint": "訪問者があなたの質問箱を開いたときに表示する言語を選択します(管理画面もこの設定に従います)",
|
||||
"admin.email": "メールアドレス",
|
||||
"admin.name": "ニックネーム",
|
||||
"admin.nameColor": "ニックネームの色",
|
||||
"admin.oldPassword": "現在のパスワード",
|
||||
"admin.newPassword": "新しいパスワード",
|
||||
"admin.notify": "新着質問の通知",
|
||||
"admin.personalize": "カスタマイズ",
|
||||
"admin.intro": "質問箱の紹介文",
|
||||
"admin.introColor": "紹介文の色",
|
||||
"admin.qrBgColor": "QRコードの背景色(明るい部分)",
|
||||
"admin.qrPdColor": "QRコードのファインダーパターン色(角の四角)",
|
||||
"admin.qrFgColor": "QRコードの前景色",
|
||||
"admin.qrDotSize": "QRコードのドットサイズ (←小 大→)",
|
||||
"admin.qrBgAlpha": "QRコードの背景透明度 (←不透明 透明→)",
|
||||
"admin.avatar": "アバター",
|
||||
"admin.background": "質問箱の背景",
|
||||
"admin.qrBackground": "QRコードの背景画像",
|
||||
"admin.social": "ソーシャルリンク",
|
||||
"admin.harassment": "迷惑行為対策",
|
||||
"admin.askLimit": "質問制限",
|
||||
"admin.blockWords": "ブロックワード設定(Enter またはカンマ区切り、最大10個、各10文字まで)",
|
||||
"admin.chat": "チャット設定",
|
||||
"admin.chatMaxMessages": "チャットのメッセージ上限",
|
||||
"admin.chatDailyLimit": "1ユーザーあたりの1日のメッセージ上限",
|
||||
"admin.tags": "タグ設定",
|
||||
"admin.tagsHint": "タグ(最大5個、各5文字まで、Enter またはカンマ区切り)",
|
||||
"admin.tagsPlaceholder": "例:アニメ,音楽,ゲーム",
|
||||
"admin.tagInputHint": "入力して Enter またはカンマで追加",
|
||||
"admin.tagInputDuplicate": "既に存在するため無視しました",
|
||||
"admin.pagePassword": "ページアクセスパスワード",
|
||||
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
|
||||
"admin.askPassword": "質問パスワード",
|
||||
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
|
||||
"admin.saveProfile": "変更を保存",
|
||||
"admin.updated": "プロフィールを更新しました",
|
||||
"admin.logout": "ログアウト",
|
||||
"admin.export": "データをエクスポート",
|
||||
|
||||
// 管理画面設定(追加)
|
||||
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
|
||||
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
|
||||
"admin.notifyEmail": "メール",
|
||||
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
|
||||
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
|
||||
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
|
||||
"admin.backgroundPlaceholder": "質問箱の背景をアップロード",
|
||||
"admin.qrBackgroundPlaceholder": "QRコードの背景画像をアップロード(暗すぎる背景はQRコードが読み取れなくなることがあります)",
|
||||
"admin.qrPreviewHint": "画像を選択するとプレビューが即時更新されます",
|
||||
"admin.preview": "ライブプレビュー",
|
||||
"admin.themePreset": "プリセットテーマ",
|
||||
"admin.themeDefault": "デフォルト",
|
||||
"admin.themeDark": "ダーク",
|
||||
"admin.themeOcean": "オーシャン",
|
||||
"admin.themeSunset": "サンセット",
|
||||
"admin.resetDefaults": "🔄 デフォルトに戻す",
|
||||
"admin.previewHint": "調整はプレビューのみです。「変更を保存」をクリックすると反映されます",
|
||||
"admin.socialHint": "チェックを入れてプロフィールURLを入力してください(https:// は不要)。チェックのない項目は表示されません。",
|
||||
"admin.enableAcfun": "AcFun を有効にする",
|
||||
"admin.enableBili": "Bilibili を有効にする",
|
||||
"admin.enableWeibo": "Weibo を有効にする",
|
||||
"admin.enableXiaohongshu": "小紅書 を有効にする",
|
||||
"admin.enableBlog": "個人ブログを有効にする",
|
||||
"admin.registerOnly": "登録ユーザーのみ質問を許可",
|
||||
"admin.updateHarassment": "迷惑行為対策を更新",
|
||||
"admin.globalBlockWords": "共通ブロックワード",
|
||||
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
|
||||
"admin.updateBlockWords": "ブロックワードを保存",
|
||||
"admin.allowChat": "登録ユーザーからのチャットを許可",
|
||||
"admin.dailyLimitHint": "0 は無制限",
|
||||
"admin.updateChat": "チャット設定を更新",
|
||||
"admin.showInHome": "ホームページでタグを表示(タグで検索されることを許可)",
|
||||
"admin.updateTags": "タグを更新",
|
||||
"admin.updatePagePassword": "ページパスワードを更新",
|
||||
"admin.pagePasswordPlaceholder": "ページアクセスパスワードを入力",
|
||||
"admin.updateAskPassword": "質問パスワードを更新",
|
||||
"admin.askPasswordPlaceholder": "質問パスワードを入力",
|
||||
"admin.account": "アカウント設定",
|
||||
"admin.exportHint": "%s のすべての個人データ(基本情報、受け取った質問と回答を含む)をエクスポートできます。",
|
||||
"admin.exportJSON": "全データをエクスポート(JSON)",
|
||||
"admin.exportJSONHint": "質問・回答・追加質問・チャットを含む完全バックアップ。",
|
||||
"admin.deactivate": "アカウントを停止",
|
||||
"admin.deactivateHint": "いつでもアカウントを停止できます。停止後はログインできなくなり、質問箱のページや質問にもアクセスできなくなります。新しい質問も受け付けられません。<b>この操作は取り消せません。ご注意ください!</b>",
|
||||
"admin.socialRequired": "リンクを入力するか、チェックを外してください",
|
||||
|
||||
// 質問管理ページ
|
||||
"qlist.title": "マイ質問",
|
||||
"qlist.answered": "回答済み",
|
||||
"qlist.pending": "回答待ち",
|
||||
"qlist.private": "非公開",
|
||||
"qlist.empty": "質問はまだありません。質問箱を共有しましょう~",
|
||||
"qlist.action": "操作",
|
||||
"qlist.answerNow": "回答する",
|
||||
"qlist.sort": "並び替え",
|
||||
"qlist.status": "状態",
|
||||
"qlist.type": "種類",
|
||||
"qlist.latest": "最新",
|
||||
"qlist.oldest": "最古",
|
||||
"qlist.all": "すべて",
|
||||
"qlist.public": "通常の質問",
|
||||
"qlist.privateQuestion": "非公開の質問",
|
||||
"qlist.received": "受け取った質問",
|
||||
"qlist.myChats": "開始したチャット",
|
||||
"qlist.statsTotal": "質問総数",
|
||||
"qlist.statsAnswered": "回答済み",
|
||||
"qlist.statsUnanswered": "未回答",
|
||||
"qlist.statsPrivate": "非公開質問",
|
||||
"qlist.statsPinned": "ピン留め",
|
||||
"qlist.statsChats": "チャット",
|
||||
"qlist.searchPlaceholder": "質問や回答を検索...",
|
||||
"qlist.searchBtn": "検索",
|
||||
"qlist.searchClear": "クリア",
|
||||
"qlist.questionsTab": "質問",
|
||||
"qlist.votesTab": "投票",
|
||||
"qlist.chatsTab": "チャット",
|
||||
"qlist.receivedChats": "受け取ったチャット",
|
||||
"qlist.chatEmpty": "まだチャットがありません。質問箱を共有してチャットを始めましょう!",
|
||||
"qlist.pinnedShort": "ピン",
|
||||
"qlist.privateShort": "非公開",
|
||||
"qlist.chatStatsTotal": "チャット総数",
|
||||
"qlist.chatStatsReceived": "受け取った",
|
||||
"qlist.chatStatsInitiated": "開始した",
|
||||
"qlist.chatStatsClosed": "終了",
|
||||
"qlist.unansweredShort": "未答",
|
||||
"qlist.readShort": "既読",
|
||||
"qlist.unreadShort": "未読",
|
||||
"qitem.markReadShort": "既読に",
|
||||
"qlist.pinShort": "ピン",
|
||||
"qlist.unpinShort": "解除",
|
||||
"qlist.myVotes": "作成した投票",
|
||||
"qlist.loadMore": "もっと読み込む",
|
||||
"qlist.newVote": "投票を作成",
|
||||
"qlist.edit": "編集",
|
||||
"qlist.untitledVote": "無題の投票",
|
||||
"qlist.blocked": "ブロック済み",
|
||||
"qlist.unanswered": "未回答",
|
||||
|
||||
// サイトロゴの接尾辞
|
||||
"site.logoSuffix": "の質問箱",
|
||||
"site.stats": "質問箱の統計",
|
||||
|
||||
// キャプチャモーダル
|
||||
"captcha.title": "人機認証",
|
||||
"captcha.hint": "以下の計算問題を解いて、ロボットでないことを証明してください",
|
||||
"captcha.refresh": "別の問題",
|
||||
"captcha.submit": "認証して送信",
|
||||
"captcha.loading": "読み込み中...",
|
||||
"captcha.loadFailed": "読み込み失敗",
|
||||
"captcha.loadRetry": "読み込み失敗、再試行してください",
|
||||
"captcha.inputAnswer": "答えを入力してください",
|
||||
"captcha.wrongAnswer": "答えが違います。もう一度計算してください",
|
||||
"captcha.verifyFailed": "認証失敗、再試行してください",
|
||||
|
||||
// 登録ページ補足
|
||||
"auth.register.domain.prefix": "カスタムドメイン(あなたの質問箱URLは %s + ",
|
||||
"auth.register.domain.suffix": " + 下記に入力した内容になります)",
|
||||
"auth.register.confirm": "パスワード(確認)",
|
||||
"auth.register.language": "表示言語",
|
||||
"auth.register.languageDefault": "サイト既定に従う",
|
||||
"auth.register.success": "登録が完了しました。狼の質問箱へようこそ!",
|
||||
|
||||
// 探索ページ補足
|
||||
"explore.recommend": "ランダムおすすめ",
|
||||
"explore.search": "タグを検索...",
|
||||
"explore.sortLatest": "新着順",
|
||||
"explore.sortOldest": "古い順",
|
||||
"explore.noTags": "タグなし",
|
||||
"explore.noRecommend": "おすすめユーザーはいません",
|
||||
"explore.hotTags": "人気タグ",
|
||||
"explore.hotQuestions": "人気質問",
|
||||
"explore.noResult": "条件に一致するユーザーはいません。",
|
||||
"explore.lazy": "このユーザーは何も書いていません~",
|
||||
"explore.prevPage": "前へ",
|
||||
"explore.nextPage": "次へ",
|
||||
"explore.loading": "読み込み中...",
|
||||
"explore.loadFailed": "読み込み失敗",
|
||||
|
||||
// 質問箱パスワードページ
|
||||
"password.title": "この質問箱はパスワード保護されています",
|
||||
"password.desc": "この質問箱はパスワード保護されています。パスワードを入力して内容を表示してください",
|
||||
"password.input": "アクセスパスワードを入力してください",
|
||||
"password.enter": "認証して入る",
|
||||
"password.submit": "確認",
|
||||
|
||||
// 質問が見つかりません
|
||||
"qitem.notFound": "質問が見つからないか、削除されました",
|
||||
|
||||
// チャットページ
|
||||
"chat.title": "チャット",
|
||||
"chat.input": "メッセージを入力...",
|
||||
"chat.send": "送信",
|
||||
"chat.delete": "会話を削除",
|
||||
"chat.block": "相手をブロック",
|
||||
"chat.unblock": "ブロック解除",
|
||||
"chat.empty": "メッセージはまだありません。何か書いてみましょう~",
|
||||
"chat.withAsker": "質問者とのチャット",
|
||||
"chat.withUser": "%s とのチャット",
|
||||
"chat.closed": "会話は終了しました",
|
||||
"chat.closedNoSend": "会話は終了したため、新しいメッセージを送信できません",
|
||||
"chat.remaining": "残りメッセージ:%d 件(全 %d 件)",
|
||||
"chat.me": "自分",
|
||||
"chat.other": "相手",
|
||||
"chat.userBlocked": "このユーザーはブロックされています",
|
||||
"chat.addImage": "画像を追加(任意、最大5MB)",
|
||||
"chat.confirm": "確認",
|
||||
"chat.confirmBlock": "このユーザーをブロックしますか?ブロック後はメッセージを送信できなくなります。",
|
||||
"chat.confirmUnblock": "このユーザーのブロックを解除しますか?再度メッセージを送信できるようになります。",
|
||||
"chat.deleteConfirm": "このチャットを削除しますか?相手はこの会話を見られなくなり、この操作は取り消せません。",
|
||||
"maintenance.title": "NekoBox メンテナンス中...",
|
||||
"maintenance.desc": "NekoBox はメンテナンス中のため、一時的にご利用いただけません。また後でお越しください~",
|
||||
|
||||
// 質問の個別シェア(v4)
|
||||
"qitem.shareCard": "この質問をシェア",
|
||||
|
||||
// 追加質問(v4)
|
||||
"followup.title": "追加質問",
|
||||
"followup.content": "追加質問:",
|
||||
"followup.answerPlaceholder": "この追加質問に回答...",
|
||||
"followup.answerBtn": "回答する",
|
||||
"followup.waiting": "回答待ち...",
|
||||
"followup.askMore": "追加で質問する",
|
||||
"followup.remaining": "追加質問可能: %d / %d 回",
|
||||
"followup.contentPlaceholder": "質問を補足...",
|
||||
"followup.submit": "送信",
|
||||
"followup.waitingReply": "追記を送信しました。ボックス所有者の返信をお待ちください...",
|
||||
|
||||
// ピン留め(v4)
|
||||
"qlist.pinned": "ピン留め中",
|
||||
"qlist.pin": "ピン留め",
|
||||
"qlist.unpin": "ピン留め解除",
|
||||
"qlist.readStatus": "既読ステータス",
|
||||
"qlist.read": "既読",
|
||||
"qlist.unread": "未読",
|
||||
"qlist.statsRead": "既読",
|
||||
"qlist.statsUnread": "未読",
|
||||
|
||||
// 質問送信完了ページ(v4)
|
||||
"success.title": "質問を送信しました!",
|
||||
"success.yourQuestion": "あなたの質問:",
|
||||
"success.emailSent": "この質問のプライベートリンクをメールで送信しました。以下のリンクからいつでも質問の確認、回答の受信、追加質問ができます。",
|
||||
"success.saveLink": "<strong>以下のプライベートリンクを必ず保存してください!</strong>メールアドレスを入力しなかったため、このリンク(トークンを含む)が質問の確認・回答の受信・追加質問のための<strong>唯一の証拠</strong>です。失くすと復元できません。",
|
||||
"success.privateLink": "あなたのプライベートリンク(クリックしてコピー)",
|
||||
"success.copy": "コピー",
|
||||
"success.copied": "コピーしました ✓",
|
||||
"success.copyFailed": "コピーに失敗",
|
||||
"success.token": "トークン: %s(質問者はこのトークンでプライベートリンクにアクセス)",
|
||||
"success.viewQuestion": "質問を確認する",
|
||||
"success.backToBox": "%sの質問箱に戻る",
|
||||
"success.pageTitle": "質問送信完了 - %s",
|
||||
|
||||
// アカウント停止ページ
|
||||
"deactivate.title": "アカウント停止",
|
||||
"deactivate.desc": "停止後、あなたの質問箱にはアクセスできなくなり、すべてのデータが削除されます。この操作は取り消せません!",
|
||||
"deactivate.confirm": "アカウントを停止することに同意します",
|
||||
"deactivate.submit": "アカウントを停止",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
"common.back": "戻る",
|
||||
|
||||
"vote.listTitle": "投票管理",
|
||||
"vote.newTitle": "投票を作成",
|
||||
"vote.editTitle": "投票を編集",
|
||||
"vote.resultsTitle": "投票結果",
|
||||
"vote.statsTotal": "投票総数",
|
||||
"vote.statsPublished": "進行中",
|
||||
"vote.createNew": "投票を作成",
|
||||
"vote.typeSingle": "単一選択",
|
||||
"vote.typeMultiple": "複数選択",
|
||||
"vote.typeRating": "評価",
|
||||
"vote.typeRank": "順位付け",
|
||||
"vote.typeForm": "フォーム",
|
||||
"vote.statusNotStarted": "未開始",
|
||||
"vote.statusDraft": "下書き",
|
||||
"vote.statusPublished": "進行中",
|
||||
"vote.statusEnded": "終了",
|
||||
"vote.statusArchived": "アーカイブ",
|
||||
"vote.participants": "参加者",
|
||||
"vote.remainLabel": "残り",
|
||||
"vote.perpetual": "期限なし",
|
||||
"vote.durationDay": "日",
|
||||
"vote.durationHour": "時間",
|
||||
"vote.durationMinute": "分",
|
||||
"vote.createdAt": "作成日時",
|
||||
"vote.edit": "編集",
|
||||
"vote.results": "結果",
|
||||
"vote.preview": "プレビュー",
|
||||
"vote.page.mustComplete": "入力してください",
|
||||
"vote.page.rankHintRequired": "順位 1-N(必須)",
|
||||
"vote.page.rankHintOptional": "順位 1-N(任意、一部のみ可)",
|
||||
"vote.page.previewHint": "プレビューモード:公開前のサンプルです。送信は無効です",
|
||||
"vote.page.previewSubmitHint": "プレビューモード。公開後に送信できます",
|
||||
"vote.publish": "公開",
|
||||
"vote.close": "終了",
|
||||
"vote.share": "リンクをコピー",
|
||||
"vote.copied": "コピーしました",
|
||||
"vote.shareURL": "投票の共有リンク",
|
||||
"vote.confirmPublish": "この投票を公開しますか?公開後、共有リンクが生成されます。",
|
||||
"vote.confirmClose": "この投票を終了しますか?終了後、参加者は投票できなくなります。",
|
||||
"vote.confirmDelete": "この投票を削除しますか?投票データもすべて削除され、元に戻せません!",
|
||||
"vote.manageEntry": "投票を管理",
|
||||
|
||||
"vote.manageTitle": "投票管理",
|
||||
"vote.manageDesc": "匿名投票の作成と管理", "vote.empty": "投票はまだありません。右上の\"投票を作成\"から始めましょう。",
|
||||
"vote.formLockedHint": "この投票は公開中です。基本情報のみ編集可能です(収集済みデータを守るため、タイプと選択肢はロックされています)",
|
||||
"vote.formBasic": "基本設定",
|
||||
"vote.formTitle": "投票タイトル",
|
||||
"vote.formTitlePlaceholder": "例:今日の昼ごはんは?",
|
||||
"vote.formDescription": "説明",
|
||||
"vote.formDescriptionPlaceholder": "補足説明(任意)",
|
||||
"vote.formType": "投票タイプ",
|
||||
"vote.formVisibility": "表示方法",
|
||||
"vote.formVisibilityPublic": "公開(探索ページ + 質問箱ページ)",
|
||||
"vote.formVisibilityLinkOnly": "リンクのみ",
|
||||
"vote.formVoterScope": "投票範囲",
|
||||
"vote.formVoterScopeAnyone": "誰でも(匿名含む)",
|
||||
"vote.formVoterScopeRegistered": "登録ユーザーのみ",
|
||||
"vote.formShowResult": "結果の表示タイミング",
|
||||
"vote.formShowResultAfterVote": "投票後すぐに表示",
|
||||
"vote.formShowResultAfterEnd": "終了後に表示",
|
||||
"vote.formShowResultOwnerOnly": "本人のみ",
|
||||
"vote.formStartAt": "開始時間(空欄=すぐ開始)",
|
||||
"vote.formEndAt": "終了時間(空欄=制限なし)",
|
||||
"vote.formMaxChoices": "最大選択数",
|
||||
"vote.formMaxVotesPerFingerprint": "投票ごとに1台の端末につき1回まで。同じブラウザからの再送信は拒否されます。",
|
||||
"vote.formMaxVotesPerAccount": "投票ごとに1つの登録アカウントにつき1回まで。同じアカウントからの再送信は拒否されます。",
|
||||
"vote.formAllowCustom": "参加者がカスタム選択肢を追加できる",
|
||||
"vote.formRequireCaptcha": "匿名投票にキャプチャを要求",
|
||||
"vote.formOptions": "投票の選択肢",
|
||||
"vote.formRatingHint": "評価投票は選択肢不要(1-10点)",
|
||||
"vote.addOption": "選択肢を追加",
|
||||
"vote.optionPlaceholder": "選択肢の内容",
|
||||
"vote.formFields": "フォーム項目",
|
||||
"vote.addField": "項目を追加",
|
||||
"vote.field": "項目",
|
||||
"vote.formFieldType": "項目タイプ",
|
||||
"vote.fieldTypeText": "一行テキスト",
|
||||
"vote.fieldTypeTextarea": "複数行テキスト",
|
||||
"vote.fieldTypeSelect": "ドロップダウン選択",
|
||||
"vote.fieldTypeRadio": "単一選択",
|
||||
"vote.fieldTypeCheckbox": "複数選択",
|
||||
"vote.fieldTypeNumber": "数値",
|
||||
"vote.fieldTypeRank": "順位",
|
||||
"vote.page.selectPlaceholder": "選択してください",
|
||||
"vote.page.avgNumber": "平均",
|
||||
"vote.page.fieldFilled": "回答数",
|
||||
"vote.fieldTypeRating": "評価",
|
||||
"vote.formFieldLabel": "項目ラベル",
|
||||
"vote.formFieldRequired": "必須",
|
||||
"vote.formFieldMaxLength": "文字数上限(0=制限なし)",
|
||||
"vote.formFieldOptions": "選択肢",
|
||||
"vote.formFieldOptionsHint": "下の「選択肢を追加」で一つずつ追加してください(カンマ不要)",
|
||||
"vote.fieldAllowCustom": "参加者がカスタム選択肢を追加できる",
|
||||
"vote.formFieldOptionsPlaceholder": "例:良い,普通,悪い",
|
||||
"vote.createDraft": "下書きを保存",
|
||||
"vote.builderHintOptions": "選択肢は最低 2 つ必要です",
|
||||
"vote.builderHintFields": "フィールドを最低 1 つ追加してください",
|
||||
"vote.builderHintFieldOptions": "選択・順位題には選択肢が必要です(順位は最低 2 つ)",
|
||||
"vote.resultsAnswered": "回答数",
|
||||
"vote.page.rankedTimes": "順位付け数",
|
||||
"vote.resultsTotal": "総参加人数",
|
||||
"vote.resultsSubmissions": "フォームの回答",
|
||||
"vote.resultsViewTexts": "テキスト回答を表示",
|
||||
"vote.resultsToggleHint": "クリックでテキスト回答の表示・非表示を切り替え",
|
||||
"vote.resultsOptions": "投票結果",
|
||||
"vote.resultsVotes": "票",
|
||||
"vote.export": "CSV エクスポート",
|
||||
"vote.resultsEmpty": "まだ参加者がいません。リンクを共有しましょう!",
|
||||
|
||||
// 投票参加ページ
|
||||
"vote.inlineTitle": "投票",
|
||||
"vote.page.by": "の投票",
|
||||
"vote.page.goVote": "投票する",
|
||||
"vote.page.notStarted": "未開始",
|
||||
"vote.page.notStartedHint": "この投票はまだ始まっていません。",
|
||||
"vote.page.ended": "終了",
|
||||
"vote.page.endedHint": "この投票は終了しました。",
|
||||
"vote.page.active": "進行中",
|
||||
"vote.page.participated": "参加済み",
|
||||
"vote.page.loginRequired": "この投票は登録ユーザーのみ参加できます。ログインしてください。",
|
||||
"vote.page.anonymousHint": "匿名での参加が可能で、あなたの身元は記録されません(同一端末で最大1回)。",
|
||||
"vote.page.submit": "投票する",
|
||||
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
|
||||
"vote.page.resultsTitle": "投票結果",
|
||||
"vote.page.totalVotes": "総参加",
|
||||
"vote.page.avgRating": "平均点",
|
||||
"vote.page.avgRank": "平均順位",
|
||||
"vote.page.optionColumn": "選択肢",
|
||||
"vote.page.ratingLabel": "評価",
|
||||
"vote.page.ratingUnit": "点",
|
||||
"vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)",
|
||||
"vote.page.rankPlaceholder": "順位",
|
||||
"vote.page.multipleHint": "複数選択可",
|
||||
"vote.page.customOption": "カスタム選択肢(任意)",
|
||||
"vote.page.customPlaceholder": "あなたの選択肢を入力",
|
||||
"vote.page.customOther": "その他",
|
||||
"vote.page.customFieldPlaceholder": "カスタム選択肢を入力…",
|
||||
"vote.page.customStats": "カスタム選択肢",
|
||||
"vote.page.formCount": "回答受付数",
|
||||
"vote.page.ownerManage": "この投票を管理(結果は本人のみ閲覧可能)",
|
||||
|
||||
// 投票ごとの定量収集
|
||||
"vote.formQuotaLimit": "収集上限(件)",
|
||||
"vote.formQuotaHint": "0 は無制限。指定件数に達すると自動的に投票を終了します。",
|
||||
"vote.flash.notFound": "この投票は存在しないか、既に無効です",
|
||||
"vote.flash.notPublished": "この投票はまだ公開されていません",
|
||||
"vote.flash.archived": "この投票はアーカイブ済みのため参加できません",
|
||||
"vote.flash.ended": "この投票は終了しました",
|
||||
"vote.flash.notStarted": "この投票はまだ開始していません",
|
||||
"vote.flash.success": "投票が完了しました。ご参加ありがとうございます!",
|
||||
"vote.err.noChoice": "少なくとも 1 つの選択肢を選んでください",
|
||||
"vote.err.tooManyChoices": "選択できるのは最大 %d 個までです",
|
||||
"vote.err.pickOne": "選択肢を 1 つ選んでください",
|
||||
"vote.err.invalidOption": "無効な選択肢です",
|
||||
"vote.err.optionDuplicate": "選択肢は重複できません",
|
||||
"vote.err.ratingRequired": "1〜10 点の評価を入力してください",
|
||||
"vote.err.rankRequired": "すべての選択肢に 1〜%d の順位を入力してください",
|
||||
"vote.err.rankDuplicate": "順位が重複しています",
|
||||
"vote.err.fieldRequired": "「%s」を入力してください",
|
||||
"vote.err.fieldCustomRequired": "「%s」の自由記入欄を入力してください",
|
||||
"vote.err.fieldCustomTooLong": "「%s」の自由記入は %d 字以内にしてください",
|
||||
"vote.err.fieldInvalidOption": "「%s」の選択肢が無効です",
|
||||
"vote.err.fieldConfigError": "「%s」の選択肢の設定に誤りがあります",
|
||||
"vote.err.fieldRankIncomplete": "「%s」の順位をすべて入力してください",
|
||||
"vote.err.fieldRankRange": "「%s」の順位は 1〜%d の数字で入力してください",
|
||||
"vote.err.fieldRankDuplicate": "「%s」の順位が重複しています",
|
||||
"vote.err.fieldTooLong": "「%s」が文字数上限(最大 %d 字)を超えています",
|
||||
"vote.err.fieldNotNumber": "「%s」は数値で入力してください",
|
||||
"vote.err.fieldRating": "「%s」は 1〜10 点で評価してください",
|
||||
"vote.err.unknownType": "不明な投票タイプです",
|
||||
"vote.err.customTooLong": "自由記入オプションは %d 字以内にしてください",
|
||||
"vote.err.blockWords": "投稿内容に投票作成者が設定したブロックワードが含まれているため、送信できませんでした",
|
||||
"vote.err.serialize": "送信内容の保存に失敗しました",
|
||||
"captcha.error": "認証コードが正しくありません",
|
||||
"captcha.errEmpty": "認証コードを入力してください",
|
||||
"captcha.errFormat": "認証コードの形式が正しくありません。再読み込みしてお試しください",
|
||||
"captcha.errAnswerFormat": "認証コードの回答形式が正しくありません",
|
||||
"captcha.errExpired": "認証コードの有効期限が切れました。再読み込みしてお試しください",
|
||||
"captcha.errService": "認証コードサービスでエラーが発生しました。後ほどお試しください",
|
||||
"captcha.errAnswerWrong": "認証コードの回答が正しくありません",
|
||||
"censor.unavailable": "コンテンツ審査サービスが一時的に利用できません。後ほどお試しください",
|
||||
|
||||
// 機能管理:質問 / チャット / 投票 の ON・OFF、期間指定、定量収集
|
||||
"admin.tabAccount": "ユーザー設定",
|
||||
"admin.featuresHint": "「質問 / チャット / 投票」パネルで各機能の ON/OFF、公開期間、収集上限を管理します。上限に達すると対象機能は自動的に閉じられます。",
|
||||
"admin.featuresEntry": "設定",
|
||||
"admin.featureHint.question": "OFF にすると新しい質問を投稿できません。上限に達すると自動的に受付を停止します(非公開質問を含む)。",
|
||||
"admin.featureHint.chat": "OFF にすると新しいチャットを開始できません。既存の会話には影響しません。",
|
||||
"admin.featureHint.vote": "OFF にすると質問箱ページに投票が表示されず、回答もできなくなります。投票ごとの上限は編集ページで設定できます。",
|
||||
"admin.featureEnabled": "この機能を有効にする",
|
||||
"admin.featureOpenAt": "公開開始",
|
||||
"admin.featureCloseAt": "公開終了",
|
||||
"admin.featureTimeHint": "空欄は制限なし。開始前と終了後はその機能を利用できません。",
|
||||
"admin.featureSchedule": "定期公開(毎週)",
|
||||
"admin.featureScheduleStart": "毎日の開始",
|
||||
"admin.featureScheduleEnd": "毎日の終了",
|
||||
"admin.featureScheduleHint": "毎週の公開曜日と毎日の時間帯を設定すると、毎週繰り返し公開されます。すべて空欄で無効。曜日のみ(時間なし)は終日公開、時間のみ(曜日なし)は毎日その時間帯に公開。",
|
||||
"admin.featureMode": "収集モード",
|
||||
"admin.featureModeAlways": "常時開放",
|
||||
"admin.featureModeWindow": "期間限定",
|
||||
"admin.featureModeRecurring": "曜日スケジュール",
|
||||
"admin.featureModeHint": "常時開放:手動でオフにするまで開放し続けます。期間限定:一回限りの開始/終了時刻で開放します。曜日スケジュール:毎週の開放日と時間帯を繰り返し適用します。モードは保存時に排他となり、未選択モードの設定はクリアされます。",
|
||||
"admin.featureQuotaLimit": "収集上限(件)",
|
||||
"admin.featureQuotaHint": "0 は無制限。上限に達すると自動的に閉じられます。カウントをリセットすると次の回を開始できます。",
|
||||
"admin.featureQuotaCount": "収集済み",
|
||||
"admin.featureQuotaUnlimited": "無制限",
|
||||
"admin.featureQuotaResetAt": "最終リセット:",
|
||||
"admin.featureResetQuota": "カウントをリセット",
|
||||
"admin.featureSave": "この機能を保存",
|
||||
|
||||
// 機能管理:状態とメッセージ
|
||||
"feature.name.question": "質問",
|
||||
"feature.name.chat": "チャット",
|
||||
"feature.name.vote": "投票",
|
||||
"feature.statusOpen": "受付中",
|
||||
"feature.status.disabled": "停止中",
|
||||
"feature.status.not_started": "未開始",
|
||||
"feature.status.ended": "終了",
|
||||
"feature.status.quota_reached": "上限到達",
|
||||
"feature.saved": "機能設定を保存しました",
|
||||
"feature.errTime": "%s の日時形式が正しくありません。日時選択ツールを使用してください",
|
||||
"feature.errTimeRange": "%s の公開終了時刻は公開開始時刻より後にしてください",
|
||||
"feature.errQuota": "%s の収集上限は 0〜1000000 の整数で入力してください",
|
||||
"feature.errInvalid": "不明な機能です",
|
||||
"feature.quotaReset": "%s の収集カウントをリセットしました",
|
||||
"feature.errScheduleHalf": "%s の定期公開の開始時間と終了時間は両方入力してください",
|
||||
"feature.errScheduleTime": "%s の定期公開の時刻形式が正しくありません。時刻選択ツールを使用してください",
|
||||
"feature.errScheduleRange": "%s の定期公開の終了時刻は開始時刻より後にしてください",
|
||||
|
||||
// 機能が無効なときに訪問者へ表示するメッセージ
|
||||
"feature.disabled.question": "この質問箱の管理者は質問の受付を停止しています",
|
||||
"feature.disabled.chat": "この質問箱ではチャットを受け付けていません",
|
||||
"feature.disabled.vote": "この質問箱では投票を受け付けていません",
|
||||
"feature.not_started.question": "質問の受付はまだ開始していません",
|
||||
"feature.not_started.chat": "チャットの受付はまだ開始していません",
|
||||
"feature.not_started.vote": "投票の受付はまだ開始していません",
|
||||
"feature.ended.question": "質問の受付は終了しました",
|
||||
"feature.ended.chat": "チャットの受付は終了しました",
|
||||
"feature.ended.vote": "投票の受付は終了しました",
|
||||
"feature.quota_reached.question": "質問の収集上限に達しました",
|
||||
"feature.quota_reached.chat": "チャットの収集上限に達しました",
|
||||
"feature.quota_reached.vote": "投票の収集上限に達しました",
|
||||
|
||||
// 認証:訪問者に表示されるメッセージ(P0)
|
||||
"auth.errBadCredential": "メールアドレスまたはパスワードが正しくありません",
|
||||
"auth.errUserNotFound": "ユーザーが存在しません",
|
||||
"auth.errEmailFrequent": "メールの送信頻度が高すぎます。しばらくしてからお試しください",
|
||||
"auth.errEmailFailed": "メールの送信に失敗しました。しばらくしてからお試しください",
|
||||
"auth.errCodeExpired": "認証コードの有効期限が切れました",
|
||||
"auth.errPasswordChanged": "パスワードを変更しました",
|
||||
"auth.errLoginRequired": "ログインしてください",
|
||||
"auth.errDuplicateEmail": "このメールアドレスは既に登録されています",
|
||||
"auth.errDuplicateDomain": "このカスタムドメインは既に使用されています",
|
||||
|
||||
// 質問箱ページ:訪問者に表示(P0)
|
||||
"question.errWrongPassword": "パスワードが正しくありません",
|
||||
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
|
||||
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
|
||||
"question.errSelfChat": "自分自身とのチャットは開始できません",
|
||||
"question.errBlockedChat": "相手にブロックされているため、チャットを開始できません",
|
||||
"question.errChatDailyLimit": "本日の送信上限(%d件)に達しました。明日お試しください",
|
||||
"question.errDuplicateSubmit": "重複送信が検出されました。しばらくしてからお試しください",
|
||||
"question.errImageTooLarge": "画像ファイルのサイズは 5Mb 以下である必要があります",
|
||||
"question.errUploadImageFailed": "画像のアップロードに失敗しました",
|
||||
"question.errBlockWords": "質問に所有者が設定したブロックワードが含まれているため、送信できませんでした",
|
||||
"question.errAnsweredLocked": "回答済みの質問は未読にできません",
|
||||
"question.flash.chatSent": "チャットリクエストを送信しました!以下のリンクから会話を確認できます:%s",
|
||||
"question.flash.answered": "回答を公開しました!",
|
||||
"question.flash.operated": "操作が完了しました",
|
||||
"question.flash.markedRead": "既読にしました",
|
||||
"question.flash.markedUnread": "未読にしました",
|
||||
|
||||
// 追加質問:訪問者/所有者に表示(P0)
|
||||
"followup.errNoPermission": "権限がありません",
|
||||
"followup.errNotAnswered": "この質問にはまだ回答がありません。追加質問はできません",
|
||||
"followup.errLimit": "各質問の追加質問は最大 %d 回までで、上限に達しました",
|
||||
"followup.errWaitReply": "前の追加質問への返信を待ってから続けてください",
|
||||
"followup.errNotFound": "追加質問が存在しません",
|
||||
"followup.flash.sent": "追加質問を送信しました!",
|
||||
"followup.flash.answered": "回答を投稿しました!",
|
||||
|
||||
// 管理設定:所有者に表示(P1)
|
||||
"admin.flash.profileUpdated": "プロフィールを更新しました",
|
||||
"admin.flash.chatUpdated": "チャット設定を更新しました",
|
||||
"admin.flash.pagePwdOff": "ページパスワード認証を無効にしました",
|
||||
"admin.flash.pagePwdUpdated": "ページアクセスパスワードを更新しました",
|
||||
"admin.flash.askPwdOff": "質問パスワードを無効にしました",
|
||||
"admin.flash.askPwdUpdated": "質問パスワードを更新しました",
|
||||
"admin.flash.tagsTooMany": "タグは最大 5 個までです",
|
||||
"admin.flash.tagsUpdated": "タグを更新しました",
|
||||
"admin.flash.tagTooLong": "各タグは 5 文字以内である必要があります:%s",
|
||||
"admin.flash.blockWordsTooMany": "ブロックワードは最大 10 個までです",
|
||||
"admin.flash.blockWordTooLong": "ブロックワードの長さは 10 文字以内である必要があります:%s",
|
||||
"admin.flash.harassmentUpdated": "迷惑行為対策を更新しました",
|
||||
"admin.flash.blockWordsUpdated": "ブロックワードを更新しました",
|
||||
"admin.flash.deactivated": "アカウントを停止しました。狼の質問箱をご利用いただきありがとうございます。またのお越しをお待ちしています 👋🏻",
|
||||
"admin.export.errUser": "エクスポート失敗:ユーザー情報の取得に失敗しました",
|
||||
"admin.export.errQuestion": "エクスポート失敗:質問情報の取得に失敗しました",
|
||||
|
||||
// 投票管理:所有者に表示(P1)
|
||||
"vote.err.invalidID": "無効な投票 ID",
|
||||
"vote.err.notExist": "投票が存在しません",
|
||||
"vote.err.noPermission": "この投票を操作する権限がありません",
|
||||
"vote.err.type": "不明な投票タイプです",
|
||||
"vote.err.titleRequired": "投票のタイトルを入力してください",
|
||||
"vote.err.titleTooLong": "投票のタイトルは 100 文字以内である必要があります",
|
||||
"vote.err.visibility": "不明な表示設定です",
|
||||
"vote.err.voterScope": "不明な投票範囲設定です",
|
||||
"vote.err.showResult": "不明な結果表示設定です",
|
||||
"vote.err.timeRange": "終了時刻は開始時刻より後にしてください",
|
||||
"vote.err.quota": "収集上限は 0〜1000000 の整数で入力してください",
|
||||
"vote.err.optionsTooFew": "選択肢を最低 2 つ追加してください",
|
||||
"vote.err.optionsFormat": "選択肢データの形式が正しくありません",
|
||||
"vote.err.optionTooLong": "1 つの選択肢は %d 文字以内である必要があります",
|
||||
"vote.err.maxChoicesExceed": "最大選択数は選択肢の総数を超えられません",
|
||||
"vote.err.fieldsTooFew": "フォーム項目を最低 1 つ追加してください",
|
||||
"vote.err.fieldsFormat": "フォーム項目データの形式が正しくありません",
|
||||
"vote.err.fieldsTooMany": "フォーム項目は最大 50 個までです",
|
||||
"vote.err.fieldType": "不明なフォーム項目タイプです",
|
||||
"vote.err.fieldLabelMissing": "%d 番目の項目にラベルがありません",
|
||||
"vote.err.fieldLabelTooLong": "%d 番目の項目のラベルは 100 文字以内である必要があります",
|
||||
"vote.err.fieldLabelDuplicate": "項目ラベル「%s」が重複しています。別のラベルを使用してください",
|
||||
"vote.err.fieldMaxLength": "%d 番目の項目の文字数上限は 0〜2000 の間である必要があります",
|
||||
"vote.err.fieldReservedOption": "%d 番目の項目(%s)の選択肢に予約値が含まれています。変更してください",
|
||||
"vote.err.fieldOptionsTooFew": "%d 番目の項目(%s)は選択肢を最低 1 つ入力してください",
|
||||
"vote.err.fieldRankTooFew": "%d 番目の項目(%s)の順位付けには最低 2 つの選択肢が必要です",
|
||||
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
|
||||
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
|
||||
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
|
||||
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
|
||||
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
|
||||
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
|
||||
"vote.err.publishOptionsRequired": "選択型投票には最低 2 つの選択肢が必要です",
|
||||
"vote.err.optionUpdateFailed": "選択肢の更新に失敗しました。再試行してください",
|
||||
"vote.err.fieldUpdateFailed": "フォーム項目の更新に失敗しました。再試行してください",
|
||||
"vote.flash.published": "投票を公開しました!共有リンク:%s",
|
||||
"vote.flash.updated": "投票情報を更新しました(進行中の投票はタイプと選択肢を変更できません)",
|
||||
"vote.flash.draftSaved": "下書きに保存しました",
|
||||
"vote.flash.closed": "投票を終了しました",
|
||||
"vote.flash.deleted": "投票を削除しました",
|
||||
"vote.err.timeFormat": "日時の形式が正しくありません。日時選択器を使用してください",
|
||||
"vote.err.maxChoicesLimit": "選択可能数は20を超えられません",
|
||||
"vote.err.descriptionTooLong": "投票の説明は5000文字を超えられません",
|
||||
"profile.err.avatarTooLarge": "アイコンファイルが大きすぎます。最大2MB",
|
||||
"profile.err.backgroundTooLarge": "背景画像が大きすぎます。最大2MB",
|
||||
"profile.err.newPasswordTooShort": "新しいパスワードは8文字以上である必要があります",
|
||||
"profile.err.badOldPassword": "古いパスワードが間違っています",
|
||||
"admin.export.errCreateExcel": "エクスポート失敗:Excelファイルの作成に失敗しました",
|
||||
"admin.export.errWriteExcel": "エクスポート失敗:Excelファイルの書き込みに失敗しました",
|
||||
|
||||
// プロフィール設定ページ UI リニューアル:カード区切り/アイコン・背景の切り取り/パスワード変更後の強制ログアウト
|
||||
"profile.card.basic": "基本情報",
|
||||
"profile.card.avatarBg": "アイコンと背景",
|
||||
"profile.card.qr": "QRコードのスタイル",
|
||||
"profile.card.password": "パスワード変更",
|
||||
"profile.avatarCropHint": "切り取り枠は 1:1(正方形)に固定されています",
|
||||
"profile.backgroundCropHint": "切り取り枠は 16:9 に固定されています",
|
||||
"profile.cropTitle": "画像を切り取る",
|
||||
"profile.cropConfirm": "切り取りを確定",
|
||||
"profile.err.avatarRatio": "アイコン画像は 1:1 の正方形である必要があります。切り取ってから再アップロードしてください",
|
||||
"profile.err.backgroundRatio": "背景画像は 16:9 の縦横比である必要があります。切り取ってから再アップロードしてください",
|
||||
"profile.flash.passwordChanged": "パスワードを変更しました。ログアウトしましたので、新しいパスワードで再度ログインしてください",
|
||||
"profile.pwdNote": "保存後、現在のセッションはすぐに無効になり、新しいパスワードで再ログインする必要があります",
|
||||
"profile.pickAvatar": "アイコンを変更",
|
||||
"profile.pickBackground": "背景を変更",
|
||||
"profile.saveBasic": "基本情報を保存",
|
||||
"profile.saveMedia": "アイコンと背景を保存",
|
||||
"profile.savePassword": "パスワードを更新",
|
||||
"profile.saveQR": "QRコードのスタイルを保存",
|
||||
"profile.saveSocial": "SNSリンクを保存",
|
||||
"profile.err.cropLibUnavailable": "画像切り取り機能の読み込みに失敗しました。ページを更新してもう一度お試しください",
|
||||
|
||||
// 認証コード JSON インターフェース:フロントエンドが消費(P2)
|
||||
"captcha.json.badRequest": "リクエスト形式が正しくありません",
|
||||
"captcha.json.idRequired": "認証コード ID を入力してください",
|
||||
"captcha.json.genFailed": "認証コードの生成に失敗しました",
|
||||
"chat.errDeletedByOwner": "このプライベート会話は相手によって削除されました。",
|
||||
"chat.errBlockedView": "あなたは相手にブロックされ、このチャットを表示できません",
|
||||
"chat.errEmpty": "メッセージを入力してください",
|
||||
"chat.errTooLong": "メッセージが長すぎます。%d文字以内でお願いします",
|
||||
"chat.errConvDeletedSend": "この会話は削除されたため、メッセージを送信できません",
|
||||
"chat.errBlockedSend": "あなたは相手にブロックされ、メッセージを送信できません",
|
||||
"chat.errBlockWords": "メッセージにボックス所有者が設定したブロックワードが含まれているため、送信できませんでした",
|
||||
"chat.errDailyLimit": "本日の送信上限(%d件)に達しました。明日再度お試しください",
|
||||
"chat.errLimitReached": "会話が上限に達したため、新しいメッセージを送信できません",
|
||||
"chat.errChatClosed": "箱の所有者がプライベートチャットを無効にしたため、メッセージを送信できません",
|
||||
"chat.errImageTooLarge": "画像サイズは%dMBを超えることはできません",
|
||||
"chat.errUploadFailed": "画像のアップロードに失敗しました。後でもう一度お試しください",
|
||||
"chat.errNoPermission": "この操作を実行する権限がありません",
|
||||
"chat.flash.sent": "メッセージを送信しました",
|
||||
"chat.flash.deleted": "プライベート会話を削除しました",
|
||||
"chat.flash.operated": "操作が完了しました",
|
||||
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
|
||||
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
|
||||
"internal.errServer": "サーバー内部エラー",
|
||||
"home.errStats": "統計情報の取得に失敗しました",
|
||||
"pixel.errPath": "不正なリクエストパス",
|
||||
"proxy.errNotFound": "ページが見つかりません",
|
||||
"proxy.errInternal": "サービスゲートウェイ内部エラー",
|
||||
"explore.errRandomUser": "ランダムユーザーの取得に失敗しました",
|
||||
// 表单校验
|
||||
"form.answer.content": "回答内容を入力してください",
|
||||
"form.answer.contentTooLong": "回答内容は%d文字以内にしてください",
|
||||
"form.email.required": "有効なメールアドレスを入力してください",
|
||||
"form.followup.answer": "フォローアップの回答を入力してください",
|
||||
"form.followup.answerTooLong": "フォローアップの回答は%d文字以内にしてください",
|
||||
"form.followup.content": "フォローアップ内容を入力してください",
|
||||
"form.followup.contentTooLong": "フォローアップ内容は%d文字以内にしてください",
|
||||
"form.login.password": "パスワードを入力してください",
|
||||
"form.profile.backgroundimagealpha": "QRコードの背景の不透明度を入力してください",
|
||||
"form.profile.colorHex": "7文字の16進数カラーコード(#を含む)を入力してください",
|
||||
"form.profile.dotscale": "QRコードの点の大きさを入力してください",
|
||||
"form.profile.intro": "自己紹介は必須で、100文字以内である必要があります",
|
||||
"form.profile.linkMax": "プロフィールリンクは30文字以内である必要があります",
|
||||
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
|
||||
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
|
||||
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
|
||||
"form.question.content": "質問内容を入力してください",
|
||||
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
|
||||
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
|
||||
"form.recaptcha": "CAPTCHA認証を完了してください",
|
||||
"form.recover.newPassword": "8〜30文字の新しいパスワードを設定してください",
|
||||
"form.recover.repeatPassword": "入力した2つの新しいパスワードが一致しません",
|
||||
"form.register.domain": "カスタムドメインは半角英数字とアンダースコアのみ使用でき、3〜20文字である必要があります",
|
||||
"form.register.email": "有効なメールアドレスを入力してください(100文字以内)",
|
||||
"form.register.name": "ニックネームは必須で、20文字以内である必要があります",
|
||||
"form.register.password": "8〜30文字のパスワードを設定してください",
|
||||
"form.register.recaptcha": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
|
||||
"form.register.repeatPassword": "入力した2つのパスワードが一致しません",
|
||||
}
|
||||
@@ -1,842 +0,0 @@
|
||||
// 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 i18n
|
||||
|
||||
// zhCNDict 简体中文文案。
|
||||
var zhCNDict = map[string]string{
|
||||
// 导航
|
||||
"nav.explore": "探索",
|
||||
"nav.mine": "我的",
|
||||
"nav.register": "注册",
|
||||
"nav.login": "登录",
|
||||
"nav.ask": "提问",
|
||||
"nav.settings": "设置",
|
||||
"nav.lang": "语言",
|
||||
|
||||
// 通用
|
||||
"common.login": "登录",
|
||||
"common.forgot": "忘记密码",
|
||||
"common.submit": "提交",
|
||||
"common.cancel": "取消",
|
||||
"common.save": "保存",
|
||||
"common.close": "关闭",
|
||||
"common.delete": "删除",
|
||||
"common.moveUp": "上移",
|
||||
"common.moveDown": "下移",
|
||||
"common.share": "分享",
|
||||
"common.verifying": "验证中...",
|
||||
"common.submitting": "提交中...",
|
||||
"common.uploading": "上传中...",
|
||||
"common.lang": "语言",
|
||||
"common.menu": "菜单",
|
||||
|
||||
"common.pleaseWait": "请稍候...",
|
||||
"common.backHome": "返回首页",
|
||||
"common.theme": "切换深色/浅色模式",
|
||||
|
||||
// 站点
|
||||
"site.title": "狼的提问箱 | 匿名提问箱",
|
||||
"site.description": "狼的提问箱——一个无需注册、可匿名提问的匿名提问箱,安全地倾诉与回应。",
|
||||
"site.keywords": "匿名提问箱,提问箱,匿名提问,狼的提问箱,匿名问答,匿名提问网站",
|
||||
"site.footer.visitor": "您是本站第 %s 位访客",
|
||||
"site.footer.pageview": "本页面已被访问 %s 次",
|
||||
"site.footer.runtime": "提问箱已成功存活",
|
||||
"site.footer.admin": "找站长",
|
||||
"site.footer.notice": "公告板",
|
||||
"site.footer.chat": "聊天室",
|
||||
"site.footer.drive": "盘",
|
||||
"site.footer.status": "监控板",
|
||||
"site.footer.nav": "导航页",
|
||||
"site.maintenance": "本站正在维护中,请稍后再来~",
|
||||
|
||||
// 认证
|
||||
"auth.login.title": "用户登录",
|
||||
"auth.login.email": "电子邮箱",
|
||||
"auth.login.password": "密码",
|
||||
"auth.login.submit": "登录",
|
||||
"auth.login.forgot": "忘记密码",
|
||||
"auth.register.title": "用户注册",
|
||||
"auth.register.name": "昵称",
|
||||
"auth.register.email": "电子邮箱",
|
||||
"auth.register.password": "密码",
|
||||
"auth.register.submit": "注册",
|
||||
"auth.forgot.title": "忘记密码",
|
||||
"auth.forgot.email": "电子邮箱",
|
||||
"auth.forgot.submit": "发送重置邮件",
|
||||
"auth.forgot.submitBtn": "找回密码",
|
||||
"auth.forgot.sent": "邮件已发送至 %s,请查收。",
|
||||
"auth.recover.title": "重置密码",
|
||||
"auth.recover.greeting": "%s,您正在重置您的密码:",
|
||||
"auth.recover.password": "新密码",
|
||||
"auth.recover.confirm": "确认密码",
|
||||
"auth.recover.submit": "重置密码",
|
||||
|
||||
// 探索页
|
||||
"explore.title": "探索用户标签",
|
||||
"explore.notice": "此处展示已开启<span class=\"uk-text-bold\">“在首页展示我的标签”</span>的用户。该功能默认关闭,若希望自己的提问箱在这里被发现,请前往 <a href=\"/user/profile\" class=\"uk-text-bold\">个人设置</a> 手动开启。",
|
||||
"explore.shuffle": "换一批",
|
||||
"explore.empty": "暂时没有发现可展示的用户,快去开启吧~",
|
||||
"explore.hotVotes": "热门投票",
|
||||
"explore.questionsTab": "提问",
|
||||
"explore.votesTab": "投票",
|
||||
"explore.votesTitle": "公开投票",
|
||||
"explore.votesDesc": "浏览正在进行的公开投票,选择你感兴趣的主题参与。",
|
||||
"explore.randomVote": "随机推荐",
|
||||
"explore.allVotes": "全部投票",
|
||||
"explore.noVotes": "暂时没有可参与的公开投票。",
|
||||
|
||||
// 提问页
|
||||
"ask.scan": "扫码向@%s提问",
|
||||
"ask.anonymous": "谁都可以以匿名的形式提问",
|
||||
"ask.registerOnly": "仅允许注册用户向TA提问",
|
||||
"ask.nextOpenAt": "下次开放时间:%s",
|
||||
"ask.lastCloseAt": "上次关闭时间:%s",
|
||||
"weekday.0": "周日",
|
||||
"weekday.1": "周一",
|
||||
"weekday.2": "周二",
|
||||
"weekday.3": "周三",
|
||||
"weekday.4": "周四",
|
||||
"weekday.5": "周五",
|
||||
"weekday.6": "周六",
|
||||
"ask.pinned": "已置顶",
|
||||
"ask.share": "分享提问箱",
|
||||
"ask.placeholder": "在此处撰写你的问题...",
|
||||
"ask.chatPlaceholder": "在此处撰写你的私聊消息...",
|
||||
"ask.addImage": "添加图片",
|
||||
"ask.private": "回复后不公开",
|
||||
"ask.notify": "接收通知",
|
||||
"ask.emailPlaceholder": "请输入你的邮箱地址",
|
||||
"ask.submit": "发送提问",
|
||||
"ask.chat": "发起私聊",
|
||||
"ask.chatDisabled": "该提问箱未开启私聊",
|
||||
"ask.chatLimit": "消息上限:%d条",
|
||||
"ask.history": "@%s 以前回答过的问题",
|
||||
"ask.loginRequired": "需要登录",
|
||||
"ask.loginRequiredChat": "发起私聊需要登录账号",
|
||||
"ask.goLogin": "去登录",
|
||||
"ask.needPassword": "此提问箱需要密码",
|
||||
"ask.needLogin": "提问箱的主人设置了仅注册用户才能提问,你需要先登录才能向他提问。",
|
||||
"ask.pleaseEmail": "请填写接收通知的邮箱地址",
|
||||
"ask.generating": "正在生成分享图…",
|
||||
"ask.genFailed": "生成分享图失败,请稍后重试",
|
||||
"ask.networkError": "网络错误",
|
||||
"ask.shareFilename": "share.png",
|
||||
|
||||
// 问题条目
|
||||
"qitem.answer": "回答",
|
||||
"qitem.delete": "删除",
|
||||
"qitem.private": "设为私密",
|
||||
"qitem.public": "设为公开",
|
||||
"qitem.asked": "提问",
|
||||
"qitem.pending": "等待回答",
|
||||
"qitem.answeredBy": "@%s 于 %s 的回答",
|
||||
"qitem.danger": "危险!",
|
||||
"qitem.deleteConfirm": "你确定要删除这个提问吗?该操作不可恢复,请谨慎操作。",
|
||||
"qitem.deleteConfirmBtn": "确认删除",
|
||||
"qitem.backToBox": "返回%s的提问箱",
|
||||
"qitem.answerTitle": "回答问题",
|
||||
"qitem.answerPlaceholder": "在此处撰写你的回答...",
|
||||
"qitem.emailNotifyHint": "提问人留下了自己的电子邮箱,在你第一次回复该问题后,提问人将会收到一封邮件通知。",
|
||||
"qitem.submitAnswer": "回答",
|
||||
"qitem.updateAnswer": "更新回答",
|
||||
"qitem.askMore": "再问点别的问题?",
|
||||
"qitem.markRead": "标记为已读",
|
||||
"qitem.markUnread": "标记为未读",
|
||||
"qitem.readLocked": "已回答,已锁定为已读",
|
||||
|
||||
// 个人设置页
|
||||
"admin.profile": "个人信息",
|
||||
"admin.lang": "界面语言",
|
||||
"admin.langHint": "选择访客访问你的提问箱时显示的语言(后台页面跟随此设置)",
|
||||
"admin.email": "电子邮箱",
|
||||
"admin.name": "昵称",
|
||||
"admin.nameColor": "昵称颜色",
|
||||
"admin.oldPassword": "旧密码",
|
||||
"admin.newPassword": "新密码",
|
||||
"admin.notify": "新提问通知",
|
||||
"admin.personalize": "个性化设置",
|
||||
"admin.intro": "提问箱介绍",
|
||||
"admin.introColor": "提问箱介绍颜色",
|
||||
"admin.qrBgColor": "二维码背景色(亮色部分)",
|
||||
"admin.qrPdColor": "二维码探测图形颜色(边角方形)",
|
||||
"admin.qrFgColor": "二维码前景色",
|
||||
"admin.qrDotSize": "二维码点大小 (←小 大→)",
|
||||
"admin.qrBgAlpha": "二维码背景透明度 (←不透明 很透明→)",
|
||||
"admin.avatar": "个人头像",
|
||||
"admin.background": "提问箱背景",
|
||||
"admin.qrBackground": "二维码背景图",
|
||||
"admin.social": "社交信息设置",
|
||||
"admin.harassment": "防骚扰设置",
|
||||
"admin.askLimit": "提问限制",
|
||||
"admin.blockWords": "屏蔽词设置(回车或逗号分隔,最多支持 10 个屏蔽词,每个屏蔽词最大长度为 10)",
|
||||
"admin.chat": "私聊设置",
|
||||
"admin.chatMaxMessages": "私聊消息条数上限",
|
||||
"admin.chatDailyLimit": "单用户每日私信条数上限",
|
||||
"admin.tags": "标签设置",
|
||||
"admin.tagsHint": "标签(最多5个,每个最多5字,回车或逗号分隔)",
|
||||
"admin.tagsPlaceholder": "例如:动漫,音乐,游戏",
|
||||
"admin.tagInputHint": "输入后按回车或逗号添加",
|
||||
"admin.tagInputDuplicate": "已存在,已忽略",
|
||||
"admin.pagePassword": "页面访问密码",
|
||||
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
|
||||
"admin.askPassword": "提问密码",
|
||||
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
|
||||
"admin.saveProfile": "保存修改",
|
||||
"admin.updated": "更新个人信息成功",
|
||||
"admin.logout": "退出登录",
|
||||
"admin.export": "导出数据",
|
||||
|
||||
// 后台设置(补充)
|
||||
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
|
||||
"admin.newPasswordHint": "6-64 位,留空则不修改",
|
||||
"admin.notifyEmail": "邮件",
|
||||
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
|
||||
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
|
||||
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
|
||||
"admin.backgroundPlaceholder": "上传提问箱背景",
|
||||
"admin.qrBackgroundPlaceholder": "上传二维码背景(颜色过深的背景会导致二维码无法识别)",
|
||||
"admin.qrPreviewHint": "选择图片后预览将即时更新",
|
||||
"admin.preview": "实时预览",
|
||||
"admin.themePreset": "预设主题",
|
||||
"admin.themeDefault": "默认",
|
||||
"admin.themeDark": "暗夜",
|
||||
"admin.themeOcean": "海洋",
|
||||
"admin.themeSunset": "日落",
|
||||
"admin.resetDefaults": "🔄 恢复默认值",
|
||||
"admin.previewHint": "调整仅用于预览,最终效果点击「保存修改」后生效",
|
||||
"admin.socialHint": "勾选后填写主页地址(无需 https://),未勾选的项将不会在前台显示。",
|
||||
"admin.enableAcfun": "启用 AcFun",
|
||||
"admin.enableBili": "启用 Bilibili",
|
||||
"admin.enableWeibo": "启用 微博",
|
||||
"admin.enableXiaohongshu": "启用 小红书",
|
||||
"admin.enableBlog": "启用 个人博客",
|
||||
"admin.registerOnly": "仅允许注册用户向我提问",
|
||||
"admin.updateHarassment": "更新防骚扰设置",
|
||||
"admin.globalBlockWords": "全局屏蔽词",
|
||||
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
|
||||
"admin.updateBlockWords": "保存屏蔽词",
|
||||
"admin.allowChat": "允许注册用户向我发起私聊",
|
||||
"admin.dailyLimitHint": "0 表示不限制",
|
||||
"admin.updateChat": "更新私聊设置",
|
||||
"admin.showInHome": "在首页展示我的标签(允许其他用户通过标签搜索到我)",
|
||||
"admin.updateTags": "更新标签",
|
||||
"admin.updatePagePassword": "更新页面密码",
|
||||
"admin.pagePasswordPlaceholder": "输入页面访问密码",
|
||||
"admin.updateAskPassword": "更新提问密码",
|
||||
"admin.askPasswordPlaceholder": "输入提问密码",
|
||||
"admin.account": "账号设置",
|
||||
"admin.exportHint": "您可以导出您在 %s 中的所有个人数据,包括你的基本信息、收到的问题以及回答。",
|
||||
"admin.exportJSON": "导出全部数据(JSON)",
|
||||
"admin.exportJSONHint": "包含提问、回答、追问与私聊记录,可完整备份或迁移。",
|
||||
"admin.deactivate": "停用我的账号",
|
||||
"admin.deactivateHint": "您随时可以选择停用您的账号。停用后,您的账号将无法登录,您的提问箱页面以及提问将无法访问,其他人也无法再给您发送新的提问。<b>该操作无法撤销!请谨慎操作!</b>",
|
||||
"admin.socialRequired": "请填写链接或取消勾选",
|
||||
|
||||
// 问题管理页
|
||||
"qlist.title": "我的提问",
|
||||
"qlist.answered": "已回答",
|
||||
"qlist.pending": "待回答",
|
||||
"qlist.private": "私密",
|
||||
"qlist.empty": "还没有提问,快把提问箱分享出去吧~",
|
||||
"qlist.action": "操作",
|
||||
"qlist.answerNow": "去回答",
|
||||
"qlist.sort": "排序",
|
||||
"qlist.status": "状态",
|
||||
"qlist.type": "类型",
|
||||
"qlist.latest": "最新",
|
||||
"qlist.oldest": "最早",
|
||||
"qlist.all": "全部",
|
||||
"qlist.public": "一般提问",
|
||||
"qlist.privateQuestion": "私密提问",
|
||||
"qlist.received": "我收到的提问",
|
||||
"qlist.myChats": "我发起的私聊",
|
||||
"qlist.statsTotal": "总提问数",
|
||||
"qlist.statsAnswered": "已回答",
|
||||
"qlist.statsUnanswered": "未回答",
|
||||
"qlist.statsPrivate": "私密提问",
|
||||
"qlist.statsPinned": "置顶",
|
||||
"qlist.statsChats": "私聊会话",
|
||||
"qlist.searchPlaceholder": "搜索问题或回答内容...",
|
||||
"qlist.searchBtn": "搜索",
|
||||
"qlist.searchClear": "清除搜索",
|
||||
"qlist.questionsTab": "提问",
|
||||
"qlist.votesTab": "投票",
|
||||
"qlist.chatsTab": "私聊",
|
||||
"qlist.receivedChats": "我收到的私聊",
|
||||
"qlist.chatEmpty": "还没有私聊,把提问箱分享出去让大家来私聊吧~",
|
||||
"qlist.pinnedShort": "置顶",
|
||||
"qlist.privateShort": "私密",
|
||||
"qlist.chatStatsTotal": "总私聊",
|
||||
"qlist.chatStatsReceived": "我收到的",
|
||||
"qlist.chatStatsInitiated": "我发起的",
|
||||
"qlist.chatStatsClosed": "已关闭",
|
||||
"qlist.unansweredShort": "待答",
|
||||
"qlist.readShort": "已读",
|
||||
"qlist.unreadShort": "未读",
|
||||
"qitem.markReadShort": "标记",
|
||||
"qlist.pinShort": "置顶",
|
||||
"qlist.unpinShort": "取消置顶",
|
||||
"qlist.myVotes": "我创建的投票",
|
||||
"qlist.loadMore": "加载更多",
|
||||
"qlist.newVote": "新建投票",
|
||||
"qlist.edit": "编辑",
|
||||
"qlist.untitledVote": "未命名投票",
|
||||
"qlist.blocked": "已拉黑",
|
||||
"qlist.unanswered": "未回答",
|
||||
|
||||
// 站点 Logo 后缀(emoji 随机动物 + 后缀组成,如 🐺 的提问箱)
|
||||
"site.logoSuffix": "的提问箱",
|
||||
"site.stats": "提问箱信息统计",
|
||||
|
||||
// 验证码弹窗
|
||||
"captcha.title": "人机验证",
|
||||
"captcha.hint": "请计算下方的算术题以验证你不是机器人",
|
||||
"captcha.refresh": "换一题",
|
||||
"captcha.submit": "验证并提交",
|
||||
"captcha.loading": "加载中...",
|
||||
"captcha.loadFailed": "加载失败",
|
||||
"captcha.loadRetry": "加载失败,请重试",
|
||||
"captcha.inputAnswer": "请输入答案",
|
||||
"captcha.wrongAnswer": "答案错误,请重新计算",
|
||||
"captcha.verifyFailed": "验证失败,请重试",
|
||||
|
||||
// 注册页补充
|
||||
"auth.register.domain.prefix": "个性域名(你的问答箱网址将会是:%s + ",
|
||||
"auth.register.domain.suffix": " + 你在下面文本框中填写的内容)",
|
||||
"auth.register.confirm": "确认密码",
|
||||
"auth.register.language": "界面语言",
|
||||
"auth.register.languageDefault": "跟随站点默认",
|
||||
"auth.register.success": "注册成功,欢迎来到 狼的提问箱!",
|
||||
|
||||
// 探索页补充
|
||||
"explore.recommend": "随机推荐",
|
||||
"explore.search": "搜索标签...",
|
||||
"explore.sortLatest": "最新加入",
|
||||
"explore.sortOldest": "最早加入",
|
||||
"explore.noTags": "无标签",
|
||||
"explore.noRecommend": "暂无推荐用户",
|
||||
"explore.hotTags": "热门标签",
|
||||
"explore.hotQuestions": "热门问题",
|
||||
"explore.noResult": "暂无符合条件的用户。",
|
||||
"explore.lazy": "这个人很懒,什么都没写~",
|
||||
"explore.prevPage": "上一页",
|
||||
"explore.nextPage": "下一页",
|
||||
"explore.loading": "加载中...",
|
||||
"explore.loadFailed": "加载失败",
|
||||
|
||||
// 提问箱密码页
|
||||
"password.title": "此提问箱需要密码",
|
||||
"password.desc": "此提问箱已设置访问密码,请输入密码查看内容",
|
||||
"password.input": "请输入访问密码",
|
||||
"password.enter": "验证进入",
|
||||
"password.submit": "确认",
|
||||
|
||||
// 问题不存在
|
||||
"qitem.notFound": "问题不存在或已被删除",
|
||||
|
||||
// 聊天页
|
||||
"chat.title": "私聊",
|
||||
"chat.input": "输入消息...",
|
||||
"chat.send": "发送",
|
||||
"chat.delete": "删除对话",
|
||||
"chat.block": "拉黑对方",
|
||||
"chat.unblock": "取消拉黑",
|
||||
"chat.empty": "还没有消息,说点什么吧~",
|
||||
"chat.withAsker": "与提问者的私聊",
|
||||
"chat.withUser": "与 %s 的私聊",
|
||||
"chat.closed": "对话已关闭",
|
||||
"chat.closedNoSend": "对话已关闭,无法发送新消息",
|
||||
"chat.remaining": "消息剩余:%d 条(共 %d 条)",
|
||||
"chat.me": "我",
|
||||
"chat.other": "对方",
|
||||
"chat.userBlocked": "该用户已被拉黑",
|
||||
"chat.addImage": "添加图片(可选,最大5MB)",
|
||||
"chat.confirm": "确认",
|
||||
"chat.confirmBlock": "确定要拉黑该用户吗?他将无法再发送私信。",
|
||||
"chat.confirmUnblock": "确定要取消屏蔽该用户吗?他将可以再次发送私信。",
|
||||
"chat.deleteConfirm": "确定要删除这条私聊对话吗?对方将无法再看到此对话,且操作不可恢复。",
|
||||
"maintenance.title": "NekoBox 维护中...",
|
||||
"maintenance.desc": "NekoBox 正在进行维护,服务暂时不可用。过一会再来吧~",
|
||||
|
||||
// 单题分享(v4)
|
||||
"qitem.shareCard": "分享此提问",
|
||||
|
||||
// 追问(v4)
|
||||
"followup.title": "追问记录",
|
||||
"followup.content": "追问:",
|
||||
"followup.answerPlaceholder": "回答这条追问...",
|
||||
"followup.answerBtn": "回答追问",
|
||||
"followup.waiting": "等待回答中...",
|
||||
"followup.askMore": "继续追问",
|
||||
"followup.remaining": "还可追问 %d / %d 次",
|
||||
"followup.contentPlaceholder": "补充你的问题...",
|
||||
"followup.waitingReply": "你的追加提问已发出,等待提问箱主人回复中...",
|
||||
"followup.submit": "提交追问",
|
||||
|
||||
// 置顶(v4)
|
||||
"qlist.pinned": "置顶中",
|
||||
"qlist.pin": "置顶",
|
||||
"qlist.unpin": "取消置顶",
|
||||
"qlist.readStatus": "阅读状态",
|
||||
"qlist.read": "已读",
|
||||
"qlist.unread": "未读",
|
||||
"qlist.statsRead": "已读",
|
||||
"qlist.statsUnread": "未读",
|
||||
|
||||
// 提问成功页(v4)
|
||||
"success.title": "提问发送成功!",
|
||||
"success.yourQuestion": "你的提问:",
|
||||
"success.emailSent": "已向你的邮箱发送了该提问的私密链接,也可以使用下方链接随时查看提问、接收回答与继续追问。",
|
||||
"success.saveLink": "<strong>请务必保存下面的私密链接!</strong>由于你没有留下邮箱,此链接(含口令 Token)是你查看提问、接收回答与继续追问的<strong>唯一凭证</strong>,丢失后将无法找回。",
|
||||
"success.privateLink": "你的提问私密链接(点击复制保存)",
|
||||
"success.copy": "复制",
|
||||
"success.copied": "已复制 ✓",
|
||||
"success.copyFailed": "复制失败",
|
||||
"success.token": "口令 Token:%s(提问人凭 Token 访问私密链接)",
|
||||
"success.viewQuestion": "查看我的提问",
|
||||
"success.backToBox": "返回%s的提问箱",
|
||||
"success.pageTitle": "提问成功 - %s",
|
||||
|
||||
// 停用页
|
||||
"deactivate.title": "账号停用",
|
||||
"deactivate.desc": "停用后你的提问箱将无法访问,且所有数据将被删除,此操作不可恢复!",
|
||||
"deactivate.confirm": "我确认要停用账号",
|
||||
"deactivate.submit": "停用账号",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
"common.back": "返回",
|
||||
|
||||
"vote.listTitle": "投票管理",
|
||||
"vote.newTitle": "新建投票",
|
||||
"vote.editTitle": "编辑投票",
|
||||
"vote.resultsTitle": "投票结果",
|
||||
"vote.statsTotal": "投票总数",
|
||||
"vote.statsPublished": "进行中",
|
||||
"vote.createNew": "新建投票",
|
||||
"vote.typeSingle": "单选",
|
||||
"vote.typeMultiple": "多选",
|
||||
"vote.typeRating": "评分",
|
||||
"vote.typeRank": "排序",
|
||||
"vote.typeForm": "表单收集",
|
||||
"vote.statusNotStarted": "未开始",
|
||||
"vote.statusDraft": "草稿",
|
||||
"vote.statusPublished": "进行中",
|
||||
"vote.statusEnded": "已结束",
|
||||
"vote.statusArchived": "已归档",
|
||||
"vote.participants": "参与",
|
||||
"vote.remainLabel": "剩余",
|
||||
"vote.perpetual": "无限期",
|
||||
"vote.durationDay": "天",
|
||||
"vote.durationHour": "小时",
|
||||
"vote.durationMinute": "分钟",
|
||||
"vote.createdAt": "创建于",
|
||||
"vote.edit": "编辑",
|
||||
"vote.results": "结果",
|
||||
"vote.preview": "预览",
|
||||
"vote.page.mustComplete": "请完成",
|
||||
"vote.page.rankHintRequired": "排名 1-N(必填)",
|
||||
"vote.page.rankHintOptional": "排名 1-N(可选,可只排部分)",
|
||||
"vote.page.previewHint": "预览模式:这是发布前的页面样例,提交已禁用",
|
||||
"vote.page.previewSubmitHint": "预览模式,发布后即可提交",
|
||||
"vote.publish": "发布",
|
||||
"vote.close": "结束",
|
||||
"vote.share": "复制链接",
|
||||
"vote.copied": "已复制",
|
||||
"vote.shareURL": "投票分享链接",
|
||||
"vote.confirmPublish": "确定发布该投票吗?发布后即生成可分享链接。",
|
||||
"vote.confirmClose": "确定结束该投票吗?结束后参与者将无法再投票。",
|
||||
"vote.confirmDelete": "确定删除该投票吗?投票数据将一并删除,不可恢复!",
|
||||
"vote.manageEntry": "管理投票",
|
||||
|
||||
"vote.manageTitle": "管理投票",
|
||||
"vote.manageDesc": "创建与管理你的匿名投票", "vote.empty": "还没有投票,点击右上角\"新建投票\"开始吧~",
|
||||
"vote.formLockedHint": "该投票已发布,仅可修改基本信息;类型与选项已锁定(避免破坏已收集的投票数据)",
|
||||
"vote.formBasic": "基本设置",
|
||||
"vote.formTitle": "投票标题",
|
||||
"vote.formTitlePlaceholder": "例如:今天吃什么?",
|
||||
"vote.formDescription": "描述 / 说明",
|
||||
"vote.formDescriptionPlaceholder": "补充说明(可选)",
|
||||
"vote.formType": "投票类型",
|
||||
"vote.formVisibility": "展示方式",
|
||||
"vote.formVisibilityPublic": "公开展示(探索页 + 提问箱页)",
|
||||
"vote.formVisibilityLinkOnly": "仅链接分享",
|
||||
"vote.formVoterScope": "投票范围",
|
||||
"vote.formVoterScopeAnyone": "任何人(含匿名)",
|
||||
"vote.formVoterScopeRegistered": "仅注册用户",
|
||||
"vote.formShowResult": "结果可见时机",
|
||||
"vote.formShowResultAfterVote": "投票后立即可见",
|
||||
"vote.formShowResultAfterEnd": "结束后可见",
|
||||
"vote.formShowResultOwnerOnly": "仅我可见",
|
||||
"vote.formStartAt": "开始时间(留空立即开始)",
|
||||
"vote.formEndAt": "结束时间(留空不限制)",
|
||||
"vote.formMaxChoices": "最多可选数量",
|
||||
"vote.formMaxVotesPerFingerprint": "每个投票每个设备限 1 次,使用同一浏览器重复提交将被拦截。",
|
||||
"vote.formMaxVotesPerAccount": "每个投票每个注册账号限 1 次,使用同一账号重复提交将被拦截。",
|
||||
"vote.formAllowCustom": "允许参与者补充自定义选项",
|
||||
"vote.formRequireCaptcha": "匿名投票需通过验证码",
|
||||
"vote.formOptions": "投票选项",
|
||||
"vote.formRatingHint": "评分题无需配置选项(1-10 分)",
|
||||
"vote.addOption": "添加选项",
|
||||
"vote.optionPlaceholder": "选项内容",
|
||||
"vote.formFields": "表单字段",
|
||||
"vote.addField": "添加字段",
|
||||
"vote.field": "字段",
|
||||
"vote.formFieldType": "字段类型",
|
||||
"vote.fieldTypeText": "单行文本",
|
||||
"vote.fieldTypeTextarea": "多行文本",
|
||||
"vote.fieldTypeSelect": "下拉选择",
|
||||
"vote.fieldTypeRadio": "单选",
|
||||
"vote.fieldTypeCheckbox": "多选",
|
||||
"vote.fieldTypeNumber": "数字",
|
||||
"vote.fieldTypeRank": "排序",
|
||||
"vote.page.selectPlaceholder": "请选择",
|
||||
"vote.page.avgNumber": "平均",
|
||||
"vote.page.fieldFilled": "已填写",
|
||||
"vote.fieldTypeRating": "评分",
|
||||
"vote.formFieldLabel": "字段标签",
|
||||
"vote.formFieldRequired": "必填",
|
||||
"vote.formFieldMaxLength": "字数上限(0 不限制)",
|
||||
"vote.formFieldOptions": "选项",
|
||||
"vote.formFieldOptionsHint": "点击下方「添加选项」逐条添加,无需用逗号分隔",
|
||||
"vote.fieldAllowCustom": "允许补充自定义选项",
|
||||
"vote.formFieldOptionsPlaceholder": "如:满意,一般,不满意",
|
||||
"vote.createDraft": "保存草稿",
|
||||
"vote.builderHintOptions": "至少需要 2 个选项",
|
||||
"vote.builderHintFields": "请至少添加 1 个字段",
|
||||
"vote.builderHintFieldOptions": "选择题/排序题请填写选项(排序题至少 2 个)",
|
||||
"vote.resultsAnswered": "回答数",
|
||||
"vote.page.rankedTimes": "被排次数",
|
||||
"vote.resultsTotal": "总参与人数",
|
||||
"vote.resultsSubmissions": "表单回复",
|
||||
"vote.resultsViewTexts": "查看文本提交",
|
||||
"vote.resultsToggleHint": "点击展开或收起文本回复明细",
|
||||
"vote.resultsOptions": "投票结果",
|
||||
"vote.resultsVotes": "票",
|
||||
"vote.export": "导出 CSV",
|
||||
"vote.resultsEmpty": "还没有人参与,快来分享吧~",
|
||||
|
||||
// 匿名投票参与页
|
||||
"vote.inlineTitle": "投票",
|
||||
"vote.page.by": "发起的投票",
|
||||
"vote.page.goVote": "去投票",
|
||||
"vote.page.notStarted": "未开始",
|
||||
"vote.page.notStartedHint": "该投票尚未开始,敬请期待~",
|
||||
"vote.page.ended": "已结束",
|
||||
"vote.page.endedHint": "该投票已结束",
|
||||
"vote.page.active": "进行中",
|
||||
"vote.page.participated": "已参与",
|
||||
"vote.page.loginRequired": "该投票仅限注册用户参与,请先登录。",
|
||||
"vote.page.anonymousHint": "该投票允许匿名参与,你的身份不会被记录(同一设备最多可参与 1 次)。",
|
||||
"vote.page.submit": "提交投票",
|
||||
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
|
||||
"vote.page.resultsTitle": "投票结果",
|
||||
"vote.page.totalVotes": "总参与",
|
||||
"vote.page.avgRating": "平均分",
|
||||
"vote.page.avgRank": "平均排名",
|
||||
"vote.page.optionColumn": "选项",
|
||||
"vote.page.ratingLabel": "请评分",
|
||||
"vote.page.ratingUnit": "分",
|
||||
"vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)",
|
||||
"vote.page.rankPlaceholder": "排名",
|
||||
"vote.page.multipleHint": "可多选",
|
||||
"vote.page.customOption": "补充选项(可选)",
|
||||
"vote.page.customPlaceholder": "填写你心中的其他选项",
|
||||
"vote.page.customOther": "其他",
|
||||
"vote.page.customFieldPlaceholder": "填写自定义选项…",
|
||||
"vote.page.customStats": "自定义补充选项",
|
||||
"vote.page.formCount": "已收到回复",
|
||||
"vote.page.ownerManage": "管理此投票(仅你可见结果)",
|
||||
|
||||
// 单份投票的定量收集
|
||||
"vote.formQuotaLimit": "收集上限(份)",
|
||||
"vote.formQuotaHint": "0 表示不限制;收集到指定份数后自动结束该投票。",
|
||||
"vote.flash.notFound": "投票不存在或已失效",
|
||||
"vote.flash.notPublished": "该投票尚未发布",
|
||||
"vote.flash.archived": "该投票已归档,无法参与",
|
||||
"vote.flash.ended": "该投票已结束",
|
||||
"vote.flash.notStarted": "该投票尚未开始",
|
||||
"vote.flash.success": "投票成功!感谢参与",
|
||||
"vote.err.noChoice": "请至少选择一个选项",
|
||||
"vote.err.tooManyChoices": "最多只能选择 %d 个选项",
|
||||
"vote.err.pickOne": "请选择一个选项",
|
||||
"vote.err.invalidOption": "存在无效的选项",
|
||||
"vote.err.optionDuplicate": "选项不能重复选择",
|
||||
"vote.err.ratingRequired": "请给出 1-10 分的评分",
|
||||
"vote.err.rankRequired": "请为每个选项填写 1-%d 的排名",
|
||||
"vote.err.rankDuplicate": "排名不能重复",
|
||||
"vote.err.fieldRequired": "请填写「%s」",
|
||||
"vote.err.fieldCustomRequired": "请填写「%s」的自定义选项",
|
||||
"vote.err.fieldCustomTooLong": "「%s」的自定义选项不能超过 %d 字",
|
||||
"vote.err.fieldInvalidOption": "「%s」的选项无效",
|
||||
"vote.err.fieldConfigError": "「%s」选项配置错误",
|
||||
"vote.err.fieldRankIncomplete": "请填写「%s」的完整排名",
|
||||
"vote.err.fieldRankRange": "「%s」排名必须是 1-%d 之间的数字",
|
||||
"vote.err.fieldRankDuplicate": "「%s」排名不能重复",
|
||||
"vote.err.fieldTooLong": "「%s」超出字数限制(最多 %d 字)",
|
||||
"vote.err.fieldNotNumber": "「%s」必须是数字",
|
||||
"vote.err.fieldRating": "「%s」请给出 1-10 分的评分",
|
||||
"vote.err.unknownType": "未知的投票类型",
|
||||
"vote.err.customTooLong": "自定义选项不能超过 %d 字",
|
||||
"vote.err.blockWords": "提交内容中包含了投票发起人设置的屏蔽词,无法提交",
|
||||
"vote.err.serialize": "提交内容序列化失败",
|
||||
"captcha.error": "验证码错误",
|
||||
"captcha.errEmpty": "验证码不能为空",
|
||||
"captcha.errFormat": "验证码格式错误,请刷新后重试",
|
||||
"captcha.errAnswerFormat": "验证码答案格式错误",
|
||||
"captcha.errExpired": "验证码已过期,请刷新后重试",
|
||||
"captcha.errService": "验证码服务异常,请稍后重试",
|
||||
"captcha.errAnswerWrong": "验证码答案错误",
|
||||
"censor.unavailable": "内容审核服务暂时不可用,请稍后再试",
|
||||
|
||||
// 功能管理:提问 / 私聊 / 投票 的开关、定时开关与定量收集
|
||||
"admin.tabAccount": "用户设置",
|
||||
"admin.featuresHint": "在「提问 / 私聊 / 投票」面板中管理各功能的开关、开放时段与收集上限;达到上限后对应功能自动关闭。",
|
||||
"admin.featuresEntry": "设置",
|
||||
"admin.featureHint.question": "关闭后访客无法提交新提问;达到收集上限后自动停止接收(含私密提问)。",
|
||||
"admin.featureHint.chat": "关闭后访客无法发起新的私聊;已有会话不受影响。",
|
||||
"admin.featureHint.vote": "关闭后提问箱页不再展示投票板块,访客也无法参与你的投票。单份投票可在编辑页单独设置收集上限。",
|
||||
"admin.featureEnabled": "开启该功能",
|
||||
"admin.featureOpenAt": "定时开放",
|
||||
"admin.featureCloseAt": "定时关闭",
|
||||
"admin.featureTimeHint": "留空表示不限制;开放时间之前、关闭时间之后该功能均不可用。",
|
||||
"admin.featureSchedule": "循环定时(每周循环)",
|
||||
"admin.featureScheduleStart": "每日开始",
|
||||
"admin.featureScheduleEnd": "每日结束",
|
||||
"admin.featureScheduleHint": "勾选每周固定开放日并设置每日时间段,按周循环生效;全部留空表示不启用。只填开放日(不填时间)表示该天全天开放;只填时间(不勾选任何日)表示每天该时段开放。",
|
||||
"admin.featureMode": "收集模式",
|
||||
"admin.featureModeAlways": "长期开放",
|
||||
"admin.featureModeWindow": "定时收集",
|
||||
"admin.featureModeRecurring": "循环定时",
|
||||
"admin.featureModeHint": "长期开放:不限时间,保持开放直到手动关闭;定时收集:一次性开放 / 关闭时间窗口;循环定时:每周固定开放日循环生效。三种模式互斥,保存时未选模式的设置将被清空。",
|
||||
"admin.featureQuotaLimit": "收集上限(条 / 份)",
|
||||
"admin.featureQuotaHint": "0 表示不限制;累计收集达到上限后自动关闭,重置计数后可开启下一轮。",
|
||||
"admin.featureQuotaCount": "已收集",
|
||||
"admin.featureQuotaUnlimited": "不限制",
|
||||
"admin.featureQuotaResetAt": "上次重置:",
|
||||
"admin.featureResetQuota": "重置计数",
|
||||
"admin.featureSave": "保存该功能设置",
|
||||
|
||||
// 功能管理:状态与提示
|
||||
"feature.name.question": "提问",
|
||||
"feature.name.chat": "私聊",
|
||||
"feature.name.vote": "投票",
|
||||
"feature.statusOpen": "进行中",
|
||||
"feature.status.disabled": "已关闭",
|
||||
"feature.status.not_started": "未开始",
|
||||
"feature.status.ended": "已结束",
|
||||
"feature.status.quota_reached": "已达上限",
|
||||
"feature.saved": "功能设置已保存",
|
||||
"feature.errTime": "%s 的时间格式不正确,请使用日期时间选择器",
|
||||
"feature.errTimeRange": "%s 的定时关闭时间必须晚于定时开放时间",
|
||||
"feature.errQuota": "%s 的收集上限需为 0 到 1000000 之间的整数",
|
||||
"feature.errInvalid": "未知的功能类型",
|
||||
"feature.quotaReset": "%s 的收集计数已重置",
|
||||
"feature.errScheduleHalf": "%s 的循环定时开始与结束时间必须同时填写",
|
||||
"feature.errScheduleTime": "%s 的循环定时时间格式不正确,请使用时间选择器",
|
||||
"feature.errScheduleRange": "%s 的循环定时结束时间必须晚于开始时间",
|
||||
|
||||
// 功能关闭时对访客的提示
|
||||
"feature.disabled.question": "提问箱主人已关闭提问功能",
|
||||
"feature.disabled.chat": "该提问箱未开启私聊功能",
|
||||
"feature.disabled.vote": "该提问箱已关闭投票功能",
|
||||
"feature.not_started.question": "提问功能尚未开放",
|
||||
"feature.not_started.chat": "私聊功能尚未开放",
|
||||
"feature.not_started.vote": "投票功能尚未开放",
|
||||
"feature.ended.question": "提问功能已结束收集",
|
||||
"feature.ended.chat": "私聊功能已结束",
|
||||
"feature.ended.vote": "投票功能已结束",
|
||||
"feature.quota_reached.question": "提问功能已达到收集上限",
|
||||
"feature.quota_reached.chat": "私聊功能已达到收集上限",
|
||||
"feature.quota_reached.vote": "投票功能已达到收集上限",
|
||||
|
||||
// 认证:访客可见的提示(P0)
|
||||
"auth.errBadCredential": "邮箱或密码错误",
|
||||
"auth.errUserNotFound": "用户不存在",
|
||||
"auth.errEmailFrequent": "邮件发送太频繁,请稍后再试",
|
||||
"auth.errEmailFailed": "邮件发送失败,请稍后再试",
|
||||
"auth.errCodeExpired": "验证码已过期",
|
||||
"auth.errPasswordChanged": "密码修改成功",
|
||||
"auth.errLoginRequired": "请先登录",
|
||||
"auth.errDuplicateEmail": "该邮箱已被注册",
|
||||
"auth.errDuplicateDomain": "该个性域名已被使用",
|
||||
|
||||
// 提问箱页:访客可见(P0)
|
||||
"question.errWrongPassword": "密码错误",
|
||||
"question.errAskPasswordWrong": "提问密码错误",
|
||||
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
|
||||
"question.errSelfChat": "不能给自己发起私聊",
|
||||
"question.errBlockedChat": "你已被对方拉黑,无法发起私聊",
|
||||
"question.errChatDailyLimit": "你今日已达发送上限(%d条),请明天再试",
|
||||
"question.errDuplicateSubmit": "检测到重复提交,请稍后再试",
|
||||
"question.errImageTooLarge": "图片文件大小不能大于 5Mb",
|
||||
"question.errUploadImageFailed": "上传图片失败",
|
||||
"question.errBlockWords": "提问内容中包含了提问箱主人设置的屏蔽词,发送失败",
|
||||
"question.errAnsweredLocked": "已回答的问题不能标记为未读",
|
||||
"question.flash.chatSent": "私聊请求已发送!你可以通过以下链接查看对话:%s",
|
||||
"question.flash.answered": "回答发布成功!",
|
||||
"question.flash.operated": "操作成功",
|
||||
"question.flash.markedRead": "已标记为已读",
|
||||
"question.flash.markedUnread": "已标记为未读",
|
||||
|
||||
// 追问:访客/箱主可见(P0)
|
||||
"followup.errNoPermission": "无权操作",
|
||||
"followup.errNotAnswered": "问题尚未被回答,暂时不能追问",
|
||||
"followup.errLimit": "每条提问最多追问 %d 次,已达上限",
|
||||
"followup.errWaitReply": "请等待主人回复上一条追问后再继续追问",
|
||||
"followup.errNotFound": "追问不存在",
|
||||
"followup.flash.sent": "追问发送成功!",
|
||||
"followup.flash.answered": "回答成功!",
|
||||
|
||||
// 后台设置:箱主可见(P1)
|
||||
"admin.flash.profileUpdated": "更新个人信息成功",
|
||||
"admin.flash.chatUpdated": "私聊设置更新成功",
|
||||
"admin.flash.pagePwdOff": "已关闭页面密码访问",
|
||||
"admin.flash.pagePwdUpdated": "页面访问密码已更新",
|
||||
"admin.flash.askPwdOff": "已关闭提问密码",
|
||||
"admin.flash.askPwdUpdated": "提问密码已更新",
|
||||
"admin.flash.tagsTooMany": "标签不能超过 5 个",
|
||||
"admin.flash.tagsUpdated": "更新标签成功",
|
||||
"admin.flash.tagTooLong": "每个标签不能超过 5 个字符:%s",
|
||||
"admin.flash.blockWordsTooMany": "屏蔽词不能超过 10 个",
|
||||
"admin.flash.blockWordTooLong": "屏蔽词长度不能超过 10 个字符:%s",
|
||||
"admin.flash.harassmentUpdated": "更新防骚扰设置成功",
|
||||
"admin.flash.blockWordsUpdated": "屏蔽词已更新",
|
||||
"admin.flash.deactivated": "您的账号已停用,感谢您使用 狼的提问箱。期待未来还能再见 👋🏻",
|
||||
"admin.export.errUser": "导出失败:获取用户信息失败",
|
||||
"admin.export.errQuestion": "导出失败:获取问题信息失败",
|
||||
|
||||
// 投票管理:箱主可见(P1)
|
||||
"vote.err.invalidID": "无效的投票 ID",
|
||||
"vote.err.notExist": "投票不存在",
|
||||
"vote.err.noPermission": "无权操作该投票",
|
||||
"vote.err.type": "未知的投票类型",
|
||||
"vote.err.titleRequired": "请填写投票标题",
|
||||
"vote.err.titleTooLong": "投票标题不能超过 100 字",
|
||||
"vote.err.visibility": "未知的可见性设置",
|
||||
"vote.err.voterScope": "未知的投票范围设置",
|
||||
"vote.err.showResult": "未知的结果展示设置",
|
||||
"vote.err.timeRange": "结束时间必须晚于开始时间",
|
||||
"vote.err.quota": "收集上限需为 0 到 1000000 之间的整数",
|
||||
"vote.err.optionsTooFew": "请至少添加 2 个选项",
|
||||
"vote.err.optionsFormat": "选项数据格式错误",
|
||||
"vote.err.optionTooLong": "单个选项内容不能超过 %d 字",
|
||||
"vote.err.maxChoicesExceed": "最多可选数量不能超过选项总数",
|
||||
"vote.err.fieldsTooFew": "请至少添加 1 个表单字段",
|
||||
"vote.err.fieldsFormat": "表单字段数据格式错误",
|
||||
"vote.err.fieldsTooMany": "表单字段不能超过 50 个",
|
||||
"vote.err.fieldType": "存在未知的表单字段类型",
|
||||
"vote.err.fieldLabelMissing": "第 %d 个字段缺少标签",
|
||||
"vote.err.fieldLabelTooLong": "第 %d 个字段的标签不能超过 100 字",
|
||||
"vote.err.fieldLabelDuplicate": "字段标签「%s」重复,请使用不同的标签",
|
||||
"vote.err.fieldMaxLength": "第 %d 个字段的字数上限需在 0-2000 之间",
|
||||
"vote.err.fieldReservedOption": "第 %d 个字段(%s)的选项内容包含保留值,请更换",
|
||||
"vote.err.fieldOptionsTooFew": "第 %d 个字段(%s)请至少填写 1 个选项",
|
||||
"vote.err.fieldRankTooFew": "第 %d 个字段(%s)排序题至少需要 2 个选项",
|
||||
"vote.err.fieldSerialize": "字段选项序列化失败",
|
||||
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
|
||||
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
|
||||
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
|
||||
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
|
||||
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
|
||||
"vote.err.publishOptionsRequired": "选项投票至少需要 2 个选项",
|
||||
"vote.err.optionUpdateFailed": "选项更新失败,请重试",
|
||||
"vote.err.fieldUpdateFailed": "表单字段更新失败,请重试",
|
||||
"vote.flash.published": "投票已发布!分享链接:%s",
|
||||
"vote.flash.updated": "投票信息已更新(进行中的投票不可修改类型与选项)",
|
||||
"vote.flash.draftSaved": "已保存到草稿",
|
||||
"vote.flash.closed": "投票已结束",
|
||||
"vote.flash.deleted": "投票已删除",
|
||||
"vote.err.timeFormat": "时间格式不正确,请使用日期时间选择器",
|
||||
"vote.err.maxChoicesLimit": "最多可选数量不能超过 20",
|
||||
"vote.err.descriptionTooLong": "投票描述不能超过 5000 字",
|
||||
"profile.err.avatarTooLarge": "头像文件太大,最大支持 2MB",
|
||||
"profile.err.backgroundTooLarge": "背景图文件太大,最大支持 2MB",
|
||||
"profile.err.newPasswordTooShort": "新密码长度不能少于 8 位",
|
||||
"profile.err.badOldPassword": "旧密码输入错误",
|
||||
"admin.export.errCreateExcel": "导出失败:创建Excel文件失败",
|
||||
"admin.export.errWriteExcel": "导出失败:写入Excel文件失败",
|
||||
|
||||
// profile 设置页 UI 重构:卡片分区 / 头像背景裁剪 / 改密强制登出
|
||||
"profile.card.basic": "基础资料",
|
||||
"profile.card.avatarBg": "头像与背景",
|
||||
"profile.card.qr": "二维码样式",
|
||||
"profile.card.password": "修改密码",
|
||||
"profile.avatarCropHint": "裁剪框已锁定为 1:1 正方形",
|
||||
"profile.backgroundCropHint": "裁剪框已锁定为 16:9 宽屏",
|
||||
"profile.cropTitle": "裁剪图片",
|
||||
"profile.cropConfirm": "确认裁剪",
|
||||
"profile.err.avatarRatio": "头像图片必须为 1:1 正方形,请裁剪后再上传",
|
||||
"profile.err.backgroundRatio": "背景图片必须为 16:9 宽高比,请裁剪后再上传",
|
||||
"profile.flash.passwordChanged": "密码修改成功,已退出登录,请使用新密码重新登录",
|
||||
"profile.pwdNote": "保存后当前会话立即失效,需使用新密码重新登录",
|
||||
"profile.pickAvatar": "更换头像",
|
||||
"profile.pickBackground": "更换背景",
|
||||
"profile.saveBasic": "保存基础资料",
|
||||
"profile.saveMedia": "保存头像与背景",
|
||||
"profile.savePassword": "确认修改密码",
|
||||
"profile.saveQR": "保存二维码样式",
|
||||
"profile.saveSocial": "保存社交链接",
|
||||
"profile.err.cropLibUnavailable": "图片裁剪组件加载失败,请刷新页面后重试",
|
||||
|
||||
// 验证码 JSON 接口:前端消费(P2)
|
||||
"captcha.json.badRequest": "请求格式错误",
|
||||
"captcha.json.idRequired": "验证码 ID 不能为空",
|
||||
"captcha.json.genFailed": "生成验证码失败",
|
||||
"chat.errDeletedByOwner": "该私聊对话已被对方删除。",
|
||||
"chat.errBlockedView": "你已被对方拉黑,无法查看该私聊",
|
||||
"chat.errEmpty": "消息不能为空",
|
||||
"chat.errTooLong": "消息内容过长,请控制在 %d 字以内",
|
||||
"chat.errConvDeletedSend": "该对话已被删除,无法发送消息",
|
||||
"chat.errBlockedSend": "你已被对方拉黑,无法发送私信",
|
||||
"chat.errBlockWords": "消息中包含了提问箱主人设置的屏蔽词,无法发送",
|
||||
"chat.errDailyLimit": "你今日已达发送上限(%d条),请明天再试",
|
||||
"chat.errLimitReached": "对话已达到上限,无法发送新消息",
|
||||
"chat.errChatClosed": "该箱主已关闭私聊功能,无法发送消息",
|
||||
"chat.errImageTooLarge": "图片大小不能超过 %dMB",
|
||||
"chat.errUploadFailed": "图片上传失败,请稍后重试",
|
||||
"chat.errNoPermission": "无权执行此操作",
|
||||
"chat.flash.sent": "消息发送成功",
|
||||
"chat.flash.deleted": "私聊对话已删除",
|
||||
"chat.flash.operated": "操作成功",
|
||||
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
|
||||
"internal.errFlash": "服务内部错误,请稍后重试。",
|
||||
"internal.errServer": "服务器内部错误",
|
||||
"home.errStats": "获取统计信息失败",
|
||||
"pixel.errPath": "非法的请求路径",
|
||||
"proxy.errNotFound": "页面不存在",
|
||||
"proxy.errInternal": "服务网关内部错误",
|
||||
"explore.errRandomUser": "获取随机用户失败",
|
||||
// 表单校验
|
||||
"form.answer.content": "回答内容不能为空",
|
||||
"form.answer.contentTooLong": "回答内容不能超过 %d 个字符",
|
||||
"form.email.required": "请填写有效的电子邮箱地址",
|
||||
"form.followup.answer": "追问回答不能为空",
|
||||
"form.followup.answerTooLong": "追问回答不能超过 %d 个字符",
|
||||
"form.followup.content": "追问内容不能为空",
|
||||
"form.followup.contentTooLong": "追问内容不能超过 %d 个字符",
|
||||
"form.login.password": "请输入密码",
|
||||
"form.profile.backgroundimagealpha": "请填写二维码背景透明度",
|
||||
"form.profile.colorHex": "请填写 7 位十六进制颜色代码(含 #)",
|
||||
"form.profile.dotscale": "请填写二维码点大小",
|
||||
"form.profile.intro": "个人介绍不能为空,且不超过 100 个字符",
|
||||
"form.profile.linkMax": "主页链接长度不能超过 30 个字符",
|
||||
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
|
||||
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
|
||||
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
|
||||
"form.question.content": "问题内容不能为空",
|
||||
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
|
||||
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
|
||||
"form.recaptcha": "请完成人机验证",
|
||||
"form.recover.newPassword": "请设置 8 至 30 位的新密码",
|
||||
"form.recover.repeatPassword": "两次输入的新密码不一致",
|
||||
"form.register.domain": "个性域名只能包含字母、数字和下划线,长度为 3 至 20 位",
|
||||
"form.register.email": "请填写有效的电子邮箱地址(不超过 100 个字符)",
|
||||
"form.register.name": "昵称不能为空,且不超过 20 个字符",
|
||||
"form.register.password": "请设置 8 至 30 位的密码",
|
||||
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
|
||||
"form.register.repeatPassword": "两次输入的密码不一致",
|
||||
}
|
||||
@@ -1,842 +0,0 @@
|
||||
// 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 i18n
|
||||
|
||||
// zhTWDict 繁體中文文案。
|
||||
var zhTWDict = map[string]string{
|
||||
// 導覽
|
||||
"nav.explore": "探索",
|
||||
"nav.mine": "我的",
|
||||
"nav.register": "註冊",
|
||||
"nav.login": "登入",
|
||||
"nav.ask": "提問",
|
||||
"nav.settings": "設定",
|
||||
"nav.lang": "語言",
|
||||
|
||||
// 通用
|
||||
"common.login": "登入",
|
||||
"common.forgot": "忘記密碼",
|
||||
"common.submit": "送出",
|
||||
"common.cancel": "取消",
|
||||
"common.save": "儲存",
|
||||
"common.close": "關閉",
|
||||
"common.delete": "刪除",
|
||||
"common.moveUp": "上移",
|
||||
"common.moveDown": "下移",
|
||||
"common.share": "分享",
|
||||
"common.verifying": "驗證中...",
|
||||
"common.submitting": "提交中...",
|
||||
"common.uploading": "上傳中...",
|
||||
"common.lang": "語言",
|
||||
"common.menu": "選單",
|
||||
|
||||
"common.pleaseWait": "請稍候...",
|
||||
"common.backHome": "返回首頁",
|
||||
"common.theme": "切換深色/淺色模式",
|
||||
|
||||
// 站點
|
||||
"site.title": "狼的提問箱 | 匿名提問箱",
|
||||
"site.description": "狼的提問箱——一個無需註冊、可匿名提問的匿名提問箱,安全地傾訴與回應。",
|
||||
"site.keywords": "匿名提問箱,提問箱,匿名提問,狼的提問箱,匿名問答,匿名提問網站",
|
||||
"site.footer.visitor": "您是本站第 %s 位訪客",
|
||||
"site.footer.pageview": "本頁面已被訪問 %s 次",
|
||||
"site.footer.runtime": "提問箱已成功存活",
|
||||
"site.footer.admin": "找站長",
|
||||
"site.footer.notice": "公告板",
|
||||
"site.footer.chat": "聊天室",
|
||||
"site.footer.drive": "盤",
|
||||
"site.footer.status": "監控板",
|
||||
"site.footer.nav": "導航頁",
|
||||
"site.maintenance": "本站正在維護中,請稍後再來~",
|
||||
|
||||
// 認證
|
||||
"auth.login.title": "使用者登入",
|
||||
"auth.login.email": "電子郵件",
|
||||
"auth.login.password": "密碼",
|
||||
"auth.login.submit": "登入",
|
||||
"auth.login.forgot": "忘記密碼",
|
||||
"auth.register.title": "使用者註冊",
|
||||
"auth.register.name": "暱稱",
|
||||
"auth.register.email": "電子郵件",
|
||||
"auth.register.password": "密碼",
|
||||
"auth.register.submit": "註冊",
|
||||
"auth.forgot.title": "忘記密碼",
|
||||
"auth.forgot.email": "電子郵件",
|
||||
"auth.forgot.submit": "傳送重設郵件",
|
||||
"auth.forgot.submitBtn": "找回密碼",
|
||||
"auth.forgot.sent": "郵件已傳送至 %s,請查收。",
|
||||
"auth.recover.title": "重設密碼",
|
||||
"auth.recover.greeting": "%s,您正在重設您的密碼:",
|
||||
"auth.recover.password": "新密碼",
|
||||
"auth.recover.confirm": "確認密碼",
|
||||
"auth.recover.submit": "重設密碼",
|
||||
|
||||
// 探索頁
|
||||
"explore.title": "探索使用者標籤",
|
||||
"explore.notice": "此處展示已開啟<span class=\"uk-text-bold\">「在首頁展示我的標籤」</span>的使用者。該功能預設關閉,若希望自己的提問箱在這裡被發現,請前往 <a href=\"/user/profile\" class=\"uk-text-bold\">個人設定</a> 手動開啟。",
|
||||
"explore.shuffle": "換一批",
|
||||
"explore.empty": "暫時沒有可展示的使用者,快去開啟吧~",
|
||||
"explore.hotVotes": "熱門投票",
|
||||
"explore.questionsTab": "提問",
|
||||
"explore.votesTab": "投票",
|
||||
"explore.votesTitle": "公開投票",
|
||||
"explore.votesDesc": "瀏覽進行中的公開投票,選擇你感興趣的主題參與。",
|
||||
"explore.randomVote": "隨機推薦",
|
||||
"explore.allVotes": "全部投票",
|
||||
"explore.noVotes": "暫時沒有可參與的公開投票。",
|
||||
|
||||
// 提問頁
|
||||
"ask.scan": "掃碼向@%s提問",
|
||||
"ask.anonymous": "誰都可以以匿名的方式提問",
|
||||
"ask.registerOnly": "僅允許註冊使用者向TA提問",
|
||||
"ask.nextOpenAt": "下次開放時間:%s",
|
||||
"ask.lastCloseAt": "上次關閉時間:%s",
|
||||
"weekday.0": "週日",
|
||||
"weekday.1": "週一",
|
||||
"weekday.2": "週二",
|
||||
"weekday.3": "週三",
|
||||
"weekday.4": "週四",
|
||||
"weekday.5": "週五",
|
||||
"weekday.6": "週六",
|
||||
"ask.pinned": "已置頂",
|
||||
"ask.share": "分享提問箱",
|
||||
"ask.placeholder": "在此處撰寫你的問題...",
|
||||
"ask.chatPlaceholder": "在此處撰寫你的私聊訊息...",
|
||||
"ask.addImage": "新增圖片",
|
||||
"ask.private": "回覆後不公開",
|
||||
"ask.notify": "接收通知",
|
||||
"ask.emailPlaceholder": "請輸入你的信箱地址",
|
||||
"ask.submit": "傳送提問",
|
||||
"ask.chat": "發起私聊",
|
||||
"ask.chatDisabled": "該提問箱未開啟私聊",
|
||||
"ask.chatLimit": "訊息上限:%d則",
|
||||
"ask.history": "@%s 以前回答過的問題",
|
||||
"ask.loginRequired": "需要登入",
|
||||
"ask.loginRequiredChat": "發起私聊需要登入帳號",
|
||||
"ask.goLogin": "去登入",
|
||||
"ask.needPassword": "此提問箱需要密碼",
|
||||
"ask.needLogin": "提問箱的主人設定了僅註冊使用者才能提問,你需要先登入才能向他提問。",
|
||||
"ask.pleaseEmail": "請填寫接收通知的信箱地址",
|
||||
"ask.generating": "正在產生分享圖…",
|
||||
"ask.genFailed": "產生分享圖失敗,請稍後重試",
|
||||
"ask.networkError": "網路錯誤",
|
||||
"ask.shareFilename": "share.png",
|
||||
|
||||
// 問題條目
|
||||
"qitem.answer": "回答",
|
||||
"qitem.delete": "刪除",
|
||||
"qitem.private": "設為私密",
|
||||
"qitem.public": "設為公開",
|
||||
"qitem.asked": "提問",
|
||||
"qitem.pending": "等待回答",
|
||||
"qitem.answeredBy": "@%s 於 %s 的回答",
|
||||
"qitem.danger": "危險!",
|
||||
"qitem.deleteConfirm": "你確定要刪除這個提問嗎?該操作不可恢復,請謹慎操作。",
|
||||
"qitem.deleteConfirmBtn": "確認刪除",
|
||||
"qitem.backToBox": "返回%s的提問箱",
|
||||
"qitem.answerTitle": "回答問題",
|
||||
"qitem.answerPlaceholder": "在此處撰寫你的回答...",
|
||||
"qitem.emailNotifyHint": "提問人留下了自己的電子郵箱,在你第一次回覆該問題後,提問人將會收到一封郵件通知。",
|
||||
"qitem.submitAnswer": "回答",
|
||||
"qitem.updateAnswer": "更新回答",
|
||||
"qitem.askMore": "再問點別的問題?",
|
||||
"qitem.markRead": "標記為已讀",
|
||||
"qitem.markUnread": "標記為未讀",
|
||||
"qitem.readLocked": "已回答,已鎖定為已讀",
|
||||
|
||||
// 個人設定頁
|
||||
"admin.profile": "個人資訊",
|
||||
"admin.lang": "介面語言",
|
||||
"admin.langHint": "選擇訪客訪問你的提問箱時顯示的語言(後台頁面跟隨此設定)",
|
||||
"admin.email": "電子郵件",
|
||||
"admin.name": "暱稱",
|
||||
"admin.nameColor": "暱稱顏色",
|
||||
"admin.oldPassword": "舊密碼",
|
||||
"admin.newPassword": "新密碼",
|
||||
"admin.notify": "新提問通知",
|
||||
"admin.personalize": "個人化設定",
|
||||
"admin.intro": "提問箱介紹",
|
||||
"admin.introColor": "提問箱介紹顏色",
|
||||
"admin.qrBgColor": "QR Code 背景色(亮色部分)",
|
||||
"admin.qrPdColor": "QR Code 探測圖形顏色(邊角方形)",
|
||||
"admin.qrFgColor": "QR Code 前景色",
|
||||
"admin.qrDotSize": "QR Code 點大小 (←小 大→)",
|
||||
"admin.qrBgAlpha": "QR Code 背景透明度 (←不透明 很透明→)",
|
||||
"admin.avatar": "個人頭像",
|
||||
"admin.background": "提問箱背景",
|
||||
"admin.qrBackground": "QR Code 背景圖",
|
||||
"admin.social": "社交資訊設定",
|
||||
"admin.harassment": "防騷擾設定",
|
||||
"admin.askLimit": "提問限制",
|
||||
"admin.blockWords": "屏蔽詞設定(Enter 或逗號分隔,最多支援 10 個屏蔽詞,每個屏蔽詞最大長度為 10)",
|
||||
"admin.chat": "私聊設定",
|
||||
"admin.chatMaxMessages": "私聊訊息條數上限",
|
||||
"admin.chatDailyLimit": "單一使用者每日私訊條數上限",
|
||||
"admin.tags": "標籤設定",
|
||||
"admin.tagsHint": "標籤(最多5個,每個最多5字,Enter 或逗號分隔)",
|
||||
"admin.tagsPlaceholder": "例如:動漫,音樂,遊戲",
|
||||
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
|
||||
"admin.tagInputDuplicate": "已存在,已忽略",
|
||||
"admin.pagePassword": "頁面訪問密碼",
|
||||
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
|
||||
"admin.askPassword": "提問密碼",
|
||||
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
|
||||
"admin.saveProfile": "儲存修改",
|
||||
"admin.updated": "更新個人資訊成功",
|
||||
"admin.logout": "登出",
|
||||
"admin.export": "匯出資料",
|
||||
|
||||
// 後台設定(補充)
|
||||
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
|
||||
"admin.newPasswordHint": "6-64 位,留空則不修改",
|
||||
"admin.notifyEmail": "郵件",
|
||||
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
|
||||
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
|
||||
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
|
||||
"admin.backgroundPlaceholder": "上傳提問箱背景",
|
||||
"admin.qrBackgroundPlaceholder": "上傳QR Code背景(顏色過深的背景會導致QR Code無法辨識)",
|
||||
"admin.qrPreviewHint": "選擇圖片後預覽將即時更新",
|
||||
"admin.preview": "即時預覽",
|
||||
"admin.themePreset": "預設主題",
|
||||
"admin.themeDefault": "預設",
|
||||
"admin.themeDark": "暗夜",
|
||||
"admin.themeOcean": "海洋",
|
||||
"admin.themeSunset": "日落",
|
||||
"admin.resetDefaults": "🔄 恢復預設值",
|
||||
"admin.previewHint": "調整僅用於預覽,最終效果點擊「儲存修改」後生效",
|
||||
"admin.socialHint": "勾選後填寫主頁地址(無需 https://),未勾選的項目將不會在前台顯示。",
|
||||
"admin.enableAcfun": "啟用 AcFun",
|
||||
"admin.enableBili": "啟用 Bilibili",
|
||||
"admin.enableWeibo": "啟用 微博",
|
||||
"admin.enableXiaohongshu": "啟用 小紅書",
|
||||
"admin.enableBlog": "啟用 個人部落格",
|
||||
"admin.registerOnly": "僅允許註冊使用者向我提問",
|
||||
"admin.updateHarassment": "更新防騷擾設定",
|
||||
"admin.globalBlockWords": "全局屏蔽詞",
|
||||
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
|
||||
"admin.updateBlockWords": "儲存屏蔽詞",
|
||||
"admin.allowChat": "允許註冊使用者向我發起私聊",
|
||||
"admin.dailyLimitHint": "0 表示不限制",
|
||||
"admin.updateChat": "更新私聊設定",
|
||||
"admin.showInHome": "在首頁展示我的標籤(允許其他使用者透過標籤搜尋到我)",
|
||||
"admin.updateTags": "更新標籤",
|
||||
"admin.updatePagePassword": "更新頁面密碼",
|
||||
"admin.pagePasswordPlaceholder": "輸入頁面訪問密碼",
|
||||
"admin.updateAskPassword": "更新提問密碼",
|
||||
"admin.askPasswordPlaceholder": "輸入提問密碼",
|
||||
"admin.account": "帳號設定",
|
||||
"admin.exportHint": "您可以匯出您在 %s 中的所有個人資料,包括你的基本資訊、收到的問題以及回答。",
|
||||
"admin.exportJSON": "導出全部資料(JSON)",
|
||||
"admin.exportJSONHint": "包含提問、回答、追問與私聊記錄,可完整備份或遷移。",
|
||||
"admin.deactivate": "停用我的帳號",
|
||||
"admin.deactivateHint": "您隨時可以選擇停用您的帳號。停用後,您的帳號將無法登入,您的提問箱頁面以及提問將無法訪問,其他人也無法再給您發送新的提問。<b>該操作無法撤銷!請謹慎操作!</b>",
|
||||
"admin.socialRequired": "請填寫連結或取消勾選",
|
||||
|
||||
// 問題管理頁
|
||||
"qlist.title": "我的提問",
|
||||
"qlist.answered": "已回答",
|
||||
"qlist.pending": "待回答",
|
||||
"qlist.private": "私密",
|
||||
"qlist.empty": "還沒有提問,快把提問箱分享出去吧~",
|
||||
"qlist.action": "操作",
|
||||
"qlist.answerNow": "去回答",
|
||||
"qlist.sort": "排序",
|
||||
"qlist.status": "狀態",
|
||||
"qlist.type": "類型",
|
||||
"qlist.latest": "最新",
|
||||
"qlist.oldest": "最早",
|
||||
"qlist.all": "全部",
|
||||
"qlist.public": "一般提問",
|
||||
"qlist.privateQuestion": "私密提問",
|
||||
"qlist.received": "我收到的提問",
|
||||
"qlist.myChats": "我發起的私聊",
|
||||
"qlist.statsTotal": "總提問數",
|
||||
"qlist.statsAnswered": "已回答",
|
||||
"qlist.statsUnanswered": "未回答",
|
||||
"qlist.statsPrivate": "私密提問",
|
||||
"qlist.statsPinned": "置頂",
|
||||
"qlist.statsChats": "私聊會話",
|
||||
"qlist.searchPlaceholder": "搜尋問題或回答內容...",
|
||||
"qlist.searchBtn": "搜尋",
|
||||
"qlist.searchClear": "清除搜尋",
|
||||
"qlist.questionsTab": "提問",
|
||||
"qlist.votesTab": "投票",
|
||||
"qlist.chatsTab": "私聊",
|
||||
"qlist.receivedChats": "我收到的私聊",
|
||||
"qlist.chatEmpty": "還沒有私聊,把提問箱分享出去讓大家來私聊吧~",
|
||||
"qlist.pinnedShort": "置頂",
|
||||
"qlist.privateShort": "私密",
|
||||
"qlist.chatStatsTotal": "總私聊",
|
||||
"qlist.chatStatsReceived": "我收到的",
|
||||
"qlist.chatStatsInitiated": "我發起的",
|
||||
"qlist.chatStatsClosed": "已關閉",
|
||||
"qlist.unansweredShort": "待答",
|
||||
"qlist.readShort": "已讀",
|
||||
"qlist.unreadShort": "未讀",
|
||||
"qitem.markReadShort": "標記",
|
||||
"qlist.pinShort": "置頂",
|
||||
"qlist.unpinShort": "取消置頂",
|
||||
"qlist.myVotes": "我建立的投票",
|
||||
"qlist.loadMore": "載入更多",
|
||||
"qlist.newVote": "新建投票",
|
||||
"qlist.edit": "編輯",
|
||||
"qlist.untitledVote": "未命名投票",
|
||||
"qlist.blocked": "已拉黑",
|
||||
"qlist.unanswered": "未回答",
|
||||
|
||||
// 站點 Logo 後綴
|
||||
"site.logoSuffix": "的提問箱",
|
||||
"site.stats": "提問箱資訊統計",
|
||||
|
||||
// 驗證碼彈窗
|
||||
"captcha.title": "人機驗證",
|
||||
"captcha.hint": "請計算下方的算術題以驗證你不是機器人",
|
||||
"captcha.refresh": "換一題",
|
||||
"captcha.submit": "驗證並送出",
|
||||
"captcha.loading": "載入中...",
|
||||
"captcha.loadFailed": "載入失敗",
|
||||
"captcha.loadRetry": "載入失敗,請重試",
|
||||
"captcha.inputAnswer": "請輸入答案",
|
||||
"captcha.wrongAnswer": "答案錯誤,請重新計算",
|
||||
"captcha.verifyFailed": "驗證失敗,請重試",
|
||||
|
||||
// 註冊頁補充
|
||||
"auth.register.domain.prefix": "個性網域(你的問答箱網址將會是:%s + ",
|
||||
"auth.register.domain.suffix": " + 你在下面文字框中填寫的內容)",
|
||||
"auth.register.confirm": "確認密碼",
|
||||
"auth.register.language": "介面語言",
|
||||
"auth.register.languageDefault": "跟隨站點預設",
|
||||
"auth.register.success": "註冊成功,歡迎來到 狼的提問箱!",
|
||||
|
||||
// 探索頁補充
|
||||
"explore.recommend": "隨機推薦",
|
||||
"explore.search": "搜尋標籤...",
|
||||
"explore.sortLatest": "最新加入",
|
||||
"explore.sortOldest": "最早加入",
|
||||
"explore.noTags": "無標籤",
|
||||
"explore.noRecommend": "暫無推薦用戶",
|
||||
"explore.hotTags": "熱門標籤",
|
||||
"explore.hotQuestions": "熱門問題",
|
||||
"explore.noResult": "暫無符合條件的用戶。",
|
||||
"explore.lazy": "這個人很懶,什麼都沒寫~",
|
||||
"explore.prevPage": "上一頁",
|
||||
"explore.nextPage": "下一頁",
|
||||
"explore.loading": "載入中...",
|
||||
"explore.loadFailed": "載入失敗",
|
||||
|
||||
// 提問箱密碼頁
|
||||
"password.title": "此提問箱需要密碼",
|
||||
"password.desc": "此提問箱已設定存取密碼,請輸入密碼查看內容",
|
||||
"password.input": "請輸入存取密碼",
|
||||
"password.enter": "驗證進入",
|
||||
"password.submit": "確認",
|
||||
|
||||
// 問題不存在
|
||||
"qitem.notFound": "問題不存在或已被刪除",
|
||||
|
||||
// 聊天頁
|
||||
"chat.title": "私聊",
|
||||
"chat.input": "輸入訊息...",
|
||||
"chat.send": "傳送",
|
||||
"chat.delete": "刪除對話",
|
||||
"chat.block": "封鎖對方",
|
||||
"chat.unblock": "取消封鎖",
|
||||
"chat.empty": "還沒有訊息,說點什麼吧~",
|
||||
"chat.withAsker": "與提問者的私聊",
|
||||
"chat.withUser": "與 %s 的私聊",
|
||||
"chat.closed": "對話已關閉",
|
||||
"chat.closedNoSend": "對話已關閉,無法發送新訊息",
|
||||
"chat.remaining": "訊息剩餘:%d 條(共 %d 條)",
|
||||
"chat.me": "我",
|
||||
"chat.other": "對方",
|
||||
"chat.userBlocked": "該使用者已被拉黑",
|
||||
"chat.addImage": "新增圖片(可選,最大5MB)",
|
||||
"chat.confirm": "確認",
|
||||
"chat.confirmBlock": "確定要拉黑該使用者嗎?他將無法再發送私訊。",
|
||||
"chat.confirmUnblock": "確定要取消封鎖該使用者嗎?他將可以再次發送私訊。",
|
||||
"chat.deleteConfirm": "確定要刪除這條私聊對話嗎?對方將無法再看到此對話,且操作不可恢復。",
|
||||
"maintenance.title": "NekoBox 維護中...",
|
||||
"maintenance.desc": "NekoBox 正在進行維護,服務暫時不可用。過一會再來吧~",
|
||||
|
||||
// 單題分享(v4)
|
||||
"qitem.shareCard": "分享此提問",
|
||||
|
||||
// 追問(v4)
|
||||
"followup.title": "追問記錄",
|
||||
"followup.content": "追問:",
|
||||
"followup.answerPlaceholder": "回答這條追問...",
|
||||
"followup.answerBtn": "回答追問",
|
||||
"followup.waiting": "等待回答中...",
|
||||
"followup.askMore": "繼續追問",
|
||||
"followup.remaining": "還可追問 %d / %d 次",
|
||||
"followup.contentPlaceholder": "補充你的問題...",
|
||||
"followup.waitingReply": "你的追加提問已發出,等待提問箱主人回覆中...",
|
||||
"followup.submit": "提交追問",
|
||||
|
||||
// 置頂(v4)
|
||||
"qlist.pinned": "置頂中",
|
||||
"qlist.pin": "置頂",
|
||||
"qlist.unpin": "取消置頂",
|
||||
"qlist.readStatus": "閱讀狀態",
|
||||
"qlist.read": "已讀",
|
||||
"qlist.unread": "未讀",
|
||||
"qlist.statsRead": "已讀",
|
||||
"qlist.statsUnread": "未讀",
|
||||
|
||||
// 提問成功頁(v4)
|
||||
"success.title": "提問發送成功!",
|
||||
"success.yourQuestion": "你的提問:",
|
||||
"success.emailSent": "已向你的信箱發送了該提問的私密連結,也可以使用下方連結隨時查看提問、接收回答與繼續追問。",
|
||||
"success.saveLink": "<strong>請務必保存下面的私密連結!</strong>由於你沒有留下信箱,此連結(含口令 Token)是你查看提問、接收回答與繼續追問的<strong>唯一憑證</strong>,遺失後將無法找回。",
|
||||
"success.privateLink": "你的提問私密連結(點擊複製保存)",
|
||||
"success.copy": "複製",
|
||||
"success.copied": "已複製 ✓",
|
||||
"success.copyFailed": "複製失敗",
|
||||
"success.token": "口令 Token:%s(提問人憑 Token 存取私密連結)",
|
||||
"success.viewQuestion": "查看我的提問",
|
||||
"success.backToBox": "返回%s的提問箱",
|
||||
"success.pageTitle": "提問成功 - %s",
|
||||
|
||||
// 停用頁
|
||||
"deactivate.title": "帳號停用",
|
||||
"deactivate.desc": "停用後你的提問箱將無法存取,且所有資料將被刪除,此操作不可恢復!",
|
||||
"deactivate.confirm": "我確認要停用帳號",
|
||||
"deactivate.submit": "停用帳號",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
"common.back": "返回",
|
||||
|
||||
"vote.listTitle": "投票管理",
|
||||
"vote.newTitle": "新建投票",
|
||||
"vote.editTitle": "編輯投票",
|
||||
"vote.resultsTitle": "投票結果",
|
||||
"vote.statsTotal": "投票總數",
|
||||
"vote.statsPublished": "進行中",
|
||||
"vote.createNew": "新建投票",
|
||||
"vote.typeSingle": "單選",
|
||||
"vote.typeMultiple": "多選",
|
||||
"vote.typeRating": "評分",
|
||||
"vote.typeRank": "排序",
|
||||
"vote.typeForm": "表單收集",
|
||||
"vote.statusNotStarted": "未開始",
|
||||
"vote.statusDraft": "草稿",
|
||||
"vote.statusPublished": "進行中",
|
||||
"vote.statusEnded": "已結束",
|
||||
"vote.statusArchived": "已歸檔",
|
||||
"vote.participants": "參與",
|
||||
"vote.remainLabel": "剩餘",
|
||||
"vote.perpetual": "無限期",
|
||||
"vote.durationDay": "天",
|
||||
"vote.durationHour": "小時",
|
||||
"vote.durationMinute": "分鐘",
|
||||
"vote.createdAt": "建立於",
|
||||
"vote.edit": "編輯",
|
||||
"vote.results": "結果",
|
||||
"vote.preview": "預覽",
|
||||
"vote.page.mustComplete": "請完成",
|
||||
"vote.page.rankHintRequired": "排名 1-N(必填)",
|
||||
"vote.page.rankHintOptional": "排名 1-N(可選,可只排部分)",
|
||||
"vote.page.previewHint": "預覽模式:這是發布前的頁面範例,提交已停用",
|
||||
"vote.page.previewSubmitHint": "預覽模式,發布後即可提交",
|
||||
"vote.publish": "發布",
|
||||
"vote.close": "結束",
|
||||
"vote.share": "複製連結",
|
||||
"vote.copied": "已複製",
|
||||
"vote.shareURL": "投票分享連結",
|
||||
"vote.confirmPublish": "確定發布該投票嗎?發布後即產生可分享連結。",
|
||||
"vote.confirmClose": "確定結束該投票嗎?結束後參與者將無法再投票。",
|
||||
"vote.confirmDelete": "確定刪除該投票嗎?投票資料將一併刪除,不可復原!",
|
||||
"vote.manageEntry": "管理投票",
|
||||
|
||||
"vote.manageTitle": "管理投票",
|
||||
"vote.manageDesc": "建立與管理你的匿名投票", "vote.empty": "還沒有投票,點擊右上角\"新建投票\"開始吧~",
|
||||
"vote.formLockedHint": "該投票已發布,僅可修改基本資訊;類型與選項已鎖定(避免破壞已收集的投票資料)",
|
||||
"vote.formBasic": "基本設定",
|
||||
"vote.formTitle": "投票標題",
|
||||
"vote.formTitlePlaceholder": "例如:今天吃什麼?",
|
||||
"vote.formDescription": "描述 / 說明",
|
||||
"vote.formDescriptionPlaceholder": "補充說明(可選)",
|
||||
"vote.formType": "投票類型",
|
||||
"vote.formVisibility": "展示方式",
|
||||
"vote.formVisibilityPublic": "公開展示(探索頁 + 提問箱頁)",
|
||||
"vote.formVisibilityLinkOnly": "僅連結分享",
|
||||
"vote.formVoterScope": "投票範圍",
|
||||
"vote.formVoterScopeAnyone": "任何人(含匿名)",
|
||||
"vote.formVoterScopeRegistered": "僅註冊用戶",
|
||||
"vote.formShowResult": "結果可見時機",
|
||||
"vote.formShowResultAfterVote": "投票後立即可見",
|
||||
"vote.formShowResultAfterEnd": "結束後可見",
|
||||
"vote.formShowResultOwnerOnly": "僅我可見",
|
||||
"vote.formStartAt": "開始時間(留空立即開始)",
|
||||
"vote.formEndAt": "結束時間(留空不限制)",
|
||||
"vote.formMaxChoices": "最多可選數量",
|
||||
"vote.formMaxVotesPerFingerprint": "每個投票每個裝置限 1 次,使用同一瀏覽器重複提交將被攔截。",
|
||||
"vote.formMaxVotesPerAccount": "每個投票每個註冊帳號限 1 次,使用同一帳號重複提交將被攔截。",
|
||||
"vote.formAllowCustom": "允許參與者補充自訂選項",
|
||||
"vote.formRequireCaptcha": "匿名投票需通過驗證碼",
|
||||
"vote.formOptions": "投票選項",
|
||||
"vote.formRatingHint": "評分題無需設定選項(1-10 分)",
|
||||
"vote.addOption": "新增選項",
|
||||
"vote.optionPlaceholder": "選項內容",
|
||||
"vote.formFields": "表單欄位",
|
||||
"vote.addField": "新增欄位",
|
||||
"vote.field": "欄位",
|
||||
"vote.formFieldType": "欄位類型",
|
||||
"vote.fieldTypeText": "單行文字",
|
||||
"vote.fieldTypeTextarea": "多行文字",
|
||||
"vote.fieldTypeSelect": "下拉選擇",
|
||||
"vote.fieldTypeRadio": "單選",
|
||||
"vote.fieldTypeCheckbox": "多選",
|
||||
"vote.fieldTypeNumber": "數字",
|
||||
"vote.fieldTypeRank": "排序",
|
||||
"vote.page.selectPlaceholder": "請選擇",
|
||||
"vote.page.avgNumber": "平均",
|
||||
"vote.page.fieldFilled": "已填寫",
|
||||
"vote.fieldTypeRating": "評分",
|
||||
"vote.formFieldLabel": "欄位標籤",
|
||||
"vote.formFieldRequired": "必填",
|
||||
"vote.formFieldMaxLength": "字數上限(0 不限制)",
|
||||
"vote.formFieldOptions": "選項",
|
||||
"vote.formFieldOptionsHint": "點擊下方「新增選項」逐條新增,不需用逗號分隔",
|
||||
"vote.fieldAllowCustom": "允許補充自訂選項",
|
||||
"vote.formFieldOptionsPlaceholder": "如:滿意,一般,不滿意",
|
||||
"vote.createDraft": "儲存草稿",
|
||||
"vote.builderHintOptions": "至少需要 2 個選項",
|
||||
"vote.builderHintFields": "請至少新增 1 個欄位",
|
||||
"vote.builderHintFieldOptions": "選擇題/排序題請填寫選項(排序題至少 2 個)",
|
||||
"vote.resultsAnswered": "回答數",
|
||||
"vote.page.rankedTimes": "被排次數",
|
||||
"vote.resultsTotal": "總參與人數",
|
||||
"vote.resultsSubmissions": "表單回覆",
|
||||
"vote.resultsViewTexts": "查看文字提交",
|
||||
"vote.resultsToggleHint": "點擊展開或收起文字回覆明細",
|
||||
"vote.resultsOptions": "投票結果",
|
||||
"vote.resultsVotes": "票",
|
||||
"vote.export": "匯出 CSV",
|
||||
"vote.resultsEmpty": "還沒有人參與,快來分享吧~",
|
||||
|
||||
// 匿名投票參與頁
|
||||
"vote.inlineTitle": "投票",
|
||||
"vote.page.by": "發起的投票",
|
||||
"vote.page.goVote": "去投票",
|
||||
"vote.page.notStarted": "未開始",
|
||||
"vote.page.notStartedHint": "該投票尚未開始,敬請期待~",
|
||||
"vote.page.ended": "已結束",
|
||||
"vote.page.endedHint": "該投票已結束",
|
||||
"vote.page.active": "進行中",
|
||||
"vote.page.participated": "已參與",
|
||||
"vote.page.loginRequired": "該投票僅限註冊用戶參與,請先登入。",
|
||||
"vote.page.anonymousHint": "該投票允許匿名參與,你的身分不會被記錄(同一裝置最多可參與 1 次)。",
|
||||
"vote.page.submit": "提交投票",
|
||||
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
|
||||
"vote.page.resultsTitle": "投票結果",
|
||||
"vote.page.totalVotes": "總參與",
|
||||
"vote.page.avgRating": "平均分",
|
||||
"vote.page.avgRank": "平均排名",
|
||||
"vote.page.optionColumn": "選項",
|
||||
"vote.page.ratingLabel": "請評分",
|
||||
"vote.page.ratingUnit": "分",
|
||||
"vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)",
|
||||
"vote.page.rankPlaceholder": "排名",
|
||||
"vote.page.multipleHint": "可多選",
|
||||
"vote.page.customOption": "補充選項(可選)",
|
||||
"vote.page.customPlaceholder": "填寫你心中的其他選項",
|
||||
"vote.page.customOther": "其他",
|
||||
"vote.page.customFieldPlaceholder": "填寫自訂選項…",
|
||||
"vote.page.customStats": "自訂補充選項",
|
||||
"vote.page.formCount": "已收到回覆",
|
||||
"vote.page.ownerManage": "管理此投票(僅你可見結果)",
|
||||
|
||||
// 單份投票的定量收集
|
||||
"vote.formQuotaLimit": "收集上限(份)",
|
||||
"vote.formQuotaHint": "0 表示不限制;收集到指定份數後自動結束該投票。",
|
||||
"vote.flash.notFound": "該投票不存在或已失效",
|
||||
"vote.flash.notPublished": "該投票尚未發布",
|
||||
"vote.flash.archived": "該投票已歸檔,無法參與",
|
||||
"vote.flash.ended": "該投票已結束",
|
||||
"vote.flash.notStarted": "該投票尚未開始",
|
||||
"vote.flash.success": "投票成功!感謝參與",
|
||||
"vote.err.noChoice": "請至少選擇一個選項",
|
||||
"vote.err.tooManyChoices": "最多只能選擇 %d 個選項",
|
||||
"vote.err.pickOne": "請選擇一個選項",
|
||||
"vote.err.invalidOption": "存在無效的選項",
|
||||
"vote.err.optionDuplicate": "選項不能重複選擇",
|
||||
"vote.err.ratingRequired": "請給出 1-10 分的評分",
|
||||
"vote.err.rankRequired": "請為每個選項填寫 1-%d 的排名",
|
||||
"vote.err.rankDuplicate": "排名不能重複",
|
||||
"vote.err.fieldRequired": "請填寫「%s」",
|
||||
"vote.err.fieldCustomRequired": "請填寫「%s」的自訂選項",
|
||||
"vote.err.fieldCustomTooLong": "「%s」的自訂選項不能超過 %d 字",
|
||||
"vote.err.fieldInvalidOption": "「%s」的選項無效",
|
||||
"vote.err.fieldConfigError": "「%s」選項配置錯誤",
|
||||
"vote.err.fieldRankIncomplete": "請填寫「%s」的完整排名",
|
||||
"vote.err.fieldRankRange": "「%s」排名必須是 1-%d 之間的數字",
|
||||
"vote.err.fieldRankDuplicate": "「%s」排名不能重複",
|
||||
"vote.err.fieldTooLong": "「%s」超出字數限制(最多 %d 字)",
|
||||
"vote.err.fieldNotNumber": "「%s」必須是數字",
|
||||
"vote.err.fieldRating": "「%s」請給出 1-10 分的評分",
|
||||
"vote.err.unknownType": "未知的投票類型",
|
||||
"vote.err.customTooLong": "自訂選項不能超過 %d 字",
|
||||
"vote.err.blockWords": "提交內容中包含了投票發起人設定的屏蔽詞,無法提交",
|
||||
"vote.err.serialize": "提交內容序列化失敗",
|
||||
"captcha.error": "驗證碼錯誤",
|
||||
"captcha.errEmpty": "驗證碼不能為空",
|
||||
"captcha.errFormat": "驗證碼格式錯誤,請重新整理後重試",
|
||||
"captcha.errAnswerFormat": "驗證碼答案格式錯誤",
|
||||
"captcha.errExpired": "驗證碼已過期,請重新整理後重試",
|
||||
"captcha.errService": "驗證碼服務異常,請稍後再試",
|
||||
"captcha.errAnswerWrong": "驗證碼答案錯誤",
|
||||
"censor.unavailable": "內容審核服務暫時不可用,請稍後再試",
|
||||
|
||||
// 功能管理:提問 / 私聊 / 投票 的開關、定時開關與定量收集
|
||||
"admin.tabAccount": "用戶設定",
|
||||
"admin.featuresHint": "在「提問 / 私聊 / 投票」面板中管理各功能的開關、開放時段與收集上限;達到上限後對應功能自動關閉。",
|
||||
"admin.featuresEntry": "設定",
|
||||
"admin.featureHint.question": "關閉後訪客無法提交新提問;達到收集上限後自動停止接收(含私密提問)。",
|
||||
"admin.featureHint.chat": "關閉後訪客無法發起新的私聊;已有會話不受影響。",
|
||||
"admin.featureHint.vote": "關閉後提問箱頁不再展示投票板塊,訪客也無法參與你的投票。單份投票可在編輯頁單獨設定收集上限。",
|
||||
"admin.featureEnabled": "啟用該功能",
|
||||
"admin.featureOpenAt": "定時開放",
|
||||
"admin.featureCloseAt": "定時關閉",
|
||||
"admin.featureTimeHint": "留空表示不限制;開放時間之前、關閉時間之後該功能均不可用。",
|
||||
"admin.featureSchedule": "循環定時(每週循環)",
|
||||
"admin.featureScheduleStart": "每日開始",
|
||||
"admin.featureScheduleEnd": "每日結束",
|
||||
"admin.featureScheduleHint": "勾選每週固定開放日並設定每日時間段,按週循環生效;全部留空表示不啟用。只填開放日(不填時間)表示該天全天開放;只填時間(不勾選任何日)表示每天該時段開放。",
|
||||
"admin.featureMode": "收集模式",
|
||||
"admin.featureModeAlways": "長期開放",
|
||||
"admin.featureModeWindow": "定時收集",
|
||||
"admin.featureModeRecurring": "循環定時",
|
||||
"admin.featureModeHint": "長期開放:不限時間,保持開放直到手動關閉;定時收集:一次性開放 / 關閉時間視窗;循環定時:每週固定開放日循環生效。三種模式互斥,儲存時未選模式的設定將被清空。",
|
||||
"admin.featureQuotaLimit": "收集上限(條 / 份)",
|
||||
"admin.featureQuotaHint": "0 表示不限制;累計收集達到上限後自動關閉,重設計數後可開啟下一輪。",
|
||||
"admin.featureQuotaCount": "已收集",
|
||||
"admin.featureQuotaUnlimited": "不限制",
|
||||
"admin.featureQuotaResetAt": "上次重設:",
|
||||
"admin.featureResetQuota": "重設計數",
|
||||
"admin.featureSave": "儲存該功能設定",
|
||||
|
||||
// 功能管理:狀態與提示
|
||||
"feature.name.question": "提問",
|
||||
"feature.name.chat": "私聊",
|
||||
"feature.name.vote": "投票",
|
||||
"feature.statusOpen": "進行中",
|
||||
"feature.status.disabled": "已關閉",
|
||||
"feature.status.not_started": "未開始",
|
||||
"feature.status.ended": "已結束",
|
||||
"feature.status.quota_reached": "已達上限",
|
||||
"feature.saved": "功能設定已儲存",
|
||||
"feature.errTime": "%s 的時間格式不正確,請使用日期時間選擇器",
|
||||
"feature.errTimeRange": "%s 的定時關閉時間必須晚於定時開放時間",
|
||||
"feature.errQuota": "%s 的收集上限需為 0 到 1000000 之間的整數",
|
||||
"feature.errInvalid": "未知的功能類型",
|
||||
"feature.quotaReset": "%s 的收集計數已重設",
|
||||
"feature.errScheduleHalf": "%s 的循環定時開始與結束時間必須同時填寫",
|
||||
"feature.errScheduleTime": "%s 的循環定時時間格式不正確,請使用時間選擇器",
|
||||
"feature.errScheduleRange": "%s 的循環定時結束時間必須晚於開始時間",
|
||||
|
||||
// 功能關閉時對訪客的提示
|
||||
"feature.disabled.question": "提問箱主人已關閉提問功能",
|
||||
"feature.disabled.chat": "該提問箱未開啟私聊功能",
|
||||
"feature.disabled.vote": "該提問箱已關閉投票功能",
|
||||
"feature.not_started.question": "提問功能尚未開放",
|
||||
"feature.not_started.chat": "私聊功能尚未開放",
|
||||
"feature.not_started.vote": "投票功能尚未開放",
|
||||
"feature.ended.question": "提問功能已結束收集",
|
||||
"feature.ended.chat": "私聊功能已結束",
|
||||
"feature.ended.vote": "投票功能已結束",
|
||||
"feature.quota_reached.question": "提問功能已達到收集上限",
|
||||
"feature.quota_reached.chat": "私聊功能已達到收集上限",
|
||||
"feature.quota_reached.vote": "投票功能已達到收集上限",
|
||||
|
||||
// 認證:訪客可見的提示(P0)
|
||||
"auth.errBadCredential": "郵箱或密碼錯誤",
|
||||
"auth.errUserNotFound": "使用者不存在",
|
||||
"auth.errEmailFrequent": "郵件發送太頻繁,請稍後再試",
|
||||
"auth.errEmailFailed": "郵件發送失敗,請稍後再試",
|
||||
"auth.errCodeExpired": "驗證碼已過期",
|
||||
"auth.errPasswordChanged": "密碼修改成功",
|
||||
"auth.errLoginRequired": "請先登入",
|
||||
"auth.errDuplicateEmail": "該郵箱已被註冊",
|
||||
"auth.errDuplicateDomain": "該個性網域已被使用",
|
||||
|
||||
// 提問箱頁:訪客可見(P0)
|
||||
"question.errWrongPassword": "密碼錯誤",
|
||||
"question.errAskPasswordWrong": "提問密碼錯誤",
|
||||
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
|
||||
"question.errSelfChat": "不能給自己發起私聊",
|
||||
"question.errBlockedChat": "你已被對方封鎖,無法發起私聊",
|
||||
"question.errChatDailyLimit": "你今日已達發送上限(%d則),請明天再試",
|
||||
"question.errDuplicateSubmit": "偵測到重複提交,請稍後再試",
|
||||
"question.errImageTooLarge": "圖片檔案大小不能大於 5Mb",
|
||||
"question.errUploadImageFailed": "上傳圖片失敗",
|
||||
"question.errBlockWords": "提問內容包含了提問箱主人設定的屏蔽詞,發送失敗",
|
||||
"question.errAnsweredLocked": "已回答的問題不能標記為未讀",
|
||||
"question.flash.chatSent": "私聊請求已發送!你可以透過以下連結查看對話:%s",
|
||||
"question.flash.answered": "回答發布成功!",
|
||||
"question.flash.operated": "操作成功",
|
||||
"question.flash.markedRead": "已標記為已讀",
|
||||
"question.flash.markedUnread": "已標記為未讀",
|
||||
|
||||
// 追問:訪客/箱主可見(P0)
|
||||
"followup.errNoPermission": "無權操作",
|
||||
"followup.errNotAnswered": "問題尚未被回答,暫時不能追問",
|
||||
"followup.errLimit": "每條提問最多追問 %d 次,已達上限",
|
||||
"followup.errWaitReply": "請等待主人回覆上一條追問後再繼續追問",
|
||||
"followup.errNotFound": "追問不存在",
|
||||
"followup.flash.sent": "追問發送成功!",
|
||||
"followup.flash.answered": "回答成功!",
|
||||
|
||||
// 後台設定:箱主可見(P1)
|
||||
"admin.flash.profileUpdated": "更新個人資訊成功",
|
||||
"admin.flash.chatUpdated": "私聊設定更新成功",
|
||||
"admin.flash.pagePwdOff": "已關閉頁面密碼存取",
|
||||
"admin.flash.pagePwdUpdated": "頁面存取密碼已更新",
|
||||
"admin.flash.askPwdOff": "已關閉提問密碼",
|
||||
"admin.flash.askPwdUpdated": "提問密碼已更新",
|
||||
"admin.flash.tagsTooMany": "標籤不能超過 5 個",
|
||||
"admin.flash.tagsUpdated": "更新標籤成功",
|
||||
"admin.flash.tagTooLong": "每個標籤不能超過 5 個字元:%s",
|
||||
"admin.flash.blockWordsTooMany": "屏蔽詞不能超過 10 個",
|
||||
"admin.flash.blockWordTooLong": "屏蔽詞長度不能超過 10 個字元:%s",
|
||||
"admin.flash.harassmentUpdated": "更新防騷擾設定成功",
|
||||
"admin.flash.blockWordsUpdated": "屏蔽詞已更新",
|
||||
"admin.flash.deactivated": "您的帳號已停用,感謝您使用 狼的提問箱。期待未來還能再見 👋🏻",
|
||||
"admin.export.errUser": "匯出失敗:獲取使用者資訊失敗",
|
||||
"admin.export.errQuestion": "匯出失敗:獲取問題資訊失敗",
|
||||
|
||||
// 投票管理:箱主可見(P1)
|
||||
"vote.err.invalidID": "無效的投票 ID",
|
||||
"vote.err.notExist": "投票不存在",
|
||||
"vote.err.noPermission": "無權操作該投票",
|
||||
"vote.err.type": "未知的投票類型",
|
||||
"vote.err.titleRequired": "請填寫投票標題",
|
||||
"vote.err.titleTooLong": "投票標題不能超過 100 字",
|
||||
"vote.err.visibility": "未知的可見性設定",
|
||||
"vote.err.voterScope": "未知的投票範圍設定",
|
||||
"vote.err.showResult": "未知的結果展示設定",
|
||||
"vote.err.timeRange": "結束時間必須晚於開始時間",
|
||||
"vote.err.quota": "收集上限需為 0 到 1000000 之間的整數",
|
||||
"vote.err.optionsTooFew": "請至少新增 2 個選項",
|
||||
"vote.err.optionsFormat": "選項資料格式錯誤",
|
||||
"vote.err.optionTooLong": "單個選項內容不能超過 %d 字",
|
||||
"vote.err.maxChoicesExceed": "最多可選數量不能超過選項總數",
|
||||
"vote.err.fieldsTooFew": "請至少新增 1 個表單欄位",
|
||||
"vote.err.fieldsFormat": "表單欄位資料格式錯誤",
|
||||
"vote.err.fieldsTooMany": "表單欄位不能超過 50 個",
|
||||
"vote.err.fieldType": "存在未知的表單欄位類型",
|
||||
"vote.err.fieldLabelMissing": "第 %d 個欄位缺少標籤",
|
||||
"vote.err.fieldLabelTooLong": "第 %d 個欄位的標籤不能超過 100 字",
|
||||
"vote.err.fieldLabelDuplicate": "欄位標籤「%s」重複,請使用不同的標籤",
|
||||
"vote.err.fieldMaxLength": "第 %d 個欄位的字數上限需在 0-2000 之間",
|
||||
"vote.err.fieldReservedOption": "第 %d 個欄位(%s)的選項內容包含保留值,請更換",
|
||||
"vote.err.fieldOptionsTooFew": "第 %d 個欄位(%s)請至少填寫 1 個選項",
|
||||
"vote.err.fieldRankTooFew": "第 %d 個欄位(%s)排序題至少需要 2 個選項",
|
||||
"vote.err.fieldSerialize": "欄位選項序列化失敗",
|
||||
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
|
||||
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
|
||||
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
|
||||
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
|
||||
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
|
||||
"vote.err.publishOptionsRequired": "選項投票至少需要 2 個選項",
|
||||
"vote.err.optionUpdateFailed": "選項更新失敗,請重試",
|
||||
"vote.err.fieldUpdateFailed": "表單欄位更新失敗,請重試",
|
||||
"vote.flash.published": "投票已發布!分享連結:%s",
|
||||
"vote.flash.updated": "投票資訊已更新(進行中的投票不可修改類型與選項)",
|
||||
"vote.flash.draftSaved": "已儲存到草稿",
|
||||
"vote.flash.closed": "投票已結束",
|
||||
"vote.flash.deleted": "投票已刪除",
|
||||
"vote.err.timeFormat": "時間格式不正確,請使用日期時間選擇器",
|
||||
"vote.err.maxChoicesLimit": "最多可選數量不能超過 20",
|
||||
"vote.err.descriptionTooLong": "投票描述不能超過 5000 字",
|
||||
"profile.err.avatarTooLarge": "頭像檔案太大,最大支援 2MB",
|
||||
"profile.err.backgroundTooLarge": "背景圖檔案太大,最大支援 2MB",
|
||||
"profile.err.newPasswordTooShort": "新密碼長度不能少於 8 位",
|
||||
"profile.err.badOldPassword": "舊密碼輸入錯誤",
|
||||
"admin.export.errCreateExcel": "匯出失敗:建立Excel檔案失敗",
|
||||
"admin.export.errWriteExcel": "匯出失敗:寫入Excel檔案失敗",
|
||||
|
||||
// profile 設定頁 UI 重構:卡片分區 / 頭像背景裁切 / 改密強制登出
|
||||
"profile.card.basic": "基本資料",
|
||||
"profile.card.avatarBg": "頭像與背景",
|
||||
"profile.card.qr": "二維碼樣式",
|
||||
"profile.card.password": "修改密碼",
|
||||
"profile.avatarCropHint": "裁切框已鎖定為 1:1 正方形",
|
||||
"profile.backgroundCropHint": "裁切框已鎖定為 16:9 寬螢幕",
|
||||
"profile.cropTitle": "裁切圖片",
|
||||
"profile.cropConfirm": "確認裁切",
|
||||
"profile.err.avatarRatio": "頭像圖片必須為 1:1 正方形,請裁切後再上傳",
|
||||
"profile.err.backgroundRatio": "背景圖片必須為 16:9 寬高比,請裁切後再上傳",
|
||||
"profile.flash.passwordChanged": "密碼修改成功,已登出,請使用新密碼重新登入",
|
||||
"profile.pwdNote": "儲存後目前工作階段立即失效,需使用新密碼重新登入",
|
||||
"profile.pickAvatar": "更換頭像",
|
||||
"profile.pickBackground": "更換背景",
|
||||
"profile.saveBasic": "儲存基本資料",
|
||||
"profile.saveMedia": "儲存頭像與背景",
|
||||
"profile.savePassword": "確認修改密碼",
|
||||
"profile.saveQR": "儲存 QR Code 樣式",
|
||||
"profile.saveSocial": "儲存社交連結",
|
||||
"profile.err.cropLibUnavailable": "圖片裁切元件載入失敗,請重新整理頁面後重試",
|
||||
|
||||
// 驗證碼 JSON 介面:前端消費(P2)
|
||||
"captcha.json.badRequest": "請求格式錯誤",
|
||||
"captcha.json.idRequired": "驗證碼 ID 不能為空",
|
||||
"captcha.json.genFailed": "生成驗證碼失敗",
|
||||
"chat.errDeletedByOwner": "該私聊對話已被對方刪除。",
|
||||
"chat.errBlockedView": "你已被對方拉黑,無法查看該私聊",
|
||||
"chat.errEmpty": "消息不能為空",
|
||||
"chat.errTooLong": "消息內容過長,請控制在 %d 字以內",
|
||||
"chat.errConvDeletedSend": "該對話已被刪除,無法發送消息",
|
||||
"chat.errBlockedSend": "你已被對方拉黑,無法發送私信",
|
||||
"chat.errBlockWords": "訊息中包含了提問箱主人設定的屏蔽詞,無法傳送",
|
||||
"chat.errDailyLimit": "你今日已達發送上限(%d條),請明天再試",
|
||||
"chat.errLimitReached": "對話已達到上限,無法發送新消息",
|
||||
"chat.errChatClosed": "該箱主已關閉私聊功能,無法發送消息",
|
||||
"chat.errImageTooLarge": "圖片大小不能超過 %dMB",
|
||||
"chat.errUploadFailed": "圖片上傳失敗,請稍後重試",
|
||||
"chat.errNoPermission": "無權執行此操作",
|
||||
"chat.flash.sent": "消息發送成功",
|
||||
"chat.flash.deleted": "私聊對話已刪除",
|
||||
"chat.flash.operated": "操作成功",
|
||||
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
|
||||
"internal.errFlash": "服務內部錯誤,請稍後重試。",
|
||||
"internal.errServer": "伺服器內部錯誤",
|
||||
"home.errStats": "獲取統計資訊失敗",
|
||||
"pixel.errPath": "非法的請求路徑",
|
||||
"proxy.errNotFound": "頁面不存在",
|
||||
"proxy.errInternal": "服務網關內部錯誤",
|
||||
"explore.errRandomUser": "獲取隨機用戶失敗",
|
||||
// 表单校验
|
||||
"form.answer.content": "回答內容不能為空",
|
||||
"form.answer.contentTooLong": "回答內容不能超過 %d 個字元",
|
||||
"form.email.required": "請填寫有效的電子郵件地址",
|
||||
"form.followup.answer": "追問回答不能為空",
|
||||
"form.followup.answerTooLong": "追問回答不能超過 %d 個字元",
|
||||
"form.followup.content": "追問內容不能為空",
|
||||
"form.followup.contentTooLong": "追問內容不能超過 %d 個字元",
|
||||
"form.login.password": "請輸入密碼",
|
||||
"form.profile.backgroundimagealpha": "請填寫 QR Code 背景透明度",
|
||||
"form.profile.colorHex": "請填寫 7 位十六進制顏色代碼(含 #)",
|
||||
"form.profile.dotscale": "請填寫 QR Code 點大小",
|
||||
"form.profile.intro": "個人介紹不能為空,且不超過 100 個字元",
|
||||
"form.profile.linkMax": "主頁連結長度不能超過 30 個字元",
|
||||
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
|
||||
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
|
||||
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
|
||||
"form.question.content": "問題內容不能為空",
|
||||
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
|
||||
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
|
||||
"form.recaptcha": "請完成人機驗證",
|
||||
"form.recover.newPassword": "請設定 8 至 30 位的新密碼",
|
||||
"form.recover.repeatPassword": "兩次輸入的新密碼不一致",
|
||||
"form.register.domain": "個性網域只能包含字母、數字與底線,長度為 3 至 20 位",
|
||||
"form.register.email": "請填寫有效的電子郵件地址(不超過 100 個字元)",
|
||||
"form.register.name": "暱稱不能為空,且不超過 20 個字元",
|
||||
"form.register.password": "請設定 8 至 30 位的密碼",
|
||||
"form.register.recaptcha": "無法人機驗證,請重新整理頁面或更換網路後重試",
|
||||
"form.register.repeatPassword": "兩次輸入的密碼不一致",
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
|
||||
{{t .LangCode "site.footer.visitor" `<span id="busuanzi_value_site_uv"><span class="tb-spinner"></span></span>` | safe}} | {{t .LangCode "site.footer.pageview" `<span id="busuanzi_value_page_pv"><span class="tb-spinner"></span></span>` | safe}}
|
||||
<br>Powered by NekoBox | {{ ICP }}<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
|
||||
<br>
|
||||
<!--<a href="/pixel">画板</a> | -->
|
||||
<a href="https://box.tama.guru/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
|
||||
| <a href="https://blog.tama.guru" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
|
||||
| <a href="https://chat.tama.guru" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
|
||||
| <a href="https://share.tama.guru" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
|
||||
| <a href="https://status.tama.guru" target="_blank">{{t .LangCode "site.footer.status"}}</a>
|
||||
| <a href="https://tama.guru" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
|
||||
</p>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
|
||||
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
|
||||
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
|
||||
// 纯内联反馈,不使用全屏遮罩。
|
||||
var SUBMIT_I18N = {
|
||||
submitting: '{{t .LangCode "common.submitting"}}',
|
||||
uploading: '{{t .LangCode "common.uploading"}}'
|
||||
};
|
||||
|
||||
function tbResetSubmitButtons() {
|
||||
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
|
||||
b.disabled = false;
|
||||
b.removeAttribute('data-tb-submitting');
|
||||
var orig = b.getAttribute('data-tb-restore');
|
||||
if (orig !== null) {
|
||||
b.innerHTML = orig;
|
||||
b.removeAttribute('data-tb-restore');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
|
||||
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
|
||||
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
|
||||
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
|
||||
form.removeAttribute('data-tb-skip-submit-feedback');
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
|
||||
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
|
||||
if (form.getAttribute('target') === '_blank') return;
|
||||
|
||||
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
|
||||
var trigger = e.submitter || form.querySelector('button[type="submit"]');
|
||||
|
||||
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
|
||||
var hasFile = false;
|
||||
form.querySelectorAll('input[type="file"]').forEach(function(f) {
|
||||
if (f.files && f.files.length > 0) hasFile = true;
|
||||
});
|
||||
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
|
||||
|
||||
if (trigger && trigger.tagName === 'BUTTON') {
|
||||
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
|
||||
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
|
||||
}
|
||||
|
||||
// 禁用表单内所有按钮,防止重复提交
|
||||
form.querySelectorAll('button').forEach(function(b) {
|
||||
b.disabled = true;
|
||||
b.setAttribute('data-tb-submitting', '1');
|
||||
});
|
||||
});
|
||||
|
||||
// bfcache 还原时复位按钮
|
||||
window.addEventListener('pageshow', tbResetSubmitButtons);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
// 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 i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDictionaryComplete 校验四种语言的字典 key 完全一致,防止漏翻译。
|
||||
func TestDictionaryComplete(t *testing.T) {
|
||||
ref := dictionary[LangZhCN]
|
||||
langs := []string{LangZhTW, LangEn, LangJa}
|
||||
|
||||
for _, lang := range langs {
|
||||
dict := dictionary[lang]
|
||||
if len(dict) != len(ref) {
|
||||
t.Errorf("%s: 词条数量 %d != 简体中文 %d", lang, len(dict), len(ref))
|
||||
}
|
||||
for key := range ref {
|
||||
if _, ok := dict[key]; !ok {
|
||||
t.Errorf("%s: 缺少词条 %q", lang, key)
|
||||
}
|
||||
}
|
||||
for key := range dict {
|
||||
if _, ok := ref[key]; !ok {
|
||||
t.Errorf("%s: 多出简体中文没有的词条 %q", lang, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": LangZhCN,
|
||||
"zh-cn": LangZhCN,
|
||||
"zh": LangZhCN,
|
||||
"zh-TW": LangZhTW,
|
||||
"zh-Hant": LangZhTW,
|
||||
"en": LangEn,
|
||||
"en-US": LangEn,
|
||||
"ja": LangJa,
|
||||
"ja-JP": LangJa,
|
||||
"fr": LangZhCN, // 未知回退
|
||||
" En ": LangEn, // 自动去除首尾空白
|
||||
"zh_cn": LangZhCN,
|
||||
"zh_tw": LangZhTW,
|
||||
"zh_hk": LangZhTW, // 香港繁体(下划线变体)
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Resolve(in); got != want {
|
||||
t.Errorf("Resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestT(t *testing.T) {
|
||||
if got := T(LangZhCN, "nav.explore"); got != "探索" {
|
||||
t.Errorf("zh-CN nav.explore = %q", got)
|
||||
}
|
||||
if got := T(LangEn, "nav.explore"); got != "Explore" {
|
||||
t.Errorf("en nav.explore = %q", got)
|
||||
}
|
||||
if got := T(LangJa, "nav.explore"); got != "探索" {
|
||||
t.Errorf("ja nav.explore = %q", got)
|
||||
}
|
||||
// 参数填充
|
||||
if got := T(LangEn, "site.footer.visitor", "42"); got != "You are visitor #42" {
|
||||
t.Errorf("en site.footer.visitor with arg = %q", got)
|
||||
}
|
||||
// 未知 key 回退返回 key 本身
|
||||
if got := T(LangEn, "no.such.key"); got != "no.such.key" {
|
||||
t.Errorf("unknown key = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"page": 1,
|
||||
"perPage": 3,
|
||||
"totalItems": 3,
|
||||
"totalPages": 1,
|
||||
"items": [
|
||||
{
|
||||
"collectionId": "tamabox",
|
||||
"collectionName": "tamabox-changelogs",
|
||||
"content": "新增:用户私信功能上线,可在「我的」页查看匿名来信。<br>\n修复:移动端问题列表间距过窄的问题。<br>\n优化:首页加载速度提升约 30%。",
|
||||
"created": "2024-03-04 21:45:55.000Z",
|
||||
"id": "none",
|
||||
"published_at": "2024-03-04 21:45:55.000Z",
|
||||
"updated": "2024-03-04 21:45:55.000Z"
|
||||
},
|
||||
{
|
||||
"collectionId": "tamabox",
|
||||
"collectionName": "tamabox-changelogs",
|
||||
"content": "修复:部分旧浏览器下时间显示异常。<br>\n新增:修改记录页(本页),将完整更新历史从首页挪出,保持首页简洁。",
|
||||
"created": "2024-02-29 00:00:55.000Z",
|
||||
"id": "none",
|
||||
"published_at": "2024-02-29 00:00:55.000Z",
|
||||
"updated": "2024-02-29 00:00:55.000Z"
|
||||
},
|
||||
{
|
||||
"collectionId": "tamabox",
|
||||
"collectionName": "tamabox-changelogs",
|
||||
"content": "项目初始化:匿名提问 / 回答的基础流程跑通。",
|
||||
"created": "2023-06-03 13:11:55.000Z",
|
||||
"id": "none",
|
||||
"published_at": "2023-06-03 13:11:55.000Z",
|
||||
"updated": "2023-06-03 13:11:55.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
||||
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
|
||||
<br>Powered by NekoBox | {{ ICP }}<br>{{t .LangCode "site.footer.runtime"}}:<span id="box_runtime"></span>
|
||||
<br>
|
||||
<!--<a href="/pixel">画板</a> | -->
|
||||
<a href="https://box.tama.guru/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
|
||||
| <a href="https://blog.tama.guru" target="_blank">{{t .LangCode "site.footer.notice"}}</a>
|
||||
| <a href="https://chat.tama.guru" target="_blank">{{t .LangCode "site.footer.chat"}}</a>
|
||||
| <a href="https://share.tama.guru" target="_blank">{{t .LangCode "site.footer.drive"}}</a>
|
||||
| <a href="https://status.tama.guru" target="_blank">{{t .LangCode "site.footer.status"}}</a>
|
||||
| <a href="https://tama.guru" target="_blank">{{t .LangCode "site.footer.nav"}}</a>
|
||||
</p>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 提交反馈(P0):点击任意表单的提交按钮后,立即把触发按钮变成「转圈 + 本地化文字」,
|
||||
// 并禁用表单内全部按钮,物理阻止在上传/网络慢时的重复点击与重复提交。
|
||||
// 浏览器前进/后退(bfcache)回到本页时自动复位,避免停留在禁用或转圈状态。
|
||||
// 纯内联反馈,不使用全屏遮罩。
|
||||
var SUBMIT_I18N = {
|
||||
submitting: '{{t .LangCode "common.submitting"}}',
|
||||
uploading: '{{t .LangCode "common.uploading"}}'
|
||||
};
|
||||
|
||||
function tbResetSubmitButtons() {
|
||||
document.querySelectorAll('button[data-tb-submitting]').forEach(function(b) {
|
||||
b.disabled = false;
|
||||
b.removeAttribute('data-tb-submitting');
|
||||
var orig = b.getAttribute('data-tb-restore');
|
||||
if (orig !== null) {
|
||||
b.innerHTML = orig;
|
||||
b.removeAttribute('data-tb-restore');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
|
||||
// 页面级提交反馈必须尊重前置校验:投票编辑页等 onsubmit 返回 false 时,
|
||||
// submit 事件仍可能继续到达此监听器;此时绝不能切换到“提交中”状态。
|
||||
if (e.defaultPrevented || form.getAttribute('data-tb-skip-submit-feedback') === '1') {
|
||||
form.removeAttribute('data-tb-skip-submit-feedback');
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载 / 新窗口类表单(target="_blank",如导出):当前页不发生导航,也没有 bfcache
|
||||
// 复位机会,进入“提交中”状态后按钮会永远卡在转圈 + 禁用。这类表单直接跳过提交反馈。
|
||||
if (form.getAttribute('target') === '_blank') return;
|
||||
|
||||
// 触发提交的按钮(programmatic submit 时 e.submitter 为 null,回退到首个 submit 按钮)
|
||||
var trigger = e.submitter || form.querySelector('button[type="submit"]');
|
||||
|
||||
// 表单含已选中的文件 → 提示“上传中”,否则“提交中”
|
||||
var hasFile = false;
|
||||
form.querySelectorAll('input[type="file"]').forEach(function(f) {
|
||||
if (f.files && f.files.length > 0) hasFile = true;
|
||||
});
|
||||
var label = hasFile ? SUBMIT_I18N.uploading : SUBMIT_I18N.submitting;
|
||||
|
||||
if (trigger && trigger.tagName === 'BUTTON') {
|
||||
trigger.setAttribute('data-tb-restore', trigger.innerHTML);
|
||||
trigger.innerHTML = '<span uk-spinner="ratio: 0.6"></span> ' + label;
|
||||
}
|
||||
|
||||
// 禁用表单内所有按钮,防止重复提交
|
||||
form.querySelectorAll('button').forEach(function(b) {
|
||||
b.disabled = true;
|
||||
b.setAttribute('data-tb-submitting', '1');
|
||||
});
|
||||
});
|
||||
|
||||
// bfcache 还原时复位按钮
|
||||
window.addEventListener('pageshow', tbResetSubmitButtons);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +0,0 @@
|
||||
// Package autoban 提供 fail2ban 风格的自动临时封禁:检测批量扫描/探测特征后
|
||||
// 临时封禁来源 IP,并可回调发送告警。纯标准库实现,便于独立测试与复用。
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 探测事件类型
|
||||
const (
|
||||
KindNone = 0 // 无探测特征
|
||||
KindSensitive = 1 // 命中敏感路径
|
||||
KindNotFound = 2 // 404 响应(未命中敏感路径)
|
||||
)
|
||||
|
||||
// 默认阈值/窗口(Options 未配置或为 0 时兜底)。
|
||||
const (
|
||||
DefaultWindowSeconds = 60
|
||||
DefaultDurationMinutes = 15
|
||||
DefaultThresholdSensitive = 10
|
||||
DefaultThreshold404 = 20
|
||||
pathSampleMax = 6
|
||||
)
|
||||
|
||||
// Options 自动封禁配置。
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
// TrackNotFound 是否把 KindNotFound(未注册路径的 404)计入封禁统计。
|
||||
// 默认 false:正常业务路径一律放行,仅敏感扫描特征路径参与计数封禁,
|
||||
// 避免共享出口下正常访问被 404 探测连坐误伤(详见 conf.Security.BanTrackNotFound)。
|
||||
TrackNotFound bool
|
||||
ThresholdSensitive int // 窗口内敏感路径命中次数
|
||||
Threshold404 int // 窗口内 404 次数
|
||||
Window time.Duration // 统计窗口
|
||||
Duration time.Duration // 封禁时长
|
||||
AlertEmail string // 告警收件人(留空不发送)
|
||||
SendAlert func(ip string, paths []string, durationMinutes int) error // 告警发送函数(nil 则不发送)
|
||||
}
|
||||
|
||||
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,前缀匹配)。
|
||||
var sensitivePaths = []string{
|
||||
"/admin", "/wp-admin", "/wp-login.php", "/wp-content", "/wordpress",
|
||||
"/administrator", "/manager", "/cpanel", "/plesk",
|
||||
"/.env", "/.git", "/.aws", "/.ssh", "/.svn", "/.hg", "/.htaccess",
|
||||
"/config", "/configuration", "/backup", "/database", "/db",
|
||||
"/actuator", "/swagger", "/api-docs", "/openapi", "/v2/api-docs",
|
||||
"/phpmyadmin", "/pma", "/mysql", "/server-status", "/server-info",
|
||||
"/shell", "/webshell", "/cmd", "/eval", "/hack", "/shell.php",
|
||||
"/login.php", "/admin.php", "/index.php", "/console", "/vendor",
|
||||
}
|
||||
|
||||
type probeEvent struct {
|
||||
at time.Time
|
||||
kind int
|
||||
}
|
||||
|
||||
type probeStat struct {
|
||||
events []probeEvent
|
||||
sensitive int
|
||||
notFound int
|
||||
paths []string // 命中路径样本(告警用)
|
||||
}
|
||||
|
||||
// Banner 自动封禁器。方法并发安全。
|
||||
type Banner struct {
|
||||
mu sync.Mutex
|
||||
|
||||
probes map[string]*probeStat
|
||||
banned map[string]time.Time
|
||||
alerted map[string]bool
|
||||
|
||||
thresholdSensitive int
|
||||
threshold404 int
|
||||
trackNotFound bool
|
||||
window time.Duration
|
||||
duration time.Duration
|
||||
alertEmail string
|
||||
sendAlert func(ip string, paths []string, durationMinutes int) error
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// New 按配置创建封禁器并注册全局清理。
|
||||
func New(opts Options) *Banner {
|
||||
window := opts.Window
|
||||
if window <= 0 {
|
||||
window = DefaultWindowSeconds * time.Second
|
||||
}
|
||||
duration := opts.Duration
|
||||
if duration <= 0 {
|
||||
duration = DefaultDurationMinutes * time.Minute
|
||||
}
|
||||
sensitive := opts.ThresholdSensitive
|
||||
if sensitive <= 0 {
|
||||
sensitive = DefaultThresholdSensitive
|
||||
}
|
||||
notFound := opts.Threshold404
|
||||
if notFound <= 0 {
|
||||
notFound = DefaultThreshold404
|
||||
}
|
||||
b := &Banner{
|
||||
probes: make(map[string]*probeStat),
|
||||
banned: make(map[string]time.Time),
|
||||
alerted: make(map[string]bool),
|
||||
thresholdSensitive: sensitive,
|
||||
threshold404: notFound,
|
||||
trackNotFound: opts.TrackNotFound,
|
||||
window: window,
|
||||
duration: duration,
|
||||
alertEmail: strings.TrimSpace(opts.AlertEmail),
|
||||
sendAlert: opts.SendAlert,
|
||||
enabled: opts.Enabled,
|
||||
}
|
||||
register(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// Record 记录一次探测事件,达到阈值时封禁 IP 并触发一次告警。
|
||||
func (b *Banner) Record(ip, path string, kind int) {
|
||||
if !b.enabled || ip == "" || kind == KindNone {
|
||||
return
|
||||
}
|
||||
// 404 计数默认关闭(正常业务路径一律放行,仅敏感扫描特征参与封禁):
|
||||
// 只有显式开启 TrackNotFound 时才把未注册路径的 404 计入统计。
|
||||
if kind == KindNotFound && !b.trackNotFound {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
st := b.probes[ip]
|
||||
if st == nil {
|
||||
st = &probeStat{}
|
||||
b.probes[ip] = st
|
||||
}
|
||||
|
||||
// 滚动清理过期事件并重算计数
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
valid = append(valid, probeEvent{at: now, kind: kind})
|
||||
if kind == KindSensitive {
|
||||
sensitive++
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
if len(st.paths) < pathSampleMax && path != "" {
|
||||
st.paths = append(st.paths, path)
|
||||
}
|
||||
|
||||
if sensitive < b.thresholdSensitive && notFound < b.threshold404 {
|
||||
return
|
||||
}
|
||||
|
||||
// 已达阈值:封禁(已在封禁中则跳过)
|
||||
if until, ok := b.banned[ip]; ok && until.After(now) {
|
||||
return
|
||||
}
|
||||
until := now.Add(b.duration)
|
||||
b.banned[ip] = until
|
||||
|
||||
// 告警(每 IP 仅一次)
|
||||
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
||||
b.alerted[ip] = true
|
||||
sample := append([]string{}, st.paths...)
|
||||
dur := int(b.duration.Minutes())
|
||||
go func() {
|
||||
_ = b.sendAlert(ip, sample, dur)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
|
||||
func (b *Banner) IsBanned(ip string) bool {
|
||||
if !b.enabled || ip == "" {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if until, ok := b.banned[ip]; ok {
|
||||
if until.After(time.Now()) {
|
||||
return true
|
||||
}
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSensitivePath 判断路径是否命中敏感探测特征(忽略大小写,完整段前缀匹配)。
|
||||
func IsSensitivePath(p string) bool {
|
||||
lower := strings.ToLower(p)
|
||||
for _, s := range sensitivePaths {
|
||||
if lower == s || strings.HasPrefix(lower, s+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- 全局注册与清理 ----------
|
||||
|
||||
var (
|
||||
bannersMu sync.Mutex
|
||||
banners = make(map[*Banner]struct{})
|
||||
bannersOnce sync.Once
|
||||
)
|
||||
|
||||
func register(b *Banner) {
|
||||
bannersMu.Lock()
|
||||
banners[b] = struct{}{}
|
||||
bannersMu.Unlock()
|
||||
|
||||
bannersOnce.Do(func() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(2 * time.Minute)
|
||||
bannersMu.Lock()
|
||||
for b := range banners {
|
||||
b.cleanup()
|
||||
}
|
||||
bannersMu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup 定期清理过期探测记录与已到期封禁(防止内存膨胀)。
|
||||
func (b *Banner) cleanup() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
for ip, st := range b.probes {
|
||||
if len(st.events) == 0 || st.events[len(st.events)-1].at.Before(cutoff) {
|
||||
delete(b.probes, ip)
|
||||
continue
|
||||
}
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
}
|
||||
for ip, until := range b.banned {
|
||||
if !until.After(now) {
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestBanner(thresholdSensitive, threshold404 int, window, duration time.Duration, alertEmail string, alertCalls *int64) *Banner {
|
||||
return New(Options{
|
||||
Enabled: true,
|
||||
TrackNotFound: true, // 测试 helper 显式开启 404 跟踪,覆盖两种计数的既有用例
|
||||
ThresholdSensitive: thresholdSensitive,
|
||||
Threshold404: threshold404,
|
||||
Window: window,
|
||||
Duration: duration,
|
||||
AlertEmail: alertEmail,
|
||||
SendAlert: func(ip string, paths []string, dur int) error {
|
||||
if alertCalls != nil {
|
||||
atomic.AddInt64(alertCalls, 1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSensitiveThreshold(t *testing.T) {
|
||||
b := newTestBanner(2, 5, time.Minute, time.Minute, "", nil)
|
||||
b.Record("1.2.3.4", "/admin", KindSensitive)
|
||||
if b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("should not be banned before reaching threshold")
|
||||
}
|
||||
b.Record("1.2.3.4", "/.env", KindSensitive)
|
||||
if !b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("expected ban after reaching sensitive threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundThreshold(t *testing.T) {
|
||||
b := newTestBanner(10, 3, time.Minute, time.Minute, "", nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
b.Record("5.6.7.8", "/random-"+string(rune('a'+i)), KindNotFound)
|
||||
}
|
||||
if !b.IsBanned("5.6.7.8") {
|
||||
t.Fatal("expected ban after reaching 404 threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundTrackDisabledByDefault(t *testing.T) {
|
||||
// 默认(TrackNotFound=false)下 404 不参与统计:正常业务路径一律放行,
|
||||
// 仅敏感扫描路径仍计数封禁——防止共享出口下正常访问被 404 探测连坐误伤。
|
||||
b := New(Options{Enabled: true, ThresholdSensitive: 1, Threshold404: 1, Window: time.Minute, Duration: time.Minute})
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Record("7.7.7.7", "/random-"+string(rune('a'+i)), KindNotFound)
|
||||
}
|
||||
if b.IsBanned("7.7.7.7") {
|
||||
t.Fatal("404 events must not accumulate when TrackNotFound is false")
|
||||
}
|
||||
b.Record("7.7.7.7", "/.env", KindSensitive)
|
||||
if !b.IsBanned("7.7.7.7") {
|
||||
t.Fatal("sensitive events must still ban even when 404 tracking is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowExpiry(t *testing.T) {
|
||||
b := newTestBanner(2, 5, 50*time.Millisecond, time.Minute, "", nil)
|
||||
b.Record("1.1.1.1", "/admin", KindSensitive)
|
||||
time.Sleep(80 * time.Millisecond) // 事件滑出窗口
|
||||
b.Record("1.1.1.1", "/.env", KindSensitive)
|
||||
if b.IsBanned("1.1.1.1") {
|
||||
t.Fatal("events outside the window must not accumulate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationExpiry(t *testing.T) {
|
||||
b := newTestBanner(1, 5, time.Minute, 60*time.Millisecond, "", nil)
|
||||
b.Record("2.2.2.2", "/admin", KindSensitive)
|
||||
if !b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("expected ban")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // 封禁到期
|
||||
if b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("ban should expire after duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertOncePerIP(t *testing.T) {
|
||||
var calls int64
|
||||
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
|
||||
b.Record("9.9.9.9", "/wp-admin", KindSensitive)
|
||||
b.Record("9.9.9.9", "/.git", KindSensitive) // 已在封禁中,不应重复告警
|
||||
b.Record("9.9.9.9", "/config", KindSensitive)
|
||||
|
||||
// sendAlert 异步调用,轮询等待最多 500ms
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for atomic.LoadInt64(&calls) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt64(&calls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 alert, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabled(t *testing.T) {
|
||||
b := New(Options{Enabled: false})
|
||||
b.Record("3.3.3.3", "/admin", KindSensitive)
|
||||
if b.IsBanned("3.3.3.3") {
|
||||
t.Fatal("disabled auto-ban must not ban")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
b := New(Options{Enabled: true}) // 全部走默认值
|
||||
if b.thresholdSensitive != DefaultThresholdSensitive || b.threshold404 != DefaultThreshold404 {
|
||||
t.Fatalf("default thresholds wrong: %d/%d", b.thresholdSensitive, b.threshold404)
|
||||
}
|
||||
if b.window != DefaultWindowSeconds*time.Second || b.duration != DefaultDurationMinutes*time.Minute {
|
||||
t.Fatalf("default window/duration wrong: %v/%v", b.window, b.duration)
|
||||
}
|
||||
if b.trackNotFound {
|
||||
t.Fatal("404 tracking must be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitivePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/admin", true},
|
||||
{"/admin/panel", true},
|
||||
{"/WP-ADMIN", true},
|
||||
{"/.env", true},
|
||||
{"/user/questions", false},
|
||||
{"/vote/abc123", false},
|
||||
{"/administratorx", false}, // 前缀必须完整段匹配,避免误伤
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsSensitivePath(c.path); got != c.want {
|
||||
t.Errorf("IsSensitivePath(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
# 2026-09-04
|
||||
|
||||
- 调研并输出 /user/profile 页面(account 面板)UI 优化评审与改造方案(等待用户批准后实施)。
|
||||
- 关键结论:
|
||||
- 密码强制登出:`users.password_version` + 会话 `pwd_ver` 机制已存在(internal/context/auth.go authenticatedUser),改密后旧会话下次请求即失效;但 UpdateProfile 改密后仍重定向 /user/profile,被 Toggle 以「请先登录」错误文案弹回 /login,成功 flash 被吞。改造方向:改密成功 → Session.Flush() + success flash(复用 auth.errPasswordChanged)→ 直接跳 /login。
|
||||
- 头像/背景裁剪:项目无任何 crop 库;第三方库全部自托管于 R2(r2.337845818.best/tamabox/cdn)。候选方案:Cropper.js 内嵌 static/(需扩 static/fs.go embed 规则)。当前 avatarPlaceholder 文案自嘲"非1:1会扁"但无强制。
|
||||
- 复用点:profile-tabs 面板、Pickr 颜色选择、easy.qrcode 预览、UIkit modal/accordion、imagecheck 像素校验、storage.UploadPictureToS3(自动转 WebP、宽>1920 缩放)。
|
||||
- i18n 四语言字典 key 全等校验(i18n_test.go TestDictionaryComplete),新增词条须同步 4 个 dict 文件。
|
||||
|
||||
## 下午:profile account 面板改造已实施(用户已批准方案)
|
||||
|
||||
- 决策:Cropper.js 走自有 R2 CDN(`lib/cropperjs/1.6.2/`,用户自行上传);改密立即登出跳登录页;卡片分区+折叠(中等重构);前后端双比例校验。
|
||||
- 改动清单:
|
||||
- `route/user/profile.go`:avatar→storage.UploadAvatarToS3、background→storage.UploadBackgroundToS3(新增),改密成功(passwordChanged 标志)→ Session.Flush + flash(profile.flash.passwordChanged) → /login;比例不符 flash 提示(profile.err.avatarRatio / backgroundRatio)。
|
||||
- `internal/imagecheck/imagecheck.go`:新增 ErrBadAspectRatio + CheckAspectRatio + DefaultAspectTolerance(0.01);`imagecheck_test.go` 新增 4 个用例。
|
||||
- `internal/storage/s3.go`:抽 uploadPictureToS3 内部分管线(aspect 可选),UploadAvatarToS3=1:1、UploadBackgroundToS3=16:9,魔数后、像素总量前校验;原有 UploadPictureToS3 兼容签名(其他调用方不变)。
|
||||
- `internal/i18n/dict_*.go`(4 语):新增 15 词条(card.* 4、cropHint 2、cropTitle/cropConfirm、pwdNote、pickAvatar/pickBackground、err.avatarRatio/backgroundRatio/cropLibUnavailable、flash.passwordChanged)。
|
||||
- `templates/user/profile.html`:account 面板重构为 8 张卡片(基础资料/头像与背景[预览+裁剪]/二维码样式折叠/社交折叠/修改密码[随主表单]/标签/页面密码/账号操作);头像 96px 圆、背景 16:9 预览;Cropper 弹窗+写回 file input 逻辑;accordion 展开时重绘二维码预览;新增 .tb-pcard 等样式与暗色适配、移动端单列。
|
||||
- 验证:go build ./...(用 dev/webpstub overlay 方式,Windows 无 libwebp)通过;go test internal/i18n + internal/imagecheck 通过;dev/checktpl 全模板语法 OK;HTML 标签开闭平衡校验通过。
|
||||
- 待办:R2 上传 cropper.min.{js,css} 到 r2.337845818.best/tamabox/cdn/lib/cropperjs/1.6.2/ 后即可端到端生效;未做浏览器实测(需本地 DB/Redis)。
|
||||
|
||||
## 晚间:修复部署环境 `UIkit is not defined`(Rocket Loader 时序)
|
||||
|
||||
- 现象:VM234:42 Uncaught ReferenceError: UIkit is not defined;更换头像/背景按钮点击无效果。
|
||||
- 根因:裁剪脚本(templates/user/profile.html 末尾)IIFE 解析期即执行 `var modalObj = UIkit.modal(modalEl)`;UIkit 在 base/header.html 是 defer 加载,Cloudflare Rocket Loader 把该内联脚本激活在 UIkit 执行前 → 抛错中断整个脚本 → `[data-pick-image]` 监听(其后)从未绑定。
|
||||
- 修复(templates/user/profile.html,4 处):
|
||||
1. `modalObj` 改为 null 懒创建(startCrop 内、依赖就绪后 `UIkit.modal(modalEl)`);
|
||||
2. 新增 `notify()`:UIkit 可用→UIkit.modal.alert,否则回退原生 window.alert;
|
||||
3. 新增 `whenReady(cb)`:轮询等待 window.UIkit && window.Cropper,超时 3s → notify(MSG_NO_CROPPER);文件大小校验前置(不依赖库);
|
||||
4. 脚本标签加 `data-cfasync="false"`,按钮监听尽早同步绑定。
|
||||
- 经验:模板内联脚本**禁止在解析期触碰 defer/外部库全局对象**(UIkit/Pickr 等),一律在用户事件回调内或依赖就绪后使用——Rocket Loader 会打乱执行序;全站内联脚本仅裁剪脚本踩中此坑。
|
||||
- 验证:node --check 通过(模板占位符替换后);R2 上 cropper 文件此前 curl 仍不可达(HTTP 000),用户需先上传,否则选图后 3s 提示「裁剪组件暂不可用」——需提醒用户。
|
||||
|
||||
## 晚间(二):图像保存 UX + 同行居中 + console 噪音排查
|
||||
|
||||
- 用户反馈:图像上传/裁剪已可用;但「保存修改」按钮在「修改密码」卡片里,改图后要去那里点击才生效,不直观;且头像修改显示在左上角别扭。
|
||||
- 改动(templates/user/profile.html):
|
||||
1. 头像与背景卡片内新增本地保存按钮(type=submit,label 新 i18n key `profile.saveMedia`,随主表单提交,裁剪写回的文件域一并上传);CSS `.tb-media-save`(居中条)。
|
||||
2. `.tb-media-row` 改 `justify-content:center`、子项 `flex:0 1 auto` + column 居中、`tb-frame-bg` 固定 320px(max-width:100%);移动端 ≤640 子项 `flex-basis:100%` 上下堆叠居中、背景图全宽、保存按钮全宽。
|
||||
3. 4 语字典新增 `profile.saveMedia`(zh-CN 保存头像与背景 / zh-TW 儲存頭像與背景 / en Save avatar & background / ja アイコンと背景を保存)。
|
||||
- console 噪音排查结论:`inspector.js` AbortError/Request timeout/responseType arraybuffer 报错**非项目代码**(全仓库 grep 无 inspector/fetch 包装);是浏览器 XHR 监控类扩展包装 fetch/XHR + Rocket Loader 激进异步调度(arraybuffer 脚本请求被中止)造成的误报。建议无痕窗口验证 + 或 Cloudflare 关闭 Rocket Loader。
|
||||
- 验证:gofmt -w 4 个 dict;go test ./internal/i18n/ 通过;go run ./dev/checktpl 全模板语法 OK。
|
||||
|
||||
## 晚间(三):二维码/社交默认展开 + 头像背景组件等宽 + 每卡片独立保存按钮
|
||||
|
||||
- 用户要求:①二维码、社交两块默认展开;②更换头像/更换背景组件大小一致(原头像 96 圆 vs 背景 320 宽预览悬殊);③为 account 面板每块加独立保存按钮(仿头像背景卡样式)。
|
||||
- 改动(templates/user/profile.html):
|
||||
1. 卡片3/4 的 uk-accordion `<li>` 加 `uk-open` 默认展开(仍可折叠,multiple:true)。
|
||||
2. `.tb-media-item` 固定等宽列 320px(flex:0 0 320 + max-width:100%),头像预览 96→120px、背景帧 width:100%(列内满宽 16:9);`.tb-media-actions .uk-button` min-width:150px 令两个更换按钮同宽。移动端:头像 80→96px、item 全宽堆叠、按钮全宽。
|
||||
3. 保存按钮:基础资料(profile.saveBasic)/头像背景(已有 saveMedia)/二维码(profile.saveQR)/社交(profile.saveSocial)/改密(原 admin.saveProfile 改 profile.savePassword「确认修改密码」)——均为同表单 type=submit(提交整表),复用居中类 `.tb-card-save`(与 .tb-media-save 同规则)。
|
||||
4. 二维码预览:默认展开后从其它 tab(?tab=question 等)切回 account 面板时不会再触发 accordion 'shown' → showPanel 里 account 时调 `window.__tbRefreshAccountPreview`(IIFE 内暴露的 debouncedUpdatePreview)重绘。
|
||||
- i18n 4 语新增 saveBasic/savePassword/saveQR/saveSocial;admin.saveProfile key 保留未删(四语仍在,仅模板不再引用)。
|
||||
- 验证:go test ./internal/i18n/ 通过;go run ./dev/checktpl OK。
|
||||
|
||||
## 晚间(四):头像/背景框等高 + 按钮统一居中 + 页面纵向压缩
|
||||
|
||||
- 用户反馈:①头像框偏小,希望与背景框显示高度一致;②按钮有的居中有的居左,要求统一并压缩空间。
|
||||
- 改动(templates/user/profile.html,纯 CSS/结构):
|
||||
1. 预览框等高:头像 120→150px 圆;背景框 267×150(16:9,与头像等高);媒体列 320→280 等宽;行间隙 20/40→14/32;移动端头像 96→120px。
|
||||
2. 按钮统一居中:账号操作卡片(登出/导出/停用)`text-align:center` + 行距 12→9px;卡片6 标签、卡片7 页面密码按钮 `div.uk-margin`→`div.tb-card-save`;提问面板 updateHarassment/updateAskPassword、私聊面板 updateChat 三个按钮同改居中。
|
||||
3. 压缩:`.tb-pcard` padding 16/16/10→13/14/8、margin-bottom 16→12、标题 margin 12→10;新增 `.profile-panel .uk-margin { margin-bottom: 12px }`(全面板字段间距 20→12)。
|
||||
- 范围说明:global-blockwords / feature-control-form 两个 partial 模板(三面板共用)内按钮未改(避免影响其它页面),如需统一再单独处理。
|
||||
- 验证:go run ./dev/checktpl OK。
|
||||
|
||||
## 晚间(五):账号操作按钮分组同行 + partial 内按钮居中
|
||||
|
||||
- 用户要求:①导出数据/导出全部数据按钮同一行;②退出登录/停用我的账号同一行;③屏蔽词设置、计数设置也居中。
|
||||
- 改动:
|
||||
1. `templates/user/profile.html`:账号操作卡重构为 `.tb-account-pair`(flex 居中,两按钮同行):导出组(默认按钮×2,target=_blank 表单)+ 说明行 `.tb-account-note`;登出组(danger 按钮+停用链接)+ 说明行。旧的 `.tb-account-action` 行式堆叠 CSS 删除,暗色规则改 `.tb-account-note`。
|
||||
2. `.feat-quota-line` 加 text-align:center;`.feat-actions` 加 justify-content:center(保存/重置计数按钮居中)——覆盖 feature-control-form(提问/私聊/投票共用)。
|
||||
3. `templates/user/global-blockwords.html`:提交按钮行 `.uk-margin` → `.tb-card-save`(居中);该 partial 仅 profile.html 引用,无外溢。
|
||||
4. 说明文字合并保留:exportHint+exportJSONHint 合并一行小字,deactivateHint(含 <b>)保留在其按钮组下方(deactivate 确认页另有完整警告文案)。
|
||||
- 验证:go run ./dev/checktpl OK。
|
||||
|
||||
## 晚间(六):修复导出按钮点击后永久「提交中」转圈
|
||||
|
||||
- 现象:导出数据/导出全部数据(target=_blank 下载表单)文件能下,但按钮永久转圈禁用。
|
||||
- 根因:`templates/base/footer.html` 全局 submit 反馈(P0 防重复提交)把触发按钮置「转圈+提交中」并禁用;复位仅靠整页导航/bfcache pageshow。`target="_blank"` 下载表单当前页不导航 → 无复位机会 → 永久卡死。
|
||||
- 修复(双保险):
|
||||
1. `templates/user/profile.html`:两个导出 form 加 `data-tb-skip-submit-feedback="1"`(footer 既有跳过通道,每次 submit 读到即跳并移除)。
|
||||
2. `templates/base/footer.html`:submit 监听里对 `form.getAttribute('target')==='_blank'` 一律 return 跳过反馈——防御未来任何下载/新窗口表单踩同一坑(全站现存 target=_blank 表单仅 profile 这两处,行为从“卡死 bug”变“无反馈”=纯修复)。
|
||||
- 经验:全站提交反馈的复位依赖导航,凡不触发本页导航的提交(target=_blank / 下载 / iframe)都必须显式跳过。
|
||||
- 验证:go run ./dev/checktpl OK。
|
||||
|
||||
## 晚间(七):私聊面板 chat 专属功能聚合单卡
|
||||
|
||||
- 用户要求:`?tab=chat` 面板里「私聊块」(功能开关/收集窗口 feature-control-form)与「私聊设置」(update-chat-settings 消息上限)聚合为一张卡;全局屏蔽词为三面板共享、保持独立。
|
||||
- 改动(templates/user/profile.html):
|
||||
1. chat 面板改两段结构:私聊专属 = `<section class="tb-pcard chat-zone">` 内含 FeatureChat partial + `<hr>` + update-chat-settings 表单(原外层 `div.uk-margin` 移除);全局屏蔽词维持原 `hr` + partial 独立区。
|
||||
2. CSS `.chat-zone`:内部 `.feat-card` 去边框透明(padding:0; border:0; background:transparent),避免卡中卡;`hr` margin 14px;内部 `legend`(admin.chat)从 uk-legend 大标题降级为 1.05rem 小节标题。
|
||||
- 提问/投票面板未动(结构同前)。
|
||||
- 验证:go run ./dev/checktpl OK。
|
||||
|
||||
## 晚间(八):功能控制卡按钮尺寸统一为常规按钮
|
||||
|
||||
- 用户要求:「保存该功能设置」按钮样式/大小改成与「更新私聊设置」一致;「重置计数」同调。
|
||||
- 改动(templates/user/feature-control-form.html,三面板共享 partial):feat-actions 内两按钮去掉 `uk-button-small` —— 保存改 `uk-button uk-button-primary`、重置改 `uk-button uk-button-default`(常规尺寸,与全站其它表单主按钮一致)。提问/私聊/投票同步生效。
|
||||
- 验证:go run ./dev/checktpl OK。
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# 2026-09-05 工作日志
|
||||
|
||||
## 提问箱公开主页「加载更多」翻页失效(内容重复)排查
|
||||
|
||||
- 用户报告:提问列表加载完后「加载更多」按钮仍在,点击后加载相同内容。示例链接 `https://box.tama.guru/_/aoi/3105?t=Kmd0mu`(item 页底部嵌有该箱「历史提问」列表 + history-template 加载更多)。
|
||||
- 代码链路:`templates/question/history-template.html`(Alpine fetch) → `route/question/page.go ListAPI`(`/api/v1/user/{domain}/questions`) → `internal/db/questions.go GetByUserID`(PinFirst 复合键集游标 `isPinned.CreatedAtUnixNano.ID`)。
|
||||
- 线上实测(box.tama.guru, aoi 箱共 38 条已答公开问题):
|
||||
- 首页 20 条 id 3109→3089;`next_cursor=0.1788566476678885000.3089`。
|
||||
- 携带任意真实 next_cursor 再次请求 → 仍返回首页同样 20 条(has_more 恒 true)→ 内容重复、无法翻页(已稳定复现)。
|
||||
- 部分异常游标返回空集/6 条等与源码 keyset 预期不符的怪异结果 → 指向线上二进制或 DB 环境与工作区源码假设不一致(时区/列精度/部署版本),单凭源码无法闭合解释。
|
||||
- 前端模板与工作区 0904 版逐字符一致(x-on:click.debounce、payload.next_cursor、注释),说明线上模板=新版;但服务端行为异常。
|
||||
- 历史:outputs/re-audit-2026-09-01.md P0-2 曾记录同款症状(前端回传旧格式游标→parsePinCursor 失败→永远第一页),当时修复为服务端下发 next_cursor;但线上当前仍复现,说明修复未生效或有残余缺陷。
|
||||
- 后续建议:生产库执行 keyset SQL 直查对照 + SHOW CREATE TABLE 确认列精度;代码侧做健壮化(游标未前进/解析失败 → 返回空 + has_more=false;前端按已见 id 集合防重)。
|
||||
|
||||
## 落地修复(用户选定:防重复兜底)
|
||||
|
||||
1. `internal/db/questions.go`:
|
||||
- `GetByUserID`:携带游标但 PinFirst 复合游标/纯 id 游标解析失败时,由「忽略游标静默回退首页」改为直接 `return []*Question{}, nil`(空列表 → 上层 has_more=false → 按钮收敛)。
|
||||
- `getBy`(GetByAskUserID 旧路径):同步改为解析失败返回空。
|
||||
2. `templates/question/history-template.html`:
|
||||
- SSR range 项与 JS append 项均加 `data-qid`;点击时先扫描 `#question-list [data-qid]` 收集已见 id,过滤重复项;无新增条目时 `more=false`(按钮隐藏/显示无更多)。
|
||||
- 注释说明重复加载 bug 的历史形态。
|
||||
3. 验证:`go build ./internal/db/...`、`go vet ./internal/db/` 通过;模板测试 `TestTemplatesParse/TestTemplateCrossReferences/TestRenderPublicQuestionList/TestRenderPublicQuestionItem` 全 PASS。全量 `go build ./...` 因第三方 `chai2010/webp`(CGO,本地缺 C 库)预存失败,与本次改动无关。
|
||||
4. 待办(未做):生产侧核对部署二进制是否含 9/1 P0-2 修复、DB 列精度/时区一致性诊断;线上复现的 keyset 怪异行为(同 nano 不同 id 返回不同页等)仍未完全由源码闭合解释。
|
||||
- 为用户产出 PG 根因诊断脚本 outputs/pg-cursor-pagination-diagnose.sql:PART A 环境/表结构/集合核对(含 created_at 列精度、索引、answered_public_all 应=38 与线上 API 对齐);PART B 用 DO+临时表纯 SQL 复刻 ListAPI 复合键集翻页循环,逐页打印 ids、带死循环/重复页检测;PART C UnixNano 指纹核对(1788566476678885000 类)用于区分部署版本旧 vs DB 环境问题。ListAPI 等价条件已核对: FilterAnswered(True)+PrivateStatus=1+PinFirst+page_size=20+1 探测。待用户执行回贴结果判读。
|
||||
- 生产库真实结构确认(用户探查): schema=nekobox(search_path 默认 public 导致首版脚本找不到表);nekobox.questions: id numeric(seq nekobox.questions_id_seq), created_at/updated_at/deleted_at = timestamp(6) WITHOUT TIME ZONE, user_id bigint, answer text, is_private/is_pinned/is_read boolean NOT NULL;nekobox.users 含 domain。线上库与源码(GORM timestamptz 假设)不一致 -> 无时区列+Go UTC 解释=线上 keyset 错位头号嫌疑。产出 v2 脚本 outputs/pg-cursor-diagnose-v2-nekobox.sql:SET search_path=nekobox; PART A 结构/集合核对(应38)+DB墙钟vs线上Z文本目视(差8h即偏移实锤); PART B DO+临时表纯墙钟 keyset 复现(锚点 timestamp 变量避免时区换算, 重复页/死循环检测); PART C nano 指纹反推命中检测(match_3089)+Z文本墙钟命中。待用户执行回贴。
|
||||
- 【根因闭合】v2 脚本线上结果: aoi_user_id=329, answered_public_all=38(与线上API一致), 无置顶行(pinned=0)。DB 墙钟==线上 JSON Z 文本(无时区偏移,会话 Asia/Shanghai 不影响无时区列直比)。PART B 纯墙钟 keyset 翻页: 第1页20条(3109..3089)=线上首页, 第2页18条(3088,2883..2411), 无重复无跳行 -> **DB 层完全自洽**。PART C: nano=1788566476678885000 反推墙钟 00:01:16.678885 精确命中 3089 (match_3089=t), Z 文本命中 3109/3089 -> 服务端 encode 正确、无时区/精度错位。结论: DB/SQL 层彻底排除,线上"游标被无视返回首页"只能用【线上部署二进制与 0904 源码不一致(解析路径差异)】解释。修复=部署含防重兜底的最新源码; 部署后需 curl 复测翻页收敛。
|
||||
- 【真根因确认+已修复】部署 0904 后线上仍复现 -> 读 parsePinCursor 源码定位: `time.Unix(0, nano)` 返回 Local 时区,进程 TZ=Asia/Shanghai 时墙钟 +8h(00:01->08:01)。生产 questions.created_at 是 timestamp WITHOUT time zone,驱动按 time.Location 墙钟直发无时区列 -> `created_at < '08:01'` 对库内全部 00:xx 行恒真 -> keyset 退化为全量 -> 永远返回首页 has_more=true -> 无限循环。本地实验 tz_proof.go 证明 08:01 vs 00:01 偏移。已修复 internal/db/questions.go parsePinCursor: time.Unix(0,nano) -> time.Unix(0,nano).UTC()(含时区约定注释)。build/vet PASS。其余 UnixNano 用途(随机/文件名)无 keyset 风险。待用户重新部署后 curl 验收翻页收敛(38条唯一)。
|
||||
- 【修复验收通过】用户重新部署后线上验证:第1页20条(新增20) has_more=true -> 第2页18条(新增18) has_more=false -> 总计38条唯一,完全收敛 ✓。非法游标(abc)/旧纯id游标(3089)均返回 {"data":[],"has_more":false} -> 解析失败收敛兜底同时生效。根因(parsePinCursor 时区偏移)与今日两处防重兜底均已上线。问题闭环。
|
||||
- 用户拟将生产库克隆到本地实验环境做 timestamptz 迁移测试。本地已建独立 PG 测试实例: 127.0.0.1:5433, user=postgres, trust 认证, data 目录 $HOME/AppData/Local/Temp/tamabox-pg-clone-test/data (PostgreSQL 18 at C:/OtherFiles/PostgreSQL/18)。等待用户 pg_dump 生产库(Fc, --schema=nekobox) 后导入跑迁移验证(epoch 快照/转换/验收/翻页复现)。注意本地另有用户自己的 5432 实例(scram 认证, 无密码勿扰)。
|
||||
- 【timestamptz 迁移已在克隆库验证通过】用户 dump(nekobox_clone_20260905.dump, 资料库 drive 节点 6oAmSRkxq7DVFAnqRt8QEv) 下载后导入本地 5433 测试实例 nekobox_clone。结果: 16 表, aoi=38 与线上一致。实际无时区时间列仅 4 表 12 列: questions/users/censor_logs/upload_images(其余表本为 timestamptz)。Asia/Shanghai 会话下迁移 ALTER TYPE timestamptz USING AT TIME ZONE 'UTC': 1.7s 完成, epoch 前后差异 0 行, 残余 without=0; 关键行指纹 3109=1788569378648502000/3089=1788566476678885000/2411=1781735419615754000 与线上一致; keyset 翻页带 +00 参数精确翻出线上第2页同款 18 条; 索引完整; 回滚路径双向验证无损。产出 outputs/timestamptz-migration-online.sql(PART0 核对/1 快照/2 迁移/3 验收4项/4 注释回滚)。克隆测试实例与 dump 暂存本地 Temp, 待用户线上执行确认后清理。
|
||||
- 【线上 timestamptz 迁移执行成功】用户线上跑 timestamptz-migration-online.sql: PART0=4表12列, PART1快照12列, PART2迁移DO完成, PART3验收全绿: 3.1 epoch差异0行, 3.2 remain_without=0, 3.3 指纹 2411=1781735419615754000/3089=1788566476678885000/3109=1788569378648502000 与克隆库/线上游标一致, 3.4 keyset第2页18条(3088,2883..2411)同款。但端到端验收受阻: box.tama.guru 持续 HTTP 521(源站不可达, 页面+API均打不开, 观察约2.5min) -> 疑似用户正在重启/部署应用或服务宕机, 待服务恢复后重跑翻页验收脚本(38条唯一收敛)确认整体闭环; 本地5433克隆实例与dump暂留待最终验收后清理。
|
||||
- 【端到端验收通过·最终闭环】服务启动后重跑线上翻页: 第1页20条 has_more=true -> 第2页18条 has_more=false -> 38条唯一收敛 ✓。注意: 迁移后 JSON created_at 文本从 "...Z"(UTC) 变为 "...+08:00"(session TZ=Asia/Shanghai) —— 绝对时刻一致。前端消费核对: history-template SSR {{Date .CreatedAt}} 迁移前显示 UTC 墙钟(少8h) 而 JS dayjs() 追加项本地化 -> 首屏与追加时间曾不一致(隐藏显示bug), 迁移后 SSR 读 timestamptz 转会话时区 -> 两者一致且显示上海本地时间(对用户更正确); item.html/list.html 同理顺带修正。无需改代码, 属显示语义修正观察项。遗留: 用户清理 diag_test 报依赖错误 -> 解答用 DROP SCHEMA diag_test CASCADE (schema 内仅 snap_before/snap_after 两张自建快照表)。本地 5433 克隆实例+dump 待清理。
|
||||
- 【发现迁移基准错误+已产修复脚本】用户反馈: 真实本地时间 00:01 的提问页面显示 08:01 -> 存量墙钟实为"上海本地时间"(驱动按进程本地墙钟写无时区列, 读出按UTC标记, 文本一致造成 v2 诊断"对称"假象), 先前迁移用 AT TIME ZONE 'UTC' 把上海墙钟误标 UTC -> 存量绝对时刻整体 +8h。克隆库验证反向修正(USING c AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Shanghai', 净-8h): 3089 08:01->00:01:16+08 (epoch 1788566476678885000->1788537676678885000), 3109->00:49:38+08, 2411->06-17 22:30+08; keyset 第2页仍18条。产出 outputs/timestamptz-migration-fix-8h.sql (PART0 聚焦4表12列/1快照/2修正/3验收含3.1显示+epoch/3.2翻页/3.3计数/3.4残余/4注释回滚=反向AT TIME ZONE 'Asia/Shanghai' AT TIME ZONE 'UTC')。克隆库全流程演练通过。待用户线上执行贴回 + 我重跑 API 验收(next_cursor nano 应从 0.1788566476678885000 变 0.1788537676678885000)。
|
||||
- 【线上反向修正验收通过】用户线上执行 timestamptz-migration-fix-8h.sql(PART2 修正完成, 后续输出被 psql 分页截断不影响), 我直接 API 验证: next_cursor nano 1788566476678885000->1788537676678885000 ✓, created_at 文本回到本地墙钟 00:49:38.648502+08:00 / 末条 00:01:16.678885+08:00 ✓, 翻页 38 唯一收敛 ✓。存量时间语义修正为"上海墙钟"。注意: 该脚本不可重跑(会再-8h)。遗留: 用户如需补看验收可跑只读 SQL; diag_test 清理可用 DROP SCHEMA diag_test CASCADE; 本地 5433 克隆实例+dump 待清理(询问用户后执行)。
|
||||
- 【留档完成】事故复盘文档 outputs/TamaBox-v4-keyset-pagination-timezone-incident-2026-09-05.md:完整记录两层缺陷(①parsePinCursor time.Unix Local +8h 致 keyset 失效②timestamptz 迁移基准误用 UTC 致存量+8h 已反向修正)、时间线、4文件代码改动、SQL 脚本产物清单(标注 v1/v2 已淘汰、online.sql 基准错误勿复用、fix-8h.sql 不可重跑)、经验教训预防清单(无时区列+带时区代码混用是土壤/迁移前须用现实锚点确认数据语义/文本一致≠语义正确)、遗留建议(页面抽查/可选 UTC 输出/清理克隆实例与dump/生产 DROP SCHEMA diag_test CASCADE)。
|
||||
|
||||
## UIkit CSS 迁移公共 CDN(速度优化落地)
|
||||
|
||||
- 背景:生产速度测试发现 R2 自托管 `uikit/3.3.3/css/modify-black-font/uikit.min.css`(263432B)加载 ~1100ms 是大头。
|
||||
- Diff 结论:自建版与官方 uikit@3.3.3(263444B,sha256 6630e078...)**仅 3 处颜色差异**——把有色容器(.uk-light/.uk-card-primary/.uk-card-secondary/.uk-overlay-primary/.uk-section-*/.uk-tile-*/.uk-offcanvas-bar)内默认白字/白标题改黑:容器文字与 table th `rgba(255,255,255,.7)→rgba(0,0,0,.7)`,h1-h6/.uk-h*/.uk-heading-* `#fff→#000`。
|
||||
- 模板实际仅用 `.uk-light`(用户背景封面,question/list.html、question-not-found.html;内部 h3/p 由 #usernamecolor/#introcolor 内联 JS 覆盖,优先级最高),其余有色容器全仓无 class 使用 → 补丁可只投影 .uk-light(477B 代替 9KB)。
|
||||
- 测速(本地国内网络,全量 263KB 3 次):cdnjs.snrat.com ~0.25s(热)最快 > jsdelivr ~0.47s > unpkg ~1.3s > cdnjs.cloudflare ~1.0-1.4s > R2 自建 1.4-2.0s。check-host.net 全球 12 节点:snrat 与 jsdelivr 均 12/12 = 200 OK(snrat 根路径 301 不影响带资源路径请求)。snrat 缓存头 max-age=31104000(360 天)。
|
||||
- 落地修改(仅 templates/base/header.html):
|
||||
1. css link → `https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css`
|
||||
2. js(uikit.min.js/uikit-icons.min.js)→ `https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/...`(CDNJS 路径与 cdnjs 官方同构,已验证文件存在 Content-Type 正确)
|
||||
3. link 后新增补丁 `<style>`(.uk-light 黑字 3 条规则 + 扩展警告注释)
|
||||
4. 头部 preconnect 增加 `https://cdnjs.snrat.com`(注释同步更新,原"全部归拢 R2"说法过时)
|
||||
- 验证:go test ./templates(TestTemplatesParse/TestTemplateCrossReferences/TestRenderPublicQuestionList 等)全 PASS;CSS 括号配对/规则数 3 条自检通过。
|
||||
- 未做(用户排除):Rocket Loader(cloudflare-static/rocket-loader.min.js 1100ms)需在 Cloudflare 后台关闭 Speed→Rocket Loader。
|
||||
- 注意:原 R2 路径 `.../expire-1-y/uikit/3.3.3/css/modify-black-font/uikit.min.css` 仍保留可访问(回滚用)。若日后引入 .uk-card-primary 等容器需黑字,须扩展补丁(参考此节差异清单)。
|
||||
|
||||
## UIkit 迁移生产/测试环境验收(对比通过)
|
||||
|
||||
- 部署后对比(真实浏览器 computed style 实测,agent-browser eval,注意本会话模型不可读图、勿再用"看截图"断言):
|
||||
- 生产 box.tama.guru/_/tamakyi(旧代码):uikitSheet=r2 modify-black-font;.uk-light 容器色 rgba(0,0,0,.7);navbar_bg rgb(248,248,248);username 内联色 rgb(0,123,255)(小狼账号自设蓝)、intro rgb(205,76,108)(自设粉)。
|
||||
- 测试 box-dev-api.337845818.best/_/admin6(新部署):uikitSheet=cdnjs.snrat.com 官方;.uk-light 容器色 rgba(0,0,0,.7)(补丁生效=modify-black-font 等价);navbar_bg rgb(248,248,248) 一致;username/intro 内联色 rgb(0,0,0)(admin6 账号默认黑,非 CSS 差异)。
|
||||
- 结论:UIkit 相关 computed style 两环境逐值一致 → 迁移无回归。用户名/简介颜色差异仅来自各账号服务端颜色字段。
|
||||
- 环境坑记录:agent-browser 冷启动需 `agent-browser install`(Chrome 152 下载 193MB,卡住原因=缺浏览器);open+eval 组合命令易超时被 auto-background,应 open 与 eval 分两条命令执行;本环境 curl 落盘/-o /dev/null 偶报 error 23 但不影响数据(可改 stdout+管道)。Chrome 位置 C:\Users\Tamakyi Gekka\.agent-browser\browsers\chrome-152.0.7977.82。
|
||||
|
||||
## uikit R2 vs snrat 国内外测速(5 次全量下载中位,2026-09-05)
|
||||
|
||||
- 国内本机:OLD-css 0.864s→NEW 0.200s(4.3x);OLD-js 0.796→NEW 0.163(4.9x);OLD-icons 0.824→NEW 0.177(4.7x)。三资源合计 2.48s→0.54s,净省 ~1.9s。
|
||||
- 国外 check-host.net 多节点(两轮):CSS p50 R2 240-358ms vs snrat 233-420ms ≈ 打平(R2 偶发长尾 max 3.3-4.8s,snrat 平稳 ≤896ms);JS p50 R2 372ms vs snrat 647ms,R2 快 ~40%。
|
||||
- 结论:snrat 国内完胜;海外 R2(Cloudflare 全球边缘)略优/打平。站点访客以国内为主 → 切 snrat 净收益大。若未来在意海外可 geo 双源(国内 snrat / 海外 R2),当前不做(过度设计)。
|
||||
- 测速脚本已归档 dev/bench-cdn.sh(cn=国内 5 次全量取中位 / global=check-host.net 海外 p50/p90),cn 模式冒烟通过(复测数据与基线一致)。
|
||||
|
||||
## 部署架构澄清(用户补充 + 实测确认)
|
||||
|
||||
- 用户架构:正式 box.tama.guru 与测试 box-dev-api.337845818.best 均经 Cloudflare Tunnel 开放;box.tama.guru 是国内走腾讯 EdgeOne 的双入口。
|
||||
- 实测(国内本机/出口视角):box.tama.guru → 43.174.247.43 Server: TencentEdgeOne(HTML 无 rocket-loader 注入,干净);box-dev-api.337845818.best & r2.337845818.best → 172.67.144.141 Server: cloudflare CF-RAY AMS(HTML 含 rocket-loader);cdnjs.snrat.com → 101.227.21.24(国内节点)。
|
||||
- 推论:Rocket Loader 只在 Cloudflare 边缘对走 CF 的请求注入 → 仅影响海外路径与测试域名;国内 EdgeOne 路径 HTML 干净(EdgeOne 回源直连源站不经 CF HTML 重写)。本机出口连 CF 落在 AMS(阿姆斯特丹)→ 本机测 R2 慢不代表真实国内用户(其可能经 CF 亚洲/香港节点),但 snrat 国内节点快是实打实。
|
||||
- 待用户确认:① 之前 1100ms rocket-loader 截图是否来自海外/测试域名或未接 EdgeOne 前 ② r2.337845818.best 是否也接 EdgeOne(未接 → 静态资源国内仍可能慢,是后续优化重点)③ CF 侧 Rocket Loader zone 开关位置。
|
||||
- 【用户澄清+盘点完成】①截图=PageSpeed(海外探测),非国内 ②r2.337845818.best 未接 EdgeOne ③DNS 分流架构:dnspod 按境内/境外解析 box.tama.guru,境内 EdgeOne/境外 Cloudflare。据此修正认知:国内不受 Rocket Loader 影响;国内真实瓶颈=R2 的 CF 国际链路。全量盘点 r2 静态资源(outputs/r2-assets-cdn-audit-2026-09-05.md):sha256 校验 6 个 JS 库(jquery/dayjs/alpine/cropperjs/vConsole/waline.js)=纯官方原版;实测 R2 每资源 0.72-1.10s vs snrat 0.15-0.42s(2-7.4×);关键认知=公共 CDN(cdnjs.cloudflare/jsdelivr 0.6-1.8s)≠快,要选 snrat 国内节点。waline-no-commits.css 为刻意定制(39 条 .wl-card display:none 隐藏评论列表,与官方选择器全同)不可直换官方。easy.qrcode=remake 定制、neko-pixel 0.0.21 npm 查无、time.box.js/图片图标=专属 → 无公共源。建议:方案 A(R2 接 EdgeOne 根治定制文件)+方案 B(A 组 5 库迁 snrat) 组合,待用户批准。
|
||||
- 【重大认知修正 + 方案落地】itdog 大陆多节点测速(用户截图):box.tama.guru(EO新加坡,43.174.247.43)大陆均值 3.2-3.6s、三网骨干峰值 9-11s(四川电信 11.1s);box-dev-api(纯 CF,172.67.144.141/147.52/80.1)大陆均值 5.7-6.5s、峰值 12s。**反向分布**:港澳台/新疆/内陆部分省份反而 300-500ms。结论:① CF 与 EO 单走大陆都慢 3-12s,CF tunnel 不是备案前解药,删 DNS 分流的方案被证伪 ② R2 大陆实测 ~250ms 正常,静态资源非瓶颈 ③ 真正瓶颈=大陆→境外源站(EO新加坡/CF边缘)动态页链路。Rocket Loader 用户已手动关闭,HTTP/3 已开启。后端审计初步:box-dev-api 的 /favicon.ico→302 首页、robots.txt 1.1-2.4s(本机 AMS 视角)、cf-cache-status: BYPASS → 静态未缓存且每次回源,存疑点待大陆视角复核。根本解决路径只剩备案+大陆 CDN(腾讯云/阿里云,或 CF 中国伙伴京东云/世纪互联),备案前可做项=静态库迁 snrat。方案 B 已落地:jquery3.7.1/dayjs1.10.8/alpine3.9.0/cropper1.6.2(js+css 用 min 版)/vConsole3.12.1/waline2.15.8 共 7 文件全部 sha256 校验一致后 R2→cdnjs.snrat.com(4 文件:header/list/question-not-found/profile),备份在 .workbuddy/backup/cdn-migration-20260905/,go test templates 通过。R2 保留:easy.qrcode remake/waline-no-commits.css/time.box.js/pickr(1.10.1 snrat 404 未收录)/neko-pixel 0.0.21/logo/社交图标。
|
||||
- 【snrat 部署验收通过】用户部署 box-dev-api 后用浏览器实测 admin6 主页:HTML 全部 snrat 引用已生效(uikit/css/js/icons、dayjs/alpinejs、waline.js/jquery/min),无 Rocket Loader 注入。document.readyState=complete,UIkit/Dayjs/jQuery/Waline 全局对象全部存在,UIkit 组件(navbar、tab、表单、按钮)渲染完整、.uk-light 补丁正确生效、定制文件(waline-no-commits/easy.qrcode remake/time.box.js/社交图标)继续走 R2 无回归。截图已存 outputs/admin6-snrat-check.png。验收小波折:open+eval 组合命令易卡顿(已分步解决),首次 eval 在 defer 脚本未执行完时探测 typeof UIkit=false 属时序问题,等 3s 后 UIkit=true。
|
||||
- 【生产部署验收通过】box.tama.guru 主页/用户页 HTML 引用全部确认(uikit/dayjs/alpinejs/jquery/waline.js → cdnjs.snrat.com,旧 R2 库残留=0,定制文件 waline-no-commits/time.box.js/tamabox_low.png 继续 R2 200 可达)。浏览器实测 /_/tamakyi:UIkit/dayjs/jQuery/Waline 全局对象全部就位(UIkit=function)、navbar rgb(248,248,248)、.uk-light rgba(0,0,0,.7) 补丁生效、用户名 rgb(0,123,255) 账号自设色、28 UIkit 按钮,无回归。截图 outputs/prod-tamakyi-snrat-check.png。⚠️ 踩坑记录:agent-browser 探测 defer JS 库有显著时序窗口——本机(AMS 出口)拉 snrat 国内节点 133KB uikit.min.js 需数秒,过早 eval typeof UIkit=false 属假阴性;且 CDN 无 Timing-Allow-Origin 时 resource timing 全 0 属正常。正确姿势:open 后 sleep≥8 再 eval,或先 fetch 触发等待;判断库是否成功以全局对象(typeof)为准,勿信 resource timing。
|
||||
- 【后端审计补完】澄清两个疑点+确认一个真问题:① /favicon.ico、/robots.txt、/ 的 302 是 curl -sSI(HEAD) 假象——flamego f.Get 路由对 HEAD 不匹配返回 302,GET 全部 200,浏览器无影响,撤稿之前"favicon 302"结论。② 真问题:route.go 的 favicon/robots/sitemap/llms/llms-full 5 个 embed handler 只设 Content-Type 无 Cache-Control → CF cf-cache-status: BYPASS、EdgeOne Age:0 每次回源 tunnel。全项目仅 share.go:501 share-card 设了 public max-age=300;/static/* 由 flamego.Static CacheControl 统一 86400。③ 动态页 HTML 无缓存头是预期(个性化),edge 每次回源属免备案架构使然。待批修复:5 个 handler 各加 Cache-Control: public, max-age=86400(约 5 行,纯增益)。源站不在本机(无 cloudflared/tamabox 进程),localhost 直测不可行。
|
||||
- 【修复落地】route.go 5 个 embed 静态 handler(/favicon.ico /robots.txt /sitemap.xml /llms.txt /llms-full.txt)各加 Cache-Control: public, max-age=86400。验证:gofmt 干净 + go build -overlay 编译通过 + go test ./internal/route/ 通过。⚠️ 环境坑记录:Windows 本机全量 go build/go test 会因 chai2010/webp@v1.4.0(cgo 依赖 libwebp) 报 undefined webpGetInfo 等——需用 dev/webpstub 机制:dev/overlay.json 已失效(指向旧仓库路径 TamaBox-v4-time),正确姿势=自建 overlay 把 internal/storage/s3.go 替换为 stub(s3.go 导出符号仅 MaxUploadBytes/UploadAvatarToS3/UploadBackgroundToS3/UploadPictureToS3/ConvertStaticImageToWebP + oss.go 引用的私有 isSupportedImage),本次 stub 存 %TEMP%/s3_stub_tamabox.go、overlay 存 %TEMP%/ov-tamabox.json,命令:go build/test -overlay=...。部署链:Dockerfile CGO_ENABLED=0 + go build ./cmd/(生产无碍)。
|
||||
- 【测试环境缓存修复验收】box-dev-api 5 路径(favicon/robots/sitemap/llms/llms-full)响应均已带 Cache-Control: public, max-age=86400(HTTP 200)。⚠️ CF 缓存机制要点:无 cookie 首访 → session 中间件 Set-Cookie(flamego_session) → cf-cache-status: BYPASS 永不缓存;带有效 cookie 请求 → 不 Set-Cookie → MISS→HIT(Age 递增) 正常缓存。真实浏览器=先访问 HTML 已种 cookie→后续 favicon 等请求均带 cookie→缓存命中;无 cookie 场景仅 curl 首访/搜索引擎爬虫(低频,可接受)。测试环境通过,用户将部署生产。若日后想让爬虫也命中缓存:把静态路由挪到 session 中间件之前或让 session 跳过静态路径(本次未做)。
|
||||
- 【答疑】/.well-known/pki-validation/fileauth.txt 删除后仍被爬:① 线上实测两环境均 302→/(Go http.Redirect 标准输出),文件内容确已删除生效;② 请求持续=全网证书/安全扫描器将 .well-known/pki-validation 与 acme-challenge 同列标准探测目标,按字典批量扫所有域名,与文件是否存在无关;③ 302 来源=route.go:396 f.NotFound handler(先 ab.Record 404 计数再 ctx.Redirect("/"),注释说明 flamego NotFound 先写 404 再 302);④ 噪音放大:探测 302→扫描器跟随→再请求 / 首页,日志翻倍且 CDN 不缓存 302(EO-Cache-Status: MISS/DYNAMIC)每次回源。治理可选:CDN 规则边缘拦 .well-known/*(推荐,零回源)/f.NotFound 对探测路径直接 404 不 302/autoban 敏感路径加速封禁。等待用户选方向。
|
||||
- 【架构澄清+瓶颈定位实验设计】用户澄清架构:海外=CF+tunnel+优选网络;国内=EdgeOne 直连源站 IP(回源不走 tunnel)。质疑"大陆→境外源站"归因。回应要点:大陆用户连的是 EO 节点非源站(此前表述简写失准),但 EO 国际站无大陆 PoP(43.174.247.43=新加坡),"境外段"=大陆→EO 新加坡节点(itdog 3-11s 证据即此段)。分离实验:生产 robots.txt 已确认 EO HIT 缓存(MISS→HIT Age 2/3,证明 cache-control 5 行修复已部署生产生效)→ 用户 itdog 对比 robots.txt(HIT 零回源) vs /_/tamakyi(每次回源),差异=EO→源站回源段+源站处理;若 robots 也 3s+ 则瓶颈纯在大陆→EO 段与回源方式无关。需用户补测+告知源站位置(决定回源段性质:源站大陆则 EO 新加坡→大陆回源也属国际链路)。
|
||||
- 【链路分解实测完成】用户确认服务器在境外。三组实测:① 本机 curl(AMS 出口):robots.txt(EO HIT 零回源) 0.6-2.2s 波动 vs 动态页 /_/tamakyi ttfb 0.87-1.46s——EO HIT 也波动大,非回源差异;② check-host.net 全球 25 节点:robots.txt(EO HIT) p50=1258ms min505(us2) vs 动态页(回源) p50=1217ms min735(jp1)——**两者几乎相同**→ 证明 EO→境外源站回源段快(服务器境外),动态页不比零回源静态慢;③ agent-browser 页面级(AMS):TTFB 348ms、HTML download 555ms、load_total 1.1s、最慢资源 log beacon 1944ms。结论:动态页慢的根因=用户→EO 新加坡节点段(大陆 itdog 3-11s 因国际出口拥塞;连欧美节点也 0.5-2.7s 因 EO 国际站无大陆 PoP 且节点网络质量差),与回源方式(tunnel/优选/直连IP)无关。对照:snrat(国内 cdnjs 镜像) 全球 p50 仅 257ms ≪ EO p50 1258ms,EO 国际站全球表现差。根治=备案+大陆 CDN。
|
||||
- 【HTML 压缩评估】生产 /_/tamakyi:原文 61,125B(内联 style 11 块 19,972B + 内联 script 10 块 14,048B + 外链 9js/2css + 空白 4KB)。gzip 已在源站开启(internal/route/gzip.go GzipHandler 挂 http.Server 最外层,处理 204/304/嗅探/Flusher,不主动 flush 单块输出;EO 透传带 Vary),gzip-9 后仅 14,966B(24%)。无显式 Flush 调用点 → 无多次 RTT 问题。结论:压缩已最优;555ms download 主因是高 RTT 非体积;内联 CSS 20KB 是设计(高 RTT 下内联优于外链+缓存),不建议外链化;剩余微优化(minify 内联块/去空白)收益 <1KB gzip,无意义。真正优化=降 RTT(大陆 CDN)。
|
||||
- 【EO 优选调研】结论:①有,社区"EO 优选 IP"玩法与 CF 优选同原理:EO 官方 CNAME 把大陆调度到新加坡(43.174.247.43 AS139341 ACE)慢,社区做法=DNS 改 A 记录直指香港等优质 EO 边缘 IP(43.174.150.x 香港同 AS)。数据源 wetest.vip(page/edgeone/address_v4 需登录,15min 更新)/cf2dns 面板/archlinuxcn forum 教程。②实测:xhh 教程香港 IP 43.174.150.199/119 已失效(000,时效性强需活源+月度更新);EO 大陆可达节点列表(EdgeOne-ips-Checker available_eofreecn_ips 167 个大陆 IP)接口 2026-07-31 停服 8-31 下线,实测全 000 → B 类已死。③EO 官方内建"自研协议优化/回源链路探测"(类 Argo)但无法解决无大陆 PoP;优选只能把边缘从新加坡换香港(大陆 3-11s→预计 50-200ms,待 itdog 验证)。④落地=dnspod 境内线路解析值改 A 记录指向活香港 EO IP(多 IP+TTL60-300s+月度更新),境外保持 CF。风险:灰色玩法/证书 SNI 需实测/EO 不承诺。结论已回复用户,未动手改 DNS。
|
||||
|
||||
## EO 优选方案 B 免费实测(用户排除付费 wetest.vip 后执行)
|
||||
- 【用户确认】方案 B=自扫或公开 CNAME 均可,不限香港,周边区域比新加坡好即可。
|
||||
- 【免费公开源全灭实测】wetest.vip 需会员(公开页0 IP);*.v1/v4.eo.fastlydns.cn、eo.072103.xyz、eo.088086.xyz 全部 NXD;蓝点网/雾创岛 43.174.150.x 香港 IP 证书已退回 *.cdn.myqcloud.com(CDN 通用池) 全失效。博客佐证(2026-02):"EO 现在不能使用优选节点,非适用节点返回 418"。
|
||||
- 【方法论修正】TLS 握手成功≠可用(腾讯 CDN 泛播池也握手);正确判据=schannel 证书校验过(CN=box.tama.guru)+HTTP 200+Server: TencentEdgeOne。
|
||||
- 【扫描事实】43.174/43.175 全 /16 抽样(3072)+官方段/HK/JP 细扫(56 /24≈14000 IP):能服务 box.tama.guru 的 EO 池 9 段全新加坡(192-195/218/240-241/246-247);EO 泛播确实同步全球——HK(43.175.115/119/43.174.126 共8 IP)、东京(43.175.16/17/18/113 共6)、大阪 107.217、AMS/DXB 零星可服务。
|
||||
- 【南宁电信视角(真实大陆)】官方新加坡 TCP 180ms/TTFB 0.8-1.0s 反而最优;香港候选 351-377ms/TTFB 1.3-1.8s、东京 276-299ms/TTFB 1.1-4.0s(不稳) 均更慢 → 官方 CNAME 即 EO 对大陆优化调度,自扫候选跨境路由绕。单点结论≠全国,华南可能反转,已列 15 个候选 IP 待用户 itdog 多节点验证(报告 outputs/eo-free-probe-2026-09-05.md)。
|
||||
- 【工具沉淀】dev/eo-probe-ips.py(TLS快速预筛)、dev/eo-probe-v2.py(schannel 严格判定,可复用任意域名)、dev/eo-scan-all.py(TCP+openssl 两段式大范围扫描)。
|
||||
- 【下一步】用户 itdog 验证候选 vs 新加坡;若均不优→方案 B 到此为止(免费优选时代窗口 2025中-2026初已关),剩余=备案+大陆CDN 或维持现状。
|
||||
|
||||
## 头像+二维码加载慢诊断(个人主页 /_/tamakyi,南宁电信实测)
|
||||
- 【根因1 P0】R2( r2.337845818.best ) 图片对象全部无 Cache-Control(仅 ETag+Last-Modified,cf-cache-status: DYNAMIC);头像 Last-Modified=当天→启发式缓存≈0→每次页面加载重下头像。304 也 1.05s(RTT 省不掉)。上传代码 s3.go PutObjectInput 从未设 CacheControl(已确认)。
|
||||
- 【根因2 P0】R2 纯 CF 国际链路:南宁实测每资源 0.78-1.4s(头像 0.9-1.05s/背景 297KB 1.39s/logo js 0.96s/1.6KB time.box.js 也 0.9s=RTT 主导);snrat 大陆镜像 0.3-0.5s 作对照。
|
||||
- 【根因3 P1】easy.qrcode.min.js(52KB,R2) logo 分支:头像 onload 才绘制,onerror 仅 console.error 无降级→头像挂/失败=二维码永久空白。
|
||||
- 【根因4 P1】头像缓存键分裂:header.html icon/favicon 用 ?og-image=1(646-648),list.html+页面头像+QR logo 用 ?v=2 → 一页下载 2 份。
|
||||
- 【根因5 P1】背景 box_background.png 303KB 未压缩 PNG(2024-08 上传未变)。
|
||||
- 【根因6 P2】defer 统计脚本拖 DOMContentLoaded:vercount 1.24s(波动2.7s)/mami 波动3.7s → 二维码初始化(DCL)被拖。
|
||||
- 【修复方向】P0① s3.go 加 CacheControl public,max-age=31536000,immutable(图片 key 随机不可变安全);P0② easy.qrcode onerror 降级无 logo 绘制+超时;P1③ 统一头像 query 键(og-image=1 无 Go 处理疑似遗留);P1④ 背景转 WebP≤60KB;P1⑤ 统计脚本 async;P2⑥⑦⑧(R2 前接 EO 回源=治本)。
|
||||
- 报告 outputs/avatar-qrcode-slow-2026-09-05.md;抓包快照 outputs/tb-page-snapshot.html;库源码已存 dev/easy.qrcode.min.js、dev/time.box.js。
|
||||
|
||||
## P0 批落地:s3.go immutable 缓存头 + 二维码降级渲染(用户批准后执行)
|
||||
- 【用户授权】"先备份当前源码,然后尝试吧"→ 执行诊断报告的 P0-① 与 P0-②。
|
||||
- 【备份】.workbuddy/backup/p0-avatar-qrcode-20260905/(s3.go + header.html + list.html + question-not-found.html + profile.html,含修改前版本;header.html 修改前版本用脚本从当前版精确移除新增块生成,已与 cdn-migration 基线比对仅差上午 10 行 CDN 迁移改动,验证一致)。
|
||||
- 【改动1 s3.go】uploadPictureToS3 的 PutObjectInput 增加 CacheControl: public, max-age=31536000, immutable(key=picture/日期/随机hex 内容不可变,安全)。SDK v1.47.5 PutObjectInput.CacheControl 字段验证存在;gofmt 通过。
|
||||
- 【改动2 header.html】<body> 后新增全局 window.tbRenderQR(el, getOptions):核心修复 easy.qrcode logo/背景图加载失败仅 console.error 无降级导致二维码永久空白。策略=已知可用层立即绘制(logo 记忆命中零等待一次成型)→ 未知层先省略画基础码(立即可扫)→ Image 探针判定后重绘升级版;失败记忆 _tbQrGood/_tbQrBad 按 URL 同会话缓存;3s 超时兜底;迟到 onload 仍能补 logo。实测 8 个行为用例全过(node stub QRCode/Image)。
|
||||
- 【改动3 三个调用点】list.html(公开主页)/question-not-found.html:new QRCode→tbRenderQR;profile.html updatePreview 同改(debounce 高频重绘场景靠 URL 记忆避免闪烁)。背景图分支(库内无 onerror 同风险)一并纳入降级。
|
||||
- 【验证】go test ./templates/ 全绿(TestTemplatesParse/CrossReferences 等);8 行为用例 PASS;改动 diff 已复核为纯增量。
|
||||
- 【部署注意】缓存头只对新上传对象生效;历史对象需手动(可用 CF 批量 purge 或 R2 重新上传);模板改动无后端逻辑变化。待用户部署后浏览器验证:二次访问头像应 0ms 级、头像坏链时二维码仍可扫。
|
||||
|
||||
## CORS/画布污染核查(P0-② 二维码降级改动安全性)
|
||||
- 【结论】当前部署不会引发 CORS 失败或 canvas 污染。三个前提实测:① R2 已配 CORS 白名单,box.tama.guru 与 box-dev-api.337845818.best 均返回 ACAO(localhost:3000/example.com/裸域 无 ACAO);② list/profile options 显式 crossOrigin:'anonymous'(注释注明"直连 R2 跨域图须 CORS 模式"),tbRenderQR 默认同值,与库内 new Image 的 CORS 模式一致,探针与库绘制走同一缓存键;③ easy.qrcode 内部 makeImage 后调 this._elCanvas.toDataURL("image/png")(catch 后 console.error + 提示 "Configured crossOrigin:'anonymous'")——库开发者已内置该诊断。
|
||||
- 【污染免疫原理】canvas 污染只发生在"no-cors 隐式加载成功但无 ACAO"的中间态;tbRenderQR 探针失败(onerror)→ 根本不 drawImage → 无污染;成功 → 响应必带 ACAO → 干净。不存在绘制了却污染的路径。
|
||||
- 【边界提醒】R2 CORS 白名单只有 2 个源;新增页面域名/本地开发需先加白名单,否则 anonymous 探针 onerror → 降级基础码(可扫但永不带 logo,不报错)。favicon/og-image/meta 均非 canvas 上下文,无需 CORS 无污染风险(header.html 640-653 直指 R2)。
|
||||
|
||||
## P0 批测试环境验证(agent-browser 实测 box-dev-api.337845818.best/_/admin6)
|
||||
- 【静态部署检查】HTML 含 tbRenderQR 2 次(helper 定义+1 调用)/ crossOrigin:'anonymous' 1 次 / logoBackgroundTransparent 2 次 / new QRCode(document.getElementById 为 0(已替换)→ 三处调用点全部到位。
|
||||
- 【正常路径】#boxqrimg canvas 200x200、toDataURL 成功(base64 长 28578 无污染)、中心 20x20 像素全为非白非黑"other"且色相分布为深色照片调(4,4,3 棕色系 96 像素、7,6,1 等)→ 头像 logo 已绘制升级版。
|
||||
- 【降级路径】临时 div 用坏头像 URL(不存在 picture path)调 tbRenderQR:立即态 canvas 已生成(len 4226 紧凑基础码、exportable true);3s 后状态保持(childCount 1、exportable true);_tbQrBad 写入坏 URL;_tbQrGood 同时记录真实页面头像 9af3e38?v=2 与背景图 25e2d84 → 双向印证降级免疫污染。
|
||||
- 【加载中排查】页面 2 个 uk-spinner:"正在生成分享图…"(分享卡区正常态)+ captcha modal 内的"加载中..."(rect 全 0 不可见);"加载更多 加载中..."按钮 display:none → 均非主诉"全屏加载中"覆盖层。用户主诉已消除。
|
||||
- 【截图】outputs/tb-dev-test-qr.png 全页、tb-dev-test-qr-crop.png 头部裁切:头像、用户名、提问按钮、二维码均已显示,无加载中残留。
|
||||
- 【代理坑提醒】agent-browser open <url> 因测试站境外+defer 脚本多会卡 load 等待;可靠做法:先 agent-browser open (about:blank 启动 daemon) → 再 open <url> 导航 → 然后 sleep ≥10s 让 defer 脚本就绪再 eval。
|
||||
- 【结论】P0 批(s3.go immutable 缓存头 + 二维码降级)测试环境验证全部通过,可部署生产。
|
||||
|
||||
## P0 批正式环境验证(agent-browser 实测 box.tama.guru/_/tamakyi)
|
||||
- 【静态部署】HTML 60.1KB;tbRenderQR 2 次/调用点 1 处替换/crossOrigin 'anonymous' 1 次/logoBackgroundTransparent 2 次/new QRCode(document.getElementById 0;头像引用 r2.337845818.best/.../2e571390...(2026/09/05 新传)→ 与测试环境一致部署完整。
|
||||
- 【正常路径】#boxqrimg canvas 200x200 exportable len 31726;中心 20x20 像素全为深色照片调(7,7,7 灰+蓝黑系)→ 头像 logo 已绘制。
|
||||
- 【降级路径】坏 URL 测试:立即态 len 4226 基础码、3s 后保持 stillCanvas exportable len 4226 不空白;badMem=1、goodMem 含 2 个真实 URL(头像 2026/09/05 新头像 + 背景图 2024/09/22)→ 双向印证探针与降级免疫污染均正常。
|
||||
- 【加载中残留】visibleLoading=[](精确定位 rect>0 且文本含"加载中"的可见元素);之前的 uk-spinner/captcha/loadmore 在生产页同样不可见。
|
||||
- 【截图】outputs/tb-prod-test-qr.png:头像、二维码码眼完整、无加载中遮挡 → 与测试环境一致通过。
|
||||
- 【坑补充】第一次 `agent-browser open | open URL` 链式启动 3+ 分钟无输出卡死(即使之前测试环境用同样命令成功),最终拆成单条独立命令即恢复。原因可能是 Chromium 冷启动资源紧张或 chain 顺序导致首条 stdin 未及时关闭。经验:永远单条命令、第一条单独跑。
|
||||
- 【结论】P0 批正式环境全部通过;图片缓存头对老对象(2024/09/22 背景图)暂不生效,新上传对象生效,需要时间自然过渡。
|
||||
|
||||
## autoban 误伤诊断(用户 app.ini: window=10s, sensitive=10, 404=20, duration=15min, trust_proxy=true)
|
||||
- 【机制】全站仅 2 个计数入口:中间件敏感路径 defer Record(autoban.go:39)+ f.NotFound 404 Record(route.go:403)。业务路由/模板 77 URL × 39 敏感词零碰撞 → 正常页面不误计。
|
||||
- 【误伤根因】正常用户 10s 内凑不出 10 敏感/20 404 → 误伤必是"他人探测记到自己键上":P0-A IP 键折叠(EO 未透传 EO-Client-IP 时全大陆回退到 43.174.247.x 新加坡边缘共享键;CF tunnel 异常时回退 127.0.0.1);P0-B CGNAT 共享出口连坐(国内 NAT444 常态,管理员自伤主因);P1-C IPv6 /64 聚合连坐;P1-D 双 CDN 未删对端权威头可被伪造头定向嫁祸。
|
||||
- 【止血配置】window 60s + sensitive 30 + 404 100 + security_alert_email 开(先看封禁 IP 判断折叠)。治本=EO 开回源 IP 透传 + 源站防火墙锁 CDN 网段 + 双 CDN 删对端头。
|
||||
- 【代码增强候选】登录 session 豁免计数、封禁 logrus 日志、UA 二次确认。报告 outputs/autoban-false-positive-2026-09-05.md。
|
||||
|
||||
## autoban 策略修正实施:ban_track_notfound(用户选定"配置开关版")
|
||||
- 【背景】生产新误伤案例 106.7.154.160(真实国内电信 IP,非 CDN 边缘):样本 /_/Liarcs×4 + /×2 全是计数事件;但 domain/题目不存在走 302(Pager/Questioner 均 ctx.Redirect)不计数 → 能进 f.NotFound 的多为非 GET 探测或未注册路径;告警 paths 仅 6 条且受 2min cleanup 影响可跨窗口累积 ≠ 触发原因;触发=10s 内 ≥20 次 f.NotFound 或 ≥10 敏感。疑似共享出口(CGNAT)连坐或目标性探测。
|
||||
- 【评估结论】敏感通道 35 项扫描特征与业务路由全表零重叠→零误伤;404 通道是唯一误伤源但价值低(主流字典必碰敏感项;/_/{domain} 枚举靠 200/302 区分本来就抓不到)→ 砍 404 计数收益>损失。
|
||||
- 【改动 5 文件,备份 .workbuddy/backup/autoban-track-notfound-20260905/】
|
||||
1. internal/conf/static.go:Security 加 BanTrackNotFound bool `ini:"ban_track_notfound"`(默认 false)
|
||||
2. internal/security/autoban/autoban.go:Options.TrackNotFound、Banner.trackNotFound、New 赋值;Record 对 KindNotFound && !trackNotFound 直接 return
|
||||
3. internal/route/route.go:Options 传 TrackNotFound: conf.Security.BanTrackNotFound;f.NotFound 注释同步
|
||||
4. internal/security/autoban/autoban_test.go:newTestBanner 默认 TrackNotFound:true;新增 TestNotFoundTrackDisabledByDefault;TestDefaults 断言 trackNotFound==false
|
||||
5. conf/app.sample.ini:security 段加 ban_track_notfound = false + 注释(真实 app.ini 在各部署环境,需用户同步补键或留空=默认关)
|
||||
- 【验证】gofmt 干净;go vet OK;go test 三包全绿(route/conf/autoban,带 -overlay=tb_overlay.json 规避 cgo webp);新用例 PASS。
|
||||
- 【部署提醒】测试/生产 app.ini 无需强制加键(缺省 false 即关闭 404 计数);若想恢复 404 计数置 true。conf/app.ini 不在本地副本(conf/ 仅 app.sample.ini)。
|
||||
|
||||
## ban_track_notfound 测试环境实测(box-dev-api,出口=本机南宁电信)
|
||||
- 【正常路径】/ + /_/admin3..admin9 全 200 无 403;不存在 domain ×8 → 302 不封(业务 302 通道确认);admin6 页 SSR title「admin6 - 狼的提问箱」正常。
|
||||
- 【404 洪水】未注册路径 ×30 → 全 302(location:/) 不封(TrackNotFound=false 预期,但受 enable_auto_ban 前提约束)。
|
||||
- 【敏感×37 异常】/.env /admin /wp-login.php 等共 37 个敏感路径(默认阈值 10 的 3.7 倍)→ 全 302 无 403。302 均为服务端 f.NotFound Redirect(location:/ + securityHeaders + CF NEL),确认到达应用层 → 疑似测试环境 enable_auto_ban=false(或阈值极高/实例未跑新二进制),需用户核对 app.ini + 日志后才能定论。若 enable=false,本测无法验证 TrackNotFound 语义,建议测试环境 enable_auto_ban=true 后重测。
|
||||
- 【教训】agent-browser open 境外站再次卡死(>3.5min),TaskStop 清理;渲染验证降级 curl 抽查 HTML 即可。
|
||||
|
||||
## ban_track_notfound 测试环境复测通过(22:17-22:35,出口 222.218.124.37)
|
||||
- 【上轮误判根因】串行 curl 每个请求间隔 1-2s(南宁→CF→源 RTT),13 个敏感请求跨 18s > 10s 窗口 → 永不达阈值;日志时间戳证实。非配置问题。
|
||||
- 【复测方法】xargs -P 25 并发打,窗口内密集命中。
|
||||
- 【结果】① 正常路径 / + admin3-9 全 200;② 纯 404 并发 x25 → 全 302 无 403(TrackNotFound=false 生效);③ 敏感路径并发 x25 → 前~10 个 302 后 15 个全 403(阈值10触发封禁);④ 封禁后 / 与 /_/admin6 也 403(IsBanned 全局生效)。
|
||||
- 【副作用】本机出口 IP 222.218.124.37 被测试环境临时封禁 15min(约至 22:35 自动解除);测试环境日志可见该批 403 + 若有 security_alert_email 会收到告警(含样本路径)。
|
||||
- 【结论】修复语义完整闭环:正常业务放行、纯404豁免、敏感扫描封禁。部署生产即可。
|
||||
@@ -1,136 +0,0 @@
|
||||
# 2026-09-06
|
||||
|
||||
## 移除 vercount 第三方页脚统计(用户确认"不用就去掉")
|
||||
- 【诊断】cn.vercount.one 实际 = Vercel 托管 + CF 前置(104.21.1.96),cf-cache-status DYNAMIC 每次回源跨洋;南宁实测 js ttfb ~1.9s(DNS 0.72s 高);脚本内部 DOMContentLoaded 后才 fetch events.vercount.one(ttfb ~3s,才是页脚 spinner 真瓶颈);且慢 defer 会推迟 DOMContentLoaded ~1.4s。页脚仅展示 site_uv/page_pv 两徽标(busuanzi 兼容命名)。
|
||||
- 【决策】第三方装饰统计非必须 → 用户选彻底移除(非动态注入方案)。备份 .workbuddy/backup/remove-vercount-20260906/。
|
||||
- 【改动 7 文件】
|
||||
1. templates/base/header.html:删 dns-prefetch cn.vercount.one;删 busuanzi 注释行与 <script defer vercount>;删专用 .tb-spinner CSS 块(含 dark 覆盖)
|
||||
2. templates/base/footer.html:删整行 visitor/pageview 统计(busuanzi_value_site_uv/page_pv)
|
||||
3-6. internal/i18n/dict_{zh_cn,zh_tw,en,ja}.go:删 site.footer.visitor/pageview 两个 key
|
||||
7. internal/i18n/i18n_test.go:visitor 参数填充断言改指 auth.recover.greeting(保 %s 覆盖)
|
||||
- 【验证】残留 grep 全空;gofmt clean;go test ./internal/i18n/ + ./templates/ 全绿。
|
||||
- 【未动】static/tamabox.changelogs.json 为 2024 停更归档;根 CHANGELOG.md 是否追加条目待用户确认。
|
||||
|
||||
## umami 数据展示放弃 + tamami.js 加载优化(方案A load 注入)
|
||||
- 【umami 取数可行性】mami.tama.guru = Vercel 托管 Next.js(CNAME vercel-dns-017.com);umami v2 API 全需登录(/api/websites 401、login 空体 zod 400);新版 umami 无 Share 功能 → 用户放弃 umami 数据恢复页脚统计,footer 维持无统计徽标(vercount 移除后现状)。
|
||||
- 【mami 慢根因】Vercel 无国内节点,白天/冷访问 1-2s+;tamami.js 4.6KB 缓存头好(public max-age=86400, Age=303024 靠 CDN 兜底)凌晨命中 0.3s;用户体感慢主因=umami 后台(Next.js 动态回源)+ 白天边缘波动。defer 在 head 会推迟 DOMContentLoaded(同 vercount 问题)。
|
||||
- 【R2 方案排除】实测 R2 直链 0.80-1.56s 比 Vercel 缓存命中 0.28-0.35s 更慢(凌晨南宁),印证 UIkit 当初迁 cdnjs.snrat.com 的原因。
|
||||
- 【脚本能力确认】tamami.js 内部:host = data-host-url 优先否则脚本同源,拼 /api/send 上报 → 静态搬迁可行但无快托管点故弃。
|
||||
- 【改动】templates/base/header.html(备份 .workbuddy/backup/tamami-load-inject-20260906/):638 行 <script defer tamami.js> → window load 后 createElement 注入 + setAttribute data-website-id。效果:mami 再慢不阻塞页面关键事件;PV 统计损失≈0(load 后立即上报当前页)。
|
||||
- 【验证】go test ./templates/ 通过。
|
||||
- 【未做】mami 接入 EdgeOne(方案C)未实施——用户暂选 A;上报 /api/send 跨洋延迟/丢失为 Vercel 托管固有限制。
|
||||
|
||||
## 全站外部资源加载体检(南宁凌晨实测 2 次取快值)
|
||||
- 【健康】snrat 全家(uikit css/js/icons/dayjs/alpine/waline) 0.14-0.32s;www.recaptcha.net 0.17s;cf-turnstile 0.61s;bytecdntp 0.065s(注释未启用);cdn.github.red 0.15s;sentry-cdn 0.32s(仅 SentryDSN 非空才加载)。
|
||||
- 【🔴唯一慢域=R2】time.box.js 0.7-1.7s(header 全站 defer 1.6KB,纯装饰 runtime 倒计时,硬编码 2023-06-01 起算,无外部依赖);neko-pixel umd 0.87s(pixel页);waline R2 源/home.html bytecdntp 均注释残留未启用(实际 waline 走 snrat)。图片类(头像/social icons)同 R2 慢但异步非阻塞,缓存头已 immutable。
|
||||
- 【发现】header 的 vConsole 有 .VConsole 开关;sentry script 无 defer/async 且 tracesSampleRate=1.0——若生产 SentryDSN 启用则同步阻塞+高采样开销(需用户确认是否启用)。
|
||||
- 【未决】time.box.js 优化方案待用户选(内联 footer / load 注入 / 不动);SentryDSN 生产是否配置待确认。
|
||||
|
||||
## time.box.js 内联到 footer(用户选"内联",备份 .workbuddy/backup/timebox-inline-20260906/)
|
||||
- 【背景】加载体检确认 R2 是全站唯一慢域(0.71-1.68s),公共路径只剩 time.box.js(header 全站 defer 1.6KB 存活倒计时,无外部依赖)拖慢 DOMContentLoaded。
|
||||
- 【改动】① header.html 删 <script defer r2 cdn/time.box.js>;② footer.html box_runtime 后内联紧凑版 IIFE:功能等价 + 修 2 个原版 bug(先赋值后判空会抛错;DOMContentLoaded 后首个 1s 才显示)。
|
||||
- 【验证】time.box.js 引用清零(仅注释提及);go test ./templates/ 通过。
|
||||
- 【待确认】SentryDSN 生产是否配置(若启用 sentry bundle 250KB 同步阻塞 + tracesSampleRate=1.0);R2 旧对象 time.box.js 可留作兼容不动。
|
||||
|
||||
## 测试环境部署验证通过(box-dev-api,01:19-01:21)
|
||||
- 【tamami load 注入】主页+admin6:旧 defer src=mami.tama.guru 标签残留 0;addEventListener("load") 注入监听器 1;内联代码含 createElement+data-website-id 与源码一致。
|
||||
- 【time.box.js 内联】主页+admin6:time.box.js 外链 0;id="box_runtime" 元素 1;footer 内联脚本(Date.UTC(2023 + setInterval(tick))就位。
|
||||
- 【说明】未做浏览器 JS 行为验证(box-dev 境外站 agent-browser 有卡死史);SSR HTML 结构与源码逐字一致 + 标准 API 逻辑,判定部署生效。文案「存活」未出现因 curl 无语言 cookie 回退 en。
|
||||
|
||||
## EO(生产 box.tama.guru) vs CF(测试 box-dev) 速度实测对比(南宁,凌晨 01:36,http1.1)
|
||||
- 【归属】prod: Server TencentEdgeOne IP 43.174.246/247.43(EO 新加坡);dev: Server cloudflare 104.21.47.52/172.67.144.141,但 CF-RAY 后缀 AMS(阿姆斯特丹)与实测 conn ~0.24s 矛盾(疑 Ray 字段不准/边缘非 AMS)。
|
||||
- 【数据】①动态首页 8 轮 TTFB:prod 0.86-1.69s 均~1.22 vs dev 1.06-1.81s 均~1.49 → EO 快 ~0.27s;②连接阶段 8 轮:prod conn 0.20/TLS 中位 0.43(2次>0.9s 尖峰)vs dev conn 0.25/TLS 0.53(平稳无尖峰)→ EO 网络层快 50-100ms 但稳定性差;③favicon 静态 6 轮:prod 0.60-1.41 均0.84(有1.4s尖峰)vs dev 0.70-0.85 均0.75 → CF 反而更稳更快。
|
||||
- 【结论】EO vs CF 对南宁同一量级互有胜负,无碾压差异;动态页 EO 略快,静态/稳定性 CF 优。差异更多源于回源链路+边缘抖动而非 CDN 标签本身。局限:凌晨单点、http1.1、生产/测试后端实例不同(变量未隔离);白天高峰需复测。
|
||||
|
||||
## EO vs CF 正式 A/B(01:40-01:47,南宁,http1.1,后端不同服务器-残余变量已注明)
|
||||
- 【方法】robots.txt(静态文本近零后端耗时)30 轮交替 + 随机 query 强制回源 15 轮 + 此前 TCP/TLS/首页/favicon 数据汇总。
|
||||
- 【robots 30轮】EO med 0.61s/mean 0.74/p90 1.16/>1s×7;CF med 1.16/mean 1.34/p90 2.23/全部>1s(min 1.03)。
|
||||
- 【强制回源 15轮】EO med 0.83/mean 0.97;CF med 1.56/mean 1.68(CF min 1.39)→ EO 快 ~0.7s 近 2 倍。
|
||||
- 【综合】TCP 0.20vs0.25、TLS 0.43vs0.53、首页 1.22vs1.49:大样本下 EO 全面领先;favicon 6轮小样本 CF 略快(缓存状态差异)不改变结论。
|
||||
- 【坑】本机 Windows curl(Schannel) 不支持 --http2,勿用于 A/B;上轮 --http2 全失败 999。Bash 同名 /tmp 文件曾出现残留污染→用 $$ 文件名。
|
||||
- 【说明】CF 回源走 tunnel(cloudflared)、EO 回源直连;源站不同为残余变量,strict A/B 需同源(把 dev 挂 EO 或 prod 挂 CF)。
|
||||
|
||||
## autoban 敏感特征补强:sensitiveFragments 片段匹配(用户选"补强 WP 特征表",备份 .workbuddy/backup/wp-fragments-20260906/)
|
||||
- 【背景】生产日志见 wlwmanifest 扫描://wp/wp-includes/wlwmanifest.xml、//news/...、//2018/...(枚举前缀变体)全部 302 放行。根因:① 所有未注册路径走 f.NotFound→ctx.Redirect("/") 得 302(非 404,注释"先写404"与实际不符,flamego Redirect 覆盖状态);② TrackNotFound=false 后 404 不计数;③ IsSensitivePath 前缀匹配抓不到中部敏感段 + 特征表无 wp-includes/xmlrpc/wlwmanifest。
|
||||
- 【改动 autoban.go】新增 sensitiveFragments 数组(子串匹配强指纹,仅 WP 特征业务必无):/wp-includes/、/wp-json/、/wp-cron.php、/wp-trackback.php、/wp-admin、/wp-content、/wp-login.php、/wlwmanifest.xml、/xmlrpc.php、/wordpress;IsSensitivePath 增加 Contains 第二循环(大小写不敏感)。前缀表 sensitivePaths 保留不动(防 /administratorx 类误伤语义不变)。
|
||||
- 【测试】TestIsSensitivePath 补 9 正例(含日志真实形态 //wp/wp-includes/wlwmanifest.xml 等)+ 6 负例(/_/tamakyi、/_/admin6、/api/v1/question/xxx、/explore、/i18n 不误伤)。
|
||||
- 【验证】gofmt clean;autoban 全测 PASS;route 带 overlay 编译+测试通过(IsSensitivePath 由 internal/route/autoban.go:35 中间件调用,改匹配器全链生效)。
|
||||
|
||||
## 域名迁移方案:box.tama.guru → box.shiroko.one(方案阶段,未动代码)
|
||||
- 【用户决策】tama.guru 不续费(2026-12-24 到期,RDAP 实证,注册商 DNSPod)→ 硬过渡;新域 box.shiroko.one;Bing+GSC 全套;umami 随迁 mami.shiroko.one。方案文档 outputs/domain-migration-box-shiroko-2026-09-06.md。
|
||||
- 【取证要点】shiroko.one 未配置(NS=spaceship,http 200 为注册商 Parking Page);tama.guru apex=导航页 Hugo/CF Pages,box 走 DNSPod 三线路(境内 EO/境外+默认 CF SaaS);mami=Vercel。
|
||||
- 【SEO 现状】box sitemap 仅 8 条(/ /explore /register /login /_/tamakyi /change-logs),explore 是唯一内容页;robots Disallow /_/ 与 sitemap 提交 /_/tamakyi 矛盾;页面无 canonical;og:url/邮件链接均依赖必填配置 external_url;static/robots.txt+sitemap.xml go:embed 硬编码旧域;register.go:64 Bloglink 硬编码;censor TrustedHosts 含 tama.guru/337845818.best;R2 CORS 白名单需加新域。
|
||||
- 【核心机制】301 逐 URL 是唯一可靠保排名路径;Bing Site Move 工具可用性不稳定(2021 传闻下线)仅加速器;Site Move 后新域 6 个月禁再迁;通行 301 保持 6-12 月 vs 本方案仅 3.5 月窗口=R1 主风险,靠索引面小+早切换(≤10/01)+IndexNow 缓解。
|
||||
- 【301 载体推荐】旧域 DNS 切换日统一 CNAME → CF Pages 跳转项目(_redirects: /* https://box.shiroko.one/:splat 301!),与源站/旧 CDN 解耦;弃"源站 nginx 按 Host 301"(回源 Host=旧域会成环)。
|
||||
- 【开放项待用户】站群 blog/status/share/chat 是否迁(影响 footer/home 内链);/_/tamakyi 是否保(决定 robots/sitemap 取舍);cloudflared ingress 是否按 hostname。
|
||||
|
||||
## 域名迁移批1落地:全项目 tama.guru → shiroko.one(本地代码,备份 .workbuddy/backup/domain-shiroko-20260906/)
|
||||
- 【用户决策】其他 blog 之类子域一并调整到 shiroko.one;/_/tamakyi 不保(sitemap 移除,robots 维持 Disallow /_/);先做本地操作。
|
||||
- 【替换规则】*.tama.guru → *.shiroko.one(box/blog/chat/share/status/mami),apex tama.guru → shiroko.one。
|
||||
- 【改 11 文件】static/robots.txt(Sitemap行);static/sitemap.xml(5条换域+删/_/tamakyi+去重旧域无斜杠重复项+lastmod全刷2026-09-06);static/llms.txt(6处);static/llms-full.txt(7处);templates/base/footer.html(6条链接);templates/base/header.html(umami注释同步+验证token加迁移提醒注释);templates/home.html(站长箱/投票/Markdown/4条blog链/explore);route/auth/register.go:64 Bloglink默认值;internal/security/censor/text.go(TrustedHosts 新增 shiroko.one,**保留** tama.guru 过渡期);conf/app.sample.ini(补齐缺失的必填项 external_url,conf.go:36 强制校验,原模板缺失→照抄即启动失败);dev/eo-*.py(3脚本 --host 默认值)。
|
||||
- 【关键发现】① header 的 mami src 此前已先行改为 mami.shiroko.one(仅注释待同步);② google-site-verification(636行) 与 msvalidate.01(7行+637行重复) 是域名绑定 token,新域验证后必须替换 content,否则 GSC/Bing 无法验证新站→Site Move/Change of Address 无法提交;③ app.sample.ini 缺 external_url 必填键(模板缺陷)。
|
||||
- 【验证】sitemap XML 解析通过(5条无重复);go test ./templates/ + ./internal/security/censor/ 通过;route/auth、static、internal/conf 带 overlay 编译通过。
|
||||
- 【方案文档同步】outputs/domain-migration-box-shiroko-2026-09-06.md:§0 补两条决策;§11 开放项标 1/2 已答并新增 token 项;新增 §12 批1实施记录;附批次标批1完成。
|
||||
- 【待用户操作】blog/chat/share/status 各子域实际服务搬迁(否则新域链接成死链);censor 旧域条目待旧域到期后删。
|
||||
|
||||
## IndexNow key 文件 302 修复(static/index-now/<key>.txt 无法访问)
|
||||
- 【现象】用户新建 static/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt 并改 fs.go 的 //go:embed,但访问 302 跳首页。
|
||||
- 【根因】internal/route/route.go:138 的 flamego.Static 中间件 **Prefix="/static"** 只接管 /static/... 前缀;而 robots.txt/sitemap.xml/llms*.txt/favicon.ico 等根目录静态文件**每个都是单独注册的 f.Get handler**。新文件无对应路由 → 落 f.NotFound → ctx.Redirect("/") 得 302。embed 本身没问题(实测 FS.Open 可读,内容正确)。
|
||||
- 【修复】route.go:① import 加 regexp;② 包级 var indexNowKeyPattern = `^[0-9a-f]{32}\.txt$`(防路径穿越);③ 在 llms-full.txt 路由后加 f.Get("/index-now/{key}"),校验白名单后 static.FS.Open("index-now/"+key),Content-Type text/plain + max-age=86400,失败 404。备份 .workbuddy/backup/domain-shiroko-20260906/route.go.bak。
|
||||
- 【新增回归测试】internal/route/indexnow_route_test.go TestIndexNowKeyFileRoute:正确key→200+内容;路径穿越(..%2f 与 ../../)、合法格式但不存在、后缀不符→全 404。用 flamego.NewWithLogger(io.Discard) 构造最小实例(不依赖 New())。
|
||||
- 【flamego 关键行为】internal/route/tree.go placeholderTree.match 把整个路径段赋给参数,**不排除点号** → /index-now/{key} 能匹配 <key>.txt。
|
||||
- 【验证】gofmt clean;带 overlay 编译 ./internal/route/ 通过;route/static/templates 三包测试 ok。
|
||||
- 【部署提醒】需重新编译部署(embed+路由均在二进制内);IndexNow 提交时 keyLocation 填完整 URL https://box.shiroko.one/index-now/66e5e0911b0941f2ba7dbfbbd961e9be.txt;旧域 301 后该路径会被重定向,搜索引擎校验需 200 直出,故切换后应用新域 keyLocation 重新提交。
|
||||
|
||||
## 域名变更全员邮件通知脚本(dev/notify_domain_change.py,不碰项目源码)
|
||||
- 【需求】用户改了站点域名,想向全体未注销用户发邮件通知;要求复用生产 conf/app.ini 的 DB+SMTP,不动项目源码。
|
||||
- 【依据】① users 表软删除用 gorm.Model,未注销=deleted_at IS NULL;收件=email 列;可顺带取 name/domain 做个性化。② 发信对齐 internal/mail/mail.go:发件人"TamaBox <mail.account>",465=隐式SSL 否则 STARTTLS,支持 mail.skip_tls_verify。③ DSN 拼装见 internal/conf/db.go(postgres sslmode=disable+search_path=schema)。
|
||||
- 【实现】独立 Python 脚本:读 NEKOBOX_CONFIG_PATH 或 -c,configparser 设 strict=False(容忍示例 [upload] 重复键 image_endpoint)、去引号、去行内注释;fetch_recipients 查未注销有邮箱用户;smtp_login 复用生产配置;模板用 {{占位符}} 避免与 HTML 大括号冲突。
|
||||
- 【安全开关】默认 --dry-run;--send 才真发;--to 单测地址;--limit N 小批量;单条失败隔离+汇总;每封 --delay 默认 0.3s 防限流。
|
||||
- 【验证】py_compile OK;对 app.sample.ini 实测 load_config 解析正确(external_url 去引号、重复键容忍);render 模板无残留占位符。依赖 psycopg2-binary(Postgres)/pymysql(MySQL)。
|
||||
- 【增强·四语言】TamaBox 为四语言站点,给脚本加 LANG_TEMPLATES(zh-CN/zh-TW/en/ja),fetch_recipients 多取 language 列;发送时按 users.language 选模板,回退 用户语言→[app] default_lang→zh-CN;--subject/--html/--plain 为全局覆盖。effective_templates() 选模板,build_message() 内统一 render 后才拼装(曾一度漏 render 已在自检发现并修正)。去掉默认模板 emoji。复测:四语言 text/plain+text/html 双部、box_link/site 就位、无残留占位符(正文 base64 传输编码属正常,解码核验通过)。
|
||||
|
||||
## 用户问"前端还有什么要改造的" + "box.tama.guru 接 CF 自定义主机名如何配 301"
|
||||
- 【诊断残留】grep 全项目:tama.guru 残留仅在 sitemap.xml 注释、header.html 验证 token 提醒注释、censor/text.go(过渡期有意保留)、outputs/(文档)——即迁移批1已完成,无业务代码残留旧域。
|
||||
- 【R2 硬编码仍散落】`r2.337845818.best` 约 14 处:header.html(preconnect/waline css/favicon/OG)、pixel.html(neko-pixel css+js)、question-not-found.html & list.html(social icons + easy.qrcode.min.js)、user/profile.html(qrcode js + pickr css/js)、route/question/share.go(social icons);waline serverURL `waline.337845818.best` 在 home.html/item.html。建议抽 `CdnBase`/`WalineURL` 模板变量集中管理(为同源化/换域铺路)。
|
||||
- 【301 指引(CF 自定义主机名架构)】规则建在承载该自定义主机名的 zone 上;迁移未完成前**不要**整站 301 到 shiroko.one(会断线上)。①现在做:SSL/TLS→Always Use HTTPS(http→https 自动301)+ www 规范化 Redirect Rule;②切换时做:Bulk Redirects(box.tama.guru→https://box.shiroko.one, 301, include subpaths+query)或 Single Redirect Dynamic `concat("https://box.shiroko.one", http.request.uri.path, http.request.uri.query!=""?concat("?",http.request.uri.query):"")`;③SEO 配套:sitemap 已换域重提交 GSC/Bing、旧域 DNS 保 6-12 月、header 638-639 行验证 token + 第7行 msvalidate.01 新域重新验证替换、canonical/OG 指向新域。
|
||||
- 【架构提醒】A/B 实测 EO 国内比 CF 快约 2 倍(EO 0.61 vs CF 1.16,CF 走 tunnel 回源);box.tama.guru 接 CF 后国内(南宁电信)失 EO 提速优势,规划 box.shiroko.one CDN 时建议继续用 EO 或 CF China 保国内速。
|
||||
|
||||
## 通知脚本三要求落地(dev/notify_domain_change.py,不碰项目源码)
|
||||
- 【需求】①发送前打印 DB 总用户/已注销/未注销/未注销有邮箱数,须人工 yes 确认才发;②不一口气发完留间隔;③正文明确换域原因+用户动作:新域 box.shiroko.one、旧域 2026-12-24 到期、期间 301 转发、到期后旧域停用;Why=.guru 连年涨价一年三百出头遭不住;Do=记新域+替换此前分享的二维码。
|
||||
- 【改动】抽 connect_db() 供 fetch_recipients/fetch_counts 复用;新增 fetch_counts() 打统计;main 发送前打印五类统计;--send 路径加 input("yes") 人工确认(EOFError→中止,--yes 跳过);--delay 默认 1.0s,新增 --pause-every/--pause-for 分批长暂停;四语言模板全重写,加 {{old_domain}}/{{expire_date}} 占位符与「为什么/该做什么」问答段;--old-domain 默认 box.tama.guru、--expire-date 默认 2026-12-24(均可覆盖)。
|
||||
- 【验证】py_compile OK;四语言 build_message 解码核验:新域名/旧域名/2026-12-24/301 均就位、无残留 {{占位符}}、text/plain+text/html 双部。
|
||||
|
||||
## 通知脚本二次微调(硬编码域名 + 价格模糊 + 无语言默认简中)
|
||||
- 【改动】① 新旧域名+到期日改为模块级硬编码常量 NEW_DOMAIN=box.shiroko.one / OLD_DOMAIN=box.tama.guru / EXPIRE_DATE=2026-12-24,删除 --old-domain/--expire-date 参数;site 与 box_link 改用硬编码新域(不再依赖 app.external_url,故移除其空值中止判断)。② 四语言「为什么换」文案去掉具体金额与「遭不住」,改为「.guru 连续涨价/续费成本越来越高」把原因写清。③ 语言回退:空语言直接→zh-CN(不再先落 default_lang);未知语言→default_lang→zh-CN。
|
||||
- 【验证】py_compile OK;源码 grep 无「三百/over 300/年間300/遭不住」;四语言渲染含 box.shiroko.one/box.tama.guru/2026-12-24/301、无占位符;回退逻辑 空/未知→zh-CN、en→en。
|
||||
|
||||
## 通知脚本加「断点续发」+「按语言分群」
|
||||
- 【改动】① 断点续发:新增 load_sent_state/record_sent/reset_state(JSONL 逐行追加,崩溃安全);默认状态文件 DEFAULT_STATE=脚本同目录 notify_state.json(--state 可改);发送前 load 已发邮箱集合并过滤,打印「已跳过 N 封 / 剩余待发送 N 封」;每成功一封 record 一行;--reset-state(仅 --send 时)清空后全量重发;测试 --to 模式不写状态避免污染。② 按语言分群:resolve_lang() 统一语言解析(空→zh-CN、未知→default_lang→zh-CN);发送前打印「按语言分布」统计;main 发送循环改为按 LANG_ORDER=[zh-CN,zh-TW,en,ja]+其他 分组,每组打印「[语言分组 gi/gn] 语言(标签) : 本组 N 封」,组间 --group-pause 默认 3.0s(0 关);组内仍走 --delay/--pause-every。
|
||||
- 【验证】py_compile OK;monkeypatch DB+SMTP 实测:RUN1(--limit 3)→发3、state 3 行且分组正确;RUN2(同 state 续发)→跳过3、发剩余3(zh-CN 组2+ja 组1)、state 6 行;RUN3(再续发)→跳过6、发0;RUN4(--reset-state)→清空并全量重发2。空语言/未知语言均归 zh-CN。
|
||||
- 【增强】捕获 send_message 返回字典(RCPT TO 阶段被拒收的 {addr:(code,err)}),非空时计为「[SMTP 拒收]」失败(不计入 sent、不写断点状态、可续发重试),避免误把"已被拒收"算成成功。验证:2 收件人第 2 封返回 550 拒收→成功1/失败1、state 仅1行。
|
||||
- 【用户现象】实测运行时显示"成功 N 封"但收不到信;定位:SMTP 250 OK 仅代表 RCPT TO 接收,不等于投递成功;截图收件人全是 *@admin.com(含 admin3x/admin4 等大概率不存在账号),最可能是收件不存在→退信回到 From(mail_account) 而未被监控,或发件域 SPF/DKIM 未对齐被静默丢信。建议用 `--to 真实外部邮箱 --send` 隔离验证。
|
||||
- 【增强】notify_domain_change.py 加「监控报告」+「测试模式命中数据库」:① 每次运行结束写 notify_report.json(JSON,含 db_stats/lang_distribution/sent/failed/smtp_rejected/skipped_already_sent/failed_list/config(无密码)/test_mode),`--report PATH` 改路径、`--no-report` 关闭,dry-run 与 send 都会写;② `--to 邮箱` 先查库,命中则套用该用户姓名/域名/语言,未命中按 zh-CN。验证:en 命中→英文主题、未命中→中文主题、报告字段与 SMTP 拒收单独计数均正确。
|
||||
|
||||
## 通知脚本模板修正(box_link 路径 + 头部致谢/群发 + 底部版权)
|
||||
- 【用户反馈】实测邮件内容两点问题:① 提问箱个性域名实际为 `box.shiroko.one/_/xxx`(项目 templates/profile.html 与 base/footer.html 均为 `/_/{domain}` 路径),脚本写成 `box.shiroko.one/xxx` 不对;② 邮件头部应先感谢用户使用提问箱并说明是群发站内信;③ 底部加 `© 2026 狼的提问箱`。
|
||||
- 【改动】发送循环 box_link 由 `https://{NEW_DOMAIN}/{domain}` 改为 `https://{NEW_DOMAIN}/_/{domain}`;四语言 HTML+纯文本模板(zh-CN/zh-TW/en/ja)头部问候后新增「首先感谢使用(狼的提问箱)」+「这是群发的站内通知邮件,请放心查阅」两段;底部系统通知行追加 `© 2026 狼的提问箱`(英 Wolf's Question Box / 日 狼の質問箱 / 繁 狼的提問箱)。
|
||||
- 【验证】py_compile OK;四语言解码核验:box_link 含 `/_/`、致谢+群发+版权关键词均就位、无残留 {{占位符}}。
|
||||
|
||||
## 通知脚本去外部依赖(零 pip 安装)
|
||||
- 【用户痛点】当前脚本依赖 psycopg2-binary(Postgres)/ pymysql(MySQL),但环境 pip install 基本不可用,装不了第三方包。
|
||||
- 【改动】DB 层改为「零依赖优先」:默认 auto 驱动优先调用系统自带命令行客户端 psql(Postgres)/mysql(MySQL),纯标准库 subprocess,无需 pip;仅当客户端都不存在时才回退 psycopg2/pymysql(可选 pip 安装)。新增 _build_pg_uri(密码 URL 编码)、_choose_driver、_psql_query(-X -t -A -F '\x1f' -P null=\N -P footer=off)、_mysql_query(临时 cnf 传密码避免进程参数泄露)、_lib_query、run_query 统一入口;fetch_recipients/fetch_counts 改为接收 args 并调 run_query;新增 --db-driver {auto,psql,mysql,psycopg2,pymysql};load_config 增加 db_sslmode(默认 disable,与旧 psycopg2 行为一致);PG 统计用 COUNT FILTER(WHERE...) ,MySQL 用 SUM(CASE...);main 打印 [DB 驱动]。
|
||||
- 【验证】py_compile OK;monkeypatch 实测:驱动选择(pg有psql→psql / mysql有mysql→mysql / 无客户端但可import psycopg2→lib / 全无→psql报错路径);psql 路径解析(含空语言/空名/语言NULL→空);mysql 路径解析;E2E 经 psql 路径 dry-run 写报告、send 6 封(按 zh-CN3/zh-TW1/en1/ja1 分组、空语言+未知fr回退zh-CN)、RESUME 跳过6发0、--to 命中库用户发往其邮箱。
|
||||
- 【结论】现在只需 Python 标准库 + 服务器上的 psql/mysql 客户端即可运行,彻底不依赖 pip。
|
||||
|
||||
## 通知脚本 bug 修复:language 列不存在导致 FATAL
|
||||
- 【现象】用户用 `python3 notify_domain_change.py -c ./auto/conf/app.ini --to [email protected] --send` 报 `ERROR: column "language" does not exist`。
|
||||
- 【根因】GORM 模型 `User.Language` 默认 column=language,但线上 `nekobox` 库 schema 较旧,users 表无 language 列(可能叫 lang 或根本没有该列)。脚本此前在 fetch_recipients 里硬编码 `SELECT ..., language FROM users`。
|
||||
- 【修复】新增 resolve_lang_column(cfg, args):默认 auto 探测 information_schema.columns 的 candidates(language/lang),找到即用、都不存在返回 None(调用处用 `NULL AS language` 兜底,全员回退默认语言);新增 `--lang-column` 参数可手动指定(如 --lang-column=lang)。fetch_recipients 改用探测到的列名拼 SQL;main 打印 `[语言列]` 行。mock 验证 4 场景(lang 命中/lang 缺失→NULL/显式指定/language 命中)均正确。
|
||||
- 【给用户建议】直接重跑即可;若库里列叫 lang 会自动识别,若真无该列则全员按 [app] default_lang(ja) 模板发送,不影响发信。
|
||||
|
||||
## 通知脚本「依旧报错」二次排查:版本标记 + 改名验证法
|
||||
- 【现象】首轮修完用户报「依旧不行,报错一致」。初判为用户在 Linux 服务器跑的是仓库根 `/home/tamakyi/Github/Tamabox_v4/notify_domain_change.py`(state_file 路径证伪),而工作区仅 `dev/notify_domain_change.py` 一份;用户坚称没用错文件。
|
||||
- 【加版本标记】SCRIPT_VERSION = "2026-09-06.langdetect.v2",main 配置头打印 `[版本] SCRIPT_VERSION = ...`。作用:运行输出若无此行即证明跑的不是最新副本,把"是否用错文件"变成客观判定(不再靠争论)。
|
||||
- 【改名验证】建议用户在服务器 `mv notify_domain_change.py notify_mail_v2.py` + `find . -name __pycache__ -exec rm -rf` 后跑新名,强制重编译以排除陈旧 .pyc / 同名模块作祟(用户自己提出的怀疑点)。
|
||||
- 【结论】修复后版本不可能再出现 `column "language" does not exist`(查询已无 language 字面量);若改名+清缓存后仍报该错且带 [版本] 行,则需查 information_schema 探测是否异常。
|
||||
@@ -1,83 +0,0 @@
|
||||
# 2026-09-07
|
||||
|
||||
## notify_domain_change.py 语言列探测 v3(langprobe)
|
||||
- 上轮 information_schema 探测在线上误报(探测说 language 存在、真实查询报列不存在,疑似跨 schema 同名表干扰)。
|
||||
- 本轮重写为「真实试跑探测」`detect_lang_column()`:依次执行 `SELECT ... language / lang / NULL`(LIMIT 1),哪个能跑通用哪个;NULL 兜底必然成功,不会再因列缺失 FATAL。
|
||||
- `fetch_recipients(cfg, args, lang_expr=...)` 与 `main` 调用链对齐;版本号提升为 `2026-09-06.langprobe.v3`。
|
||||
- 6 场景 mock 验证全过:language 报错→降级 lang;全缺失→NULL 回退 default_lang(ja);显式 --lang-column;自动探测拼 SQL 正确。
|
||||
- 测试脚本两处自身 bug(dict 键名 language 非 lang;mock 对 NULL 查询仍返回 'en')已修正后 ALL_PASS。
|
||||
|
||||
## v4(rfc5322.schema):QQ 邮箱 550 退信 + 统计全 0 两个问题
|
||||
- 550 退信根因:`From: 狼的提问箱 <tamabox@...>` 中文显示名未做 RFC2047 编码,QQ(mx3.qq.com)回 550 "From header is missing or invalid"。修复:`formataddr((str(Header(显示名, utf-8)), 地址))` 编码 From,Subject 同样用 Header 编码,并补 Message-ID。
|
||||
- 统计全 0 根因:psql 未限定 schema,search_path 命中了别的 schema 里的同名空 users 表(也解释了 v2 information_schema 探测误报 language 存在)。修复:新增 `resolve_users_table()` 枚举所有 schema 的 users 表并逐个 COUNT,选有数据(其次带 language 列)的 schema,全部查询用 `"schema".users` 限定;main 打印 `[DB 连接]` 与 `[users 表]` 诊断行,全空时警告。
|
||||
- 5 场景 mock 验证 ALL_PASS(schema 定位/语言探测/counts/全空回退/From+Subject 编码与 Message-ID)。版本 `2026-09-07.rfc5322.schema.v4`。
|
||||
|
||||
## 新项目 mail-broadcast/(站内信群发工具)
|
||||
- 用户确认 v4 后收件正常、数据正确,但邮件里提问箱链接显示成站点首页而非 `/_/个性域名`;要求新建独立可复用项目、四语言模板拆成独立 HTML。
|
||||
- 新建 `mail-broadcast/`:`broadcast.py`(通用化:去掉硬编码域名,`--site-url`/`--box-prefix`/`--var k=v` 自定义占位符;缺失占位符置空并上报)+ `templates/{zh-CN,zh-TW,en,ja}.{html,subject.txt}`(plain 可选,缺省由 HTML 自动抽取纯文本,HTMLParser 实现)+ README.md。
|
||||
- 保留 v4 全部 DB/SMTP 能力:schema 定位、语言列试跑探测、断点续发、分群限速、SMTP 拒收捕获、RFC2047 From/Subject、Message-ID。语言回退保持「空→zh-CN、未知→default_lang→zh-CN」。
|
||||
- 预览区直接打印每个收件人解析后的 box_link,方便发现链接问题。
|
||||
- 7 场景 mock 测试 ALL_PASS(含四语言渲染 box_link、缺失占位符上报、From 编码)。
|
||||
|
||||
## params.ini 参数文件 + --interactive 交互模式
|
||||
- 用户新需求:运行时直接读参数文件,或运行后再交互。
|
||||
- 新增 `params.ini`([site] site_url/box_prefix + [vars] 自定义占位符 + [send] to/delay/group_pause),默认自动读取脚本同目录 params.ini,`--params`/`--no-params` 可控;优先级 CLI > 文件 > 默认。
|
||||
- 安全边界:参数文件不提供 --send/--yes,正式发送必须命令行显式指定 + 终端确认。
|
||||
- `--interactive/-i`:逐项确认 site_url/box_prefix,自动扫描模板占位符(collect_template_placeholders 排除内置)逐个提问(模板加新占位符会被自动发现),可选测试邮箱,可选保存回 params.ini(save_params)。
|
||||
- 踩坑:测试脚本两次自身 bug(回答顺序没按字母序、Args 类属性 vs 实例属性);跑挂的测试曾把 params.ini 写坏,已恢复标准值。7 场景 ALL_PASS。
|
||||
|
||||
## conf 路径纳入 params.ini 与交互模式
|
||||
- params.ini 新增 `[path] config`(预填 ./auto/conf/app.ini);`-c` 默认值改为 None,解析优先级:CLI > params.ini > 环境变量 TAMABOX_CONFIG_PATH > ./conf/app.ini。
|
||||
- `--interactive` 第一步先问配置文件路径(在 load_config 之前),保存回 params.ini 时含 config 路径(save_params 签名加 config 参数)。
|
||||
- 5 场景测试 ALL_PASS(params 读取/优先级/save round-trip 含 config/交互完整流程/标准值恢复)。
|
||||
|
||||
## 交互选运行方式 + 模板对齐原程序邮件样式
|
||||
- 用户新需求:① send 之类的决策放进交互里而不是命令行;② 模板配色样式参照原程序 templates/mail/。
|
||||
- run_interactive 重构:占位符问完后进入「运行方式」选择(1 演练/2 测试/3 群发,默认回车=演练);模式 2 问测试邮箱、模式 3 可问 limit(试水)并可选择是否保留最终确认(答 n → args.yes=True)。args.send/to/yes/limit 就地写入,交互全程无需 --send/--to/--yes。
|
||||
- 四语言模板重写为原程序 Google 风格:白底 #FFFFFF、#dadce0 细边框 8px 圆角卡片(max-width 516px)、标题 24px 带下边框、正文 Roboto 14px rgba(0,0,0,0.87)、次要信息 rgba(0,0,0,0.54)、新域名高亮框 #e8f0fe/#c6dafc、按钮 #4184F3 蓝底白字、页脚 11px rgba(0,0,0,0.54)。新增 `{{year}}` 内置占位符(ctx 用 datetime.now),run_interactive 的 builtin 集合同步排除。
|
||||
- 测试踩坑:render() 返回 (结果, missing) 元组需解包;群发路径交互共 8 次输入。24 项检查 ALL_PASS(四语言 box_link/按钮链接/配色/#year/占位符完整 + 4 交互场景 + From 头 ASCII)。
|
||||
|
||||
## 默认交互模式 + 单文件通用模板(--single)
|
||||
- 用户新需求:① 默认就是交互模式不用手输;② 新增单文件模式(不按语言,所有人发同一个通用模板,模板单独存放便于以后复用)。
|
||||
- 默认交互:`--interactive` 改 default=None,parse 后判定——未给 --send/--to/--yes/--dry-run 之一即自动进入交互;`--no-interactive` 强制关闭(脚本化用)。
|
||||
- 单文件模式:templates/single.html + single.subject.txt(保留名,load_templates 排除不混入语言列表;load_single_template 单独加载,plain 自动抽取)。`--single` CLI 或交互「模板模式」选 2。发送时 grouping 固化为 single 组(不 resolve_lang),ctx 仍含每人 box_link/name/site/year;报告加 template_mode。
|
||||
- single.html 样式对齐原程序邮件,正文内容区用 HTML 注释标注「编辑这里」,含可复制的强调框示例。
|
||||
- 15 项检查 ALL_PASS(single 不混语言/加载/渲染/构建 + 4 交互组合场景)。
|
||||
|
||||
## 交互只问缺失项(不再每次重复确认战役参数)
|
||||
- 用户反馈:site_url/box_prefix/old_domain/expire_date 这类一次性参数每次交互都问很烦,直接写死到这次 html 即可。
|
||||
- run_interactive 重构:开头打印「[参数确认]」摘要(沿用 params.ini/CLI/app.ini 的值),**有值一律不问**;只补问 ① 完全没值的 site_url(main 里先做 external_url 兜底)② 模板用到但 extra_vars 里还没有值的自定义占位符。box_prefix 不再询问(默认 /_/,CLI 可覆盖)。
|
||||
- 配置文件路径同理:CLI/params.ini 明确给了就打印沿用不问,仅默认猜测时才 ask。
|
||||
- 想改值:编辑 params.ini,或把一次性内容写死进模板 html 并删掉对应 {{占位符}}(占位符删了就不会被问)。
|
||||
- 测试踩坑:mock 回答数按实际提问数对齐(占位符 2 个非 3 个;摘要走 print 非 input 需 redirect_stdout 捕获)。7 项检查 ALL_PASS。
|
||||
|
||||
## 文案修正:模板问句人称
|
||||
- 用户指出邮件模板「我该做什么?」应为「你该做什么?」(站方对收件人的口吻)。
|
||||
- 已修 templates/zh-CN.html 与 zh-TW.html 对应问句;渲染验证通过、无残留。en(What should I do?)/ ja(何をすればよいですか?)保持读者视角的惯用表达未改。
|
||||
|
||||
## --limit 与断点续发顺序 bug 修复
|
||||
- 用户反馈:第一次发送部分成功,第二次 --limit 10 时「已跳过 10、剩余 0、一条没发」。
|
||||
- 根因:main 中 --limit 截取在断点续发过滤之前执行,limit 永远取名单头部那批已发用户,跳过后剩 0。
|
||||
- 修复:调整顺序为「先剔除已发 → 再取前 N」,--limit 提示语带上已剔除数量;补 had_state 标志控制「剩余待发送」打印。
|
||||
- 6 项回归测试 ALL_PASS(跳过+limit 组合、无重叠、limit>剩余、全已发、状态读写往返)。
|
||||
|
||||
## 退信核查功能(bouncecheck.v2)
|
||||
- 背景:SMTP 250≠送达,阿里云对限流/无效地址先收下再异步退信到发件箱,用户要求发送后核查真实送达。
|
||||
- 新增零依赖 IMAP 退信扫描:--check-bounces 登录发件箱(imaplib 993,IMAP 域名由 smtp.→imap. 推导),两段式拉取(先头部命中退信特征再取全文),--since-days 默认 3。
|
||||
- 退信解析三层:标准 DSN(message/delivery-status, Action:failed)→ 中文「收信地址/收件人」标签 → 正文地址兜底(剔除发件人与 daemon/postmaster);主题关键词 + multipart/report 双判定。
|
||||
- --prune-state 把退信用户从 broadcast_state.json 剔除 → 重跑自动补发;已核查 Message-ID 存 broadcast_bounces_seen.json 防重复报告。交互运行方式新增「4) 退信核查」(并移到模板模式之前,该模式跳过模板提问)。
|
||||
- 12 项单元测试 ALL_PASS(含用户真实退信格式、标准 DSN、限流退信全角冒号、prune 往返)。防御修正:正则加 (?<![A-Za-z0-9]) 防乱码前缀吞地址;测试须用 message_from_bytes 贴近 IMAP 真实路径(message_from_string 双编码会污染解析)。
|
||||
|
||||
## 退信核查拆分三档(仅核查/仅剔除/剔除并补发)
|
||||
- 用户要求「仅剔除、不一定剔除后直接发送」。交互运行方式 4 下新增子选项:1 仅核查(默认,不改清单)/ 2 仅剔除(更新清单后结束,本次不发送)/ 3 剔除并补发(剔除后继续正常发送流程,发送前仍有统计确认)。
|
||||
- CLI 对应:--check-bounces 仅核查;--check-bounces --prune-state 仅剔除;--prune-state --send 剔除并补发(main 分支在 args.send 时不 sys.exit,落回正常发送流程)。
|
||||
- 4 项交互 mock 测试 ALL_PASS;README 同步。
|
||||
|
||||
## 退信扫描起点改为群发首条记录
|
||||
- 用户反馈:固定近 3 天会把群发无关的退信/邮件扫进来。
|
||||
- 新增 resolve_scan_start():auto(默认)取 broadcast_state.json 最早一条 ts - 1 天余量;--since YYYY-MM-DD 显式指定;无记录/无 ts 回退 --since-days(默认3)。IMAP search 用 SINCE 日期,控制台打印 [扫描起点] 说明。
|
||||
- 6 项测试 ALL_PASS(最早记录-1天/显式日期/非法日期回退/无清单回退/无 ts 回退)。
|
||||
|
||||
## 移除扫描起点 -1 天余量
|
||||
- 用户反馈:提前 1 天余量导致扫描范围仍偏大。改为 auto 模式精确从群发首条记录的 ts 开始(不再 -1 天);--since 显式日期与空清单回退 --since-days 逻辑不变。
|
||||
- 3 项测试 ALL_PASS(精确首条 ts/显式日期/空清单回退);README 同步。
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
## 安全告警邮件增加 IP 网络归属(2026-09-08)
|
||||
|
||||
- 需求:autoban 批量扫描告警邮件中展示来源 IP 的云厂商/CDN 归属(用户提到"类似 cloudranger"),选定方案 A:`projectdiscovery/cdncheck v1.3.0`(MIT,离线签名,零外部请求)。
|
||||
- 新增 `internal/security/ipinfo`:`Lookup(ip) (label, proxied)`,依次查 CDN→WAF→Cloud,错误折叠为"识别失败"标签,不中断告警链路;缩写映射 aws→AWS 等。
|
||||
- `mail.SendSecurityAlertMail` 增加 `provider`/`proxied` 参数;`route.go` SendAlert 闭包注入 lookup;模板加「网络归属」行 + 命中代理网络时的红色提示块。
|
||||
- 验证:全模块类型检查 + 相关包测试通过(s3.go 磁盘换桩法);smoke 实测 1.1.1.1→Cloudflare(WAF)、52.95.255.1→AWS(云厂商)。
|
||||
- 新增回归测试:`internal/mail/mail_alert_test.go`(模板渲染含 proxy_hint 条件分支)。
|
||||
- 教训(重要):本机 Windows + go1.25.5 下 `-overlay` 行为异常(静默不生效 / "deleted in overlay"),**overlay 不可靠;本地验证用磁盘换桩法**:`cp dev/s3stub/s3_stub.go internal/storage/s3.go`(先备份)→ build/test → 还原。chai2010/webp 是 cgo 库,用户生产编译在 Linux,无需本地真编译。
|
||||
- `go mod tidy` 顺带升级了部分共享依赖(lipgloss 0.13、xid 1.5、testify 1.11 等),全量构建+测试通过。
|
||||
- dev/overlay.json 现指向 dev/s3stub/s3_stub.go(旧值指向已删除的 Downloads 副本,已过时)。
|
||||
|
||||
## autoban 线上触发实测(box-dev-api.337845818.best,2026-09-08 13:38)
|
||||
|
||||
- 基线:/ → 200;敏感路径 → 302(业务跳转,但会计入统计)。
|
||||
- 60s 窗口内连发 18 个敏感路径:前 ~9 个 302,第 ~10 个起全部 403;封禁后连首页也 403 "403 Forbidden"(中间件先于业务路由,全站拦截)。
|
||||
- ⚠️ 触发点 ≈ 9~10 次,不是配置的 15:疑似部署侧仍是默认阈值(DefaultThresholdSensitive=10)。需检查服务器 conf/app.ini [security] ban_threshold_sensitive = 15 是否落盘并重启服务。
|
||||
- 本次触发应已发出一封告警邮件(每 IP 仅一次),可借机验收新的「网络归属」字段。
|
||||
- 本机出口 IP 已被临时封禁(默认 15 分钟自动解除),期间再测会 403。
|
||||
@@ -1,452 +0,0 @@
|
||||
# 2026-09-10
|
||||
|
||||
## 负面情绪提问拦截方案调研(评审,未改代码)
|
||||
|
||||
- 盘点提问链路现有 5 道防线,结论:**无任何情绪/意图维度判定**,全部是字面/合规/可用性维度。
|
||||
- 已验证的代码事实(后续复用):
|
||||
- 文本审核走七牛 `POST https://ai.qiniuapi.com/v3/text/censor`,scene=`antispam`,10s 超时(`internal/security/censor/qiniu.go:85`)。
|
||||
- `IsPass()` 只硬拦 `politics/terrorism/porn`,且 `review` 一律放行 → `abuse`(辱骂)实际漏过(`qiniu.go:55-68`)。
|
||||
- `censor.go:50-53` 命中 31 天缓存后**重新用 parser 计算 Pass**,缓存不锁死判定 → 收紧 `IsPass()` 无需清缓存。
|
||||
- 拉黑 `user_block` 只作用于私聊(`route/question/page.go:368` 在 `StartChat` 分支内),**不拦提问**。
|
||||
- 项目无 at-rest 加密(grep aes/Encrypt 无结果);无队列/Worker,异步只有 `shared.SafeGo` 裸 goroutine;Redis 仅 session/cache。
|
||||
- 有内存滑动窗口限流器 `internal/route/ratelimit.go`,可复用做单 IP 限流。
|
||||
- `conf.Limits.QuestionContentLen` 默认 1000;`AnswerContentLen`/`FollowUpContentLen` 默认 2000(= 七牛文本 2000 字上限的边界)。
|
||||
|
||||
## 重要背景更正(用户指出)
|
||||
|
||||
- **图片/文本审核服务是七牛云,不是阿里云**。阿里云通道虽仍在 `censor.go` 代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效。后续所有审核相关评估以七牛为准。
|
||||
|
||||
## 七牛云能力(官方文档已验证)
|
||||
|
||||
- 文本审核 label 9 类:normal/spam/ad/politics/terrorism/**abuse**/porn/flood/contraband/meaningless;suggestion: block/review/pass + score;≤2000 字;不支持白名单(拉黑词库要提交工单)。
|
||||
- 七牛自有 LLM(MaaS):`https://api.qnaigc.com/v1`,兼容 OpenAI `/v1/chat/completions` 与 Anthropic `/v1/messages`,50+ 模型,**用独立 API Key(非 AK/SK)**。
|
||||
|
||||
## 产出
|
||||
|
||||
- `outputs/llm-moderation-feasibility-2026-09-10.md`:LLM 内容审核方案可行性评审(数据流/失败降级/配置校验/Key 安全/邮件告警/成本限额)。
|
||||
- 4 个 P0:SSRF(用户自填 API 地址,连通性测试即探测入口)、Key 明文落库、用户 Key 被刷造成损失、失败即发信导致邮件轰炸。
|
||||
- 核心建议:分层 L1 收紧七牛 `IsPass()`(零成本)→ L2 本地情绪词库 → L3 LLM 只补七牛覆盖不到的语义部分。
|
||||
- 待用户确认 8 点,最关键:命中后是"提示+仍提交"还是"硬拦"。
|
||||
|
||||
## 追加修订(用户实测反馈)
|
||||
|
||||
- **用户反馈:七牛对「辱骂」判定过严,正常对话经常误伤** → 撤回原 L1 建议(把 abuse 纳入硬拦),照做会放大误伤。
|
||||
- 误伤两个真实成因(已代码复核):
|
||||
1. 七牛把正常内容判 `abuse` 且 `suggestion=block` → `qiniu.go:55-68` 拒绝 → 提问/私聊直接发不出去;
|
||||
2. **审核服务不可用时 fail-closed 拒绝**(`page.go:574`、`chat_handler.go:258`)→ 七牛抖动期间全站发不出内容。
|
||||
- 关键约束:七牛文本审核**不支持白名单**、自定义词库需提交工单 → 无法调准也无法调松,**唯一可控的是"命中后怎么办"**。
|
||||
- 修订原则:**显式规则硬拦,算法推断只标记**。箱主自定义屏蔽词+高危三类(politics/terrorism/porn)硬拦;abuse/spam/ad/flood/contraband/meaningless 只打标记照常送达;审核不可用改 fail-open+标记待审。
|
||||
- 落地:questions/messages 加标记列(AutoMigrate 自动加列)+ 后台折叠/筛选 + 设置页「审核严格度」三档(严格/宽松(默认)/仅标记)+ 用 `detail.Score` 自建阈值(默认偏高)。
|
||||
- 该路线零延迟零外部依赖,顺带消掉 SSRF/Key 明文/Key 被刷/邮件轰炸 4 个 P0。
|
||||
|
||||
---
|
||||
|
||||
## 「审核严格度」实现(已落地,构建/vet/测试全绿)
|
||||
|
||||
**备份(回滚锚点)**:`C:/OtherFiles/Github/TamaBox-v4-mainline-pre-moderation-strictness-20260910.tar.gz`
|
||||
(git 仓库损坏,用户指示「不要动 git」,改用 tar.gz;304 条目,sha256 `c6cfce2b...`)。回滚 = 解压覆盖回工作目录。
|
||||
|
||||
**核心设计:把「是否拦截」从审核服务自身的 Pass 判定中剥离**,新增策略层 `internal/security/censor/policy.go`。
|
||||
- 档位:`lax`(仅底线)/ `standard`(底线 + abuse≥0.9 + contraband)/ `strict`(底线 + 全部非 normal 维度不限分 + 采信服务 block 建议)。
|
||||
- 底线 `baselineBlockLabels`:politics / terrorism / porn / **customized**;任何档位无条件拦截、不可关闭(`Decide` 中排第一、无条件 return)。
|
||||
- 判定顺序固定:底线 → 档位维度(带分数门槛)→ 服务建议(仅 strict)。
|
||||
- 缺省 `DefaultStrictness = strict`;非法值 `ParseStrictness` 一律回退 strict(保守)。
|
||||
|
||||
**⚠️ 重要事实修正(本次发现)**:strict 档 **并非**「与升级前等价」,而是 **⊇ 升级前**。
|
||||
- 升级前拦截条件 = `!Pass` = (politics|terrorism|porn) 或 服务 suggestion==block(见备份 `route/question/page.go:443`)。
|
||||
- strict 额外把 abuse/spam/ad/flood/contraband/meaningless/harmful 全部纳入(不限置信度)→ 比升级前更严。
|
||||
- 已把散落各处「升级不改行为 / 行为等价」的错误注释全部改正(policy.go / moderation.go / conf/static.go / app.sample.ini / db/users.go / i18n 文案)。
|
||||
- **待用户拍板的取舍**:DefaultStrictness 是否维持 strict。若用户首要诉求是「减少误伤」,应改为 standard(一行常量);维持 strict 则部署后默认拦截面略扩。
|
||||
|
||||
**阿里云兼容(用户本轮要求,已完成)**:
|
||||
- **rate 量纲 bug(关键)**:阿里云 `rate` 是 0~100,七牛 `score` 是 0~1;不归一化会让 `abuseScoreFloor=0.9` 在阿里云下恒成立、standard 档分级失效。已加 `normalizeAliyunRate()`(>1 则 /100,clamp 0~1)。
|
||||
- 补映射 `harmful→ForbiddenTypeHarmful`(覆盖用户最初想要的「负面情绪」,仅在 strict 档生效)、`customized→ForbiddenTypeCustomized`(进底线)。
|
||||
- 未映射 label 不进 `Details`(无法按维度裁决);`results` 为空时 `ProviderSuggestion=block`(对齐 fail-closed)。
|
||||
- 结论:**可直接复用,无需改调用方**;只需上述 parser 层归一化/映射,三家通道(七牛/阿里云)共用同一 `Decide`。
|
||||
|
||||
**统一入口**:`internal/security/moderation/moderation.go`
|
||||
- `Check(ctx, strictness, lang, text)` = 调审核 → `censor.Decide` → 返回 err(本地化)。审核不可用仍 fail-closed。
|
||||
- `StrictnessFor(raw)` = 账号设置优先,空则跟随 `conf.Security.DefaultModerationStrictness`,再回退 strict。
|
||||
- 三场景入口已全部改走 `moderation.Check`(grep 可复核):
|
||||
- 提问/回答:`route/question/page.go:441,574`、`question.go:170`、`follow_up.go:105,164`
|
||||
- 投票:`route/vote/vote.go:72`、`route/user/vote.go:67,159,1012`
|
||||
- 私聊:`internal/route/chat/chat_handler.go:259`(用 `pageUser`=箱主,非访客)
|
||||
|
||||
**改动文件清单**:
|
||||
- 新增:`internal/security/censor/policy.go`、`policy_test.go`、`internal/security/moderation/moderation.go`、`route/user/moderation.go`、`templates/user/moderation-strictness.html`
|
||||
- 修改:`censor/{text,qiniu,aliyun}.go`、`conf/static.go`、`db/users.go`、`form/user.go`、`internal/route/route.go`、`route/question/{page,follow_up,question}.go`、`internal/route/chat/chat_handler.go`、`route/vote/vote.go`、`route/user/vote.go`、`i18n/dict_{zh_cn,zh_tw,en,ja}.go`、`templates/user/profile.html`、`templates/render_test.go`、`conf/app.sample.ini`
|
||||
- 设置页表单分别挂在 profile 的 提问/私聊/投票 三个面板。
|
||||
|
||||
**验证方式(Windows 沙箱无 libwebp/gcc,需 overlay)**:
|
||||
```
|
||||
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||||
go test -overlay="..." ./internal/security/... ./internal/i18n/... ./templates/... ./route/... ./internal/route/...
|
||||
```
|
||||
结果:build 0、`go vet ./...` 0、上述测试全 ok。新增 6 个阿里云/策略用例(normalizeAliyunRate / AliyunParserFeedsPolicy / CustomizedHitsBaseline / NoResultsFailsClosed / UnmappedLabelSkipped)。
|
||||
注:`internal/db` 测试需真实 DB,环境缺失,与本次改动无关;`censor.go`、`text_test.go` 存在**升级前就有**的空格缩进(gofmt -l 会列出),未动以避噪音。
|
||||
|
||||
---
|
||||
|
||||
## 第二轮:改动回归审阅(与备份全量 diff 逐项复核)
|
||||
|
||||
方法:把备份 tar.gz 解到 `%TEMP%/tb-review/base`,对工作区做 `diff -rq` 拿到精确变更集,再逐项查 bug。
|
||||
**结论:无 P0(无安全/正确性回归)**。关键安全性质已核实:strict 档拦截条件 **⊇ 升级前**,故默认档下不可能出现「以前拦、现在放」。`censor.Text` 的 31 天缓存命中后**重新走 parser**(`censor.go:50/61`),`Details`/`ProviderSuggestion` 会被重建,不存在「缓存命中导致档位判定失效」。`EnableTextCensor=false` 时 parser 返回 `{Pass:true}` 且 Details 为空 → `Decide` 放行,开发环境不受影响。
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
- **🔴 P1 设置页档位回显错误(真实 bug)**:`moderation_strictness` 为空(存量账号,语义=跟随站点默认)时,模板三个 `option` 的 `eq` 全为假 → 浏览器默认选中第一个选项「宽松」,而实际生效是站点默认(通常 strict)。界面显示的档位与真实拦截行为**相反**,直接违背「让用户在切换前就知道会被拦什么」的需求。
|
||||
- 修法:新增模板函数 `moderationStrictness`(`internal/template/template.go`,内部调 `moderation.StrictnessFor`,与 policy 同源),模板改用 `$current := moderationStrictness $root.LoggedUser.ModerationStrictness` 回显。
|
||||
- 未用「在 Profile handler 注入 data key」的方案:全仓有 **16 处** `ctx.Success("user/profile")`(各 Update* 校验失败回显路径),漏一处就会让模板读到缺失 key。
|
||||
- 补 `TestRenderProfileStrictnessReflectsEffectiveTier`(未设置/lax/standard/strict/非法值 5 例)。
|
||||
- **🟠 P2 `censorVoteForPublish` 档位口径不一致**:同一函数内 `vote.Title` 用 `voteOwnerStrictness(ctx, vote.UserID)`,而 `Description`/选项/字段标签走 `censorCheck`(内部读 `ctx.User`)。当前因 `getOwnedVote` 强校验 `vote.UserID == ctx.User.ID` 而**不可达**(3 个调用路径 678/907/944 都归属校验过),属潜在缺陷。已改为函数开头解析一次 `strictness` 并全程复用。
|
||||
- **🟠 P2 i18n 字典 diff 污染**:插入新 flash 键时切断了原有对齐组,导致 `admin.flash.deactivated`/`admin.export.errUser`/`admin.export.errQuestion` 三行在 4 个字典里被 gofmt 重新对齐(3 行×4 文件)。已把新键移到该组之后、独立成组,恢复原 3 行。**教训:往 gofmt 对齐组中间插键会把「拆分后的剩余组」重新对齐;应插在组末尾或独立成组。**
|
||||
- **🟠 P2 死代码**:`TextCensorResponse.ErrorMessage()` 生产与测试**均无引用**(已被 `ErrorMessageFor` 取代),且其「取首个维度」的语义正是本轮要修的错误来源,留着易被误用 → 已删除。`BlockedLabels()` 仅测试使用,但注释声称「供设置页展示」→ 已改为如实描述(UI 目前用 i18n 静态文案)。
|
||||
|
||||
### 已核查为非问题(勿重复排查)
|
||||
|
||||
- `internal/cmd/censor.go:57,68` 仍直接调 `censor.Text`:这是 `tamabox censor` **回填 CLI**,只写 `content_censor_metadata` 审计字段、不做拦截放行判定,不属三场景提交闸门 → 无需改走 `moderation.Check`。
|
||||
- 全仓已无「直接判 `resp.Pass` 决定放行」的残留(仅 `db/censor_log.go` 存字段)。
|
||||
- `LoggedUser` 每个请求都由 `authenticatedUser()` 回源 `db.Users.GetByID`(`internal/context/auth.go:22`),保存后重定向读到的一定是新值,不存在「保存后下拉框还是旧值」。
|
||||
- `route/user/moderation.go` 用 `ctx.User.ID` 且挂在 `reqUserSignIn` 组内(`internal/route/route.go:374`)→ 越权改他人档位不可行;与 `UpdateHarassment/UpdateBlockWords` 同构(同样无 rateLimit,保持一致)。
|
||||
|
||||
### 待用户处理 / 决策
|
||||
|
||||
1. **`templates/user/moderation-strictness.html` 被注入 `data-page-node-id` 属性**(全仓仅此文件有,备份/其它 40+ 模板均无 → 非项目约定,疑为页面编辑器标注)。它会**污染生产 HTML**,并让 `render_test.go` 的精确子串断言失败。我未擅自删除(可能属你侧编辑器状态);但已把断言改为正则 `selectedStrictnessOptions()`,不再耦合属性顺序 → 现在两种情况下测试都稳。**请确认是否要清掉这些属性。**
|
||||
2. `DefaultStrictness` 维持 strict 还是改 standard(见上一节):待拍板,一行常量。
|
||||
3. CHANGELOG 未记入:新功能属用户可见变更,按项目惯例应在 `dev/changelog-source.md` 顶部加条目并跑 `python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。**刻意留着等你定了档位口径再写文案**(严格/标准会改变措辞)。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮:用户三项指示
|
||||
|
||||
1. **已删除** `templates/user/moderation-strictness.html` 的 `data-page-node-id` 属性(全仓无任何读取方,纯渲染噪声)。
|
||||
- ⚠️ **重要机制发现**:**用 Write/Edit 工具写这个文件会被自动重新注入该属性**(且 id 每次是新的一批);用 **shell(sed)修改则不会被注入**。判断注入方是「文件工具触发的后处理钩子」,不是文件监听。→ **该文件今后一律用 shell 修改**,不要用 Write/Edit。
|
||||
- 同时把 `render_test.go` 里原本精确匹配 `<option value="standard" selected>` 的断言改成不依赖属性顺序的 `selectedStrictnessOptions()`(正则),这样属性在不在都不会误报。
|
||||
2. **`DefaultStrictness` 维持 strict**(用户已拍板)。仅更新了 `policy.go` 的注释记录该决定与理由,代码不变。
|
||||
3. **新增 `[security] text_censor_source`** —— 此前通道链是硬编码的 `[qiniu, aliyun]`,无法选择。
|
||||
- 取值:`qiniu` / `aliyun` / `auto`(**留空同 auto,即历史行为**:七牛优先、未通过则阿里云二次复核)。
|
||||
- 常量定义在 `conf`(`CensorSourceAuto/Qiniu/Aliyun`),供 `conf.Init` 校验与 `censor.censorChain()` 共用,避免两处字符串漂移。
|
||||
- `conf.Init` 启动校验(纯函数 `validateTextCensorSource`,便于单测):
|
||||
- 审核关闭 → 不校验;留空/auto → 通过(两者都没凭据时仍不阻断启动,否则 sample 原样拷贝无法启动);
|
||||
- 显式选 qiniu/aliyun 但缺凭据 → **启动即报错**(否则运行期审核必然失败,而失败是 fail-closed,会让整站发布/私信不可用且现象隐蔽);
|
||||
- 其它取值 → 报错,**不静默回退**(拼错若被当成 auto,运维会误以为已只用七牛)。
|
||||
- `censor.Text`:循环改为 `for _, censor := range censorChain()`;新增一次性告警 `warnIfNoCensorCredentials`(sync.Once,兜 auto 无凭据的漏网情况,避免按请求刷屏)。
|
||||
- 顺带把原「HACK: Qiniu's API response is not accurate…」注释改写为如实描述链式复核语义,并说明 qiniu-only 时 `continue` 不会丢判定。
|
||||
- 文档:`conf/app.sample.ini` 在 `enable_text_censor` 下新增带注释的 `text_censor_source`。
|
||||
- 测试:新增 `internal/conf/text_censor_source_test.go`(12 例)、`internal/security/censor/censor_source_test.go`(6 例),全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第四轮:阿里云通道「response data is empty」排查
|
||||
|
||||
用户报障日志:
|
||||
```
|
||||
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
|
||||
ERRO Failed to censor text error="no censor source available" strictness=lax
|
||||
```
|
||||
(两条同时出现 = 两个通道都没给出结论 → fail-closed 拒签,即整站发布不可用。)
|
||||
|
||||
### 根因取证(读 SDK 源码 + 比对官方文档,未凭猜测)
|
||||
|
||||
1. 我们的 `aliyun.go` 与**上游 NekoBox `wuhan005/NekoBox` 的 aliyun.go 逐字节一致**,
|
||||
也与**阿里云官方 Go 示例一致**(`green.CreateTextScanRequest()` + `SetContent(json)`,
|
||||
`{"scenes":["antispam"],"tasks":[{"content":...}]}`)。→ 请求构造不是本项目引入的问题。
|
||||
2. 走通了 SDK 调用链,确认「HTTP 2xx + 合法 JSON + `data` 为空」是唯一能产出该错误的组合:
|
||||
- `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走
|
||||
`errors.Wrap(err, "text scan")`,不会是这条消息;
|
||||
- `response.go:58` 只有 `AcceptFormat == JSON` 才尝试解析进 `TextScanResponse`(空结构体,必然成功);
|
||||
- 我们的 parser 用的是 `resp.GetHttpContentBytes()`,body 非 JSON 会报 `unmarshal response`。
|
||||
3. 顺带确认真实端点解析正确:`green.EndpointMap` **不含 `cn-shanghai`**,但会一路回落到
|
||||
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`(正确)。
|
||||
4. 找到一处**确定的代码缺陷**(不是根因,但让根因不可见):旧 parser 在 `len(Data)==0` 时
|
||||
只报 `response data is empty`,把顶层 `code` / `msg` / `requestId` 全丢了 → 用户拿不到任何线索。
|
||||
|
||||
### 已落地改动(`internal/security/censor/aliyun.go` + `policy_test.go`)
|
||||
|
||||
- 抽出 `newTextScanRequest(text)`(便于测试断言请求形态);**显式 `req.SetContentType(requests.Json)`**。
|
||||
SDK 的 ROA 默认值是 `application/octet-stream`(`roa_signature_composer.go:88`)。此改动属
|
||||
**按接口契约对齐**(对已正常工作的环境无副作用),**是否为本例根因未证实**,以下一次日志为准。
|
||||
- 顶层 `code != 0 && != 200` → 全新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..`
|
||||
(与旧「data 为空」同为错误返回,控制流/语义不变)。
|
||||
- `data` 为空 / JSON 解析失败 / 请求体回显:错误里带上 `code/msg/requestId` 与原始响应摘要
|
||||
(`aliyunRawDigest`,按字符截断 ≤512,避免切坏多字节)。
|
||||
- `Censor()` 里 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body。
|
||||
- `data[].code != 200` 仍 fail-closed 置 block(**语义未改**),但在真正决定 fail-closed 的位置补了
|
||||
一条 Warn(code/msg/taskId/requestId)——否则「审核失败」会伪装成「命中违规」而无迹可循。
|
||||
- 顺手修掉 `Censor()` 上错误的文档链接(原指向七牛文档 → 改为阿里云 70439)。
|
||||
- 新增测试:`TestNewTextScanRequestShape`、`TestAliyunRejectedRequestSurfacesCodeAndMsg`、
|
||||
`TestAliyunEmptyDataErrorCarriesRawResponse`、`TestAliyunInvalidJSONErrorCarriesRawResponse`、
|
||||
`TestAliyunRawDigestTruncates`。`gofmt`/`go build`(overlay)/`go vet`/`go test ./internal/security/...` 全绿。
|
||||
|
||||
### 下一步(等用户)
|
||||
|
||||
重新部署后重试一次,新日志会直接显示阿里云返回的 `code/msg/requestId` 与原始响应。
|
||||
账号侧首要排查项:①内容安全 1.0 是否已开通按量付费;②RAM 子账号是否有 Green 权限;③是否欠费停服;
|
||||
④如需跨区可在 `getClient()` 写死 `cn-shanghai` 之外调整(该处目前硬编码)。
|
||||
|
||||
### 待决策
|
||||
|
||||
- 「显式选定通道坏掉 → 整站 fail-closed 不可用」是否要加**启动预检**(启动时发一次探活请求,
|
||||
失败则告警/拒启)。成本:按条计费 + 启动依赖外部网络;收益:把「静默全站拒签」提前暴露。
|
||||
|
||||
### ✅ 根因已确认(同日 15:50,用户回传新日志)
|
||||
|
||||
诊断改动生效后一条日志即定案:
|
||||
|
||||
```
|
||||
error="text scan (request body={"scenes":["antispam"],"tasks":[{"content":"测试!"}]}):
|
||||
aliyun rejected the request: code=596
|
||||
msg="You have not opened Yundun Content Moderation Service, please go to the page
|
||||
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console"
|
||||
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
|
||||
raw={"code":596,"httpStatusCode":200,"msg":"...","success":false}"
|
||||
```
|
||||
|
||||
- **根因 = 阿里云账号未开通「内容安全 1.0」**(账号侧,非代码缺陷)。
|
||||
- 关键实证:响应体里 `httpStatusCode: 200` + `success: false` + 无 `data` → **阿里云「请求被拒也返回 HTTP 200」**,
|
||||
所以旧代码只会报「data 为空」这句废话。这一条坐实了第二节的推断。
|
||||
- **`SetContentType(requests.Json)` 被证伪**,不是根因;保留但已把代码注释改为「接口契约对齐」而非「修 bug」。
|
||||
- 处置:开通 https://www.aliyun.com/product/lvwang(勾选协议点「立即开通」);RAM 子账号需 Green 权限;
|
||||
**开通完成前先切回 `auto`/`qiniu` 恢复线上可用性**。
|
||||
- 防呆:`conf/app.sample.ini` 的 `text_censor_source` 注释补上「配好凭据 ≠ 可用」+ code=596 的完整说明。
|
||||
- 报告已更新为确认版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`。
|
||||
- 仍待决策:①启动预检(或折中=首次账号级拒绝时打一次性带处置指引的告警);②CHANGELOG 条目。
|
||||
|
||||
### ✅✅ 最终根因(同日 16:05,用户开通后仍报 596)
|
||||
|
||||
用户按我给的地址开了「AI 安全护栏」,仍报同样的 `code=596`。查证后确认**我上一轮给的地址是错的,且漏了硬门槛**:
|
||||
|
||||
| 服务 | 开通前提(官方原文) | 个人认证 |
|
||||
| --- | --- | --- |
|
||||
| **内容安全 1.0**(`green` 2018-05-09,本项目在用) | **需账号完成企业认证**(`document_detail/69806.html`,2026-07-23 更新) | ❌ 开不了 |
|
||||
| 内容安全增强版 2.0(`green-cip` 2022-03-02) | 需完成**实名认证**(`document_detail/477720.html`) | ✅ 可开 |
|
||||
|
||||
- 1.0 真正开通入口:`https://common-buy.aliyun.com/?commodityCode=cdi#/open`;
|
||||
`aliyun.com/product/lvwang` 只是产品介绍页(现为「AI 安全护栏」聚合页),点不出「一键开通」。
|
||||
- 所以:**个人实名认证账号 + 1.0 API = 596 永久复现**,与 RAM 权限无关
|
||||
(权限不足返回 403/Forbidden,不是 596;596 是「服务未开通」)。
|
||||
- 已修正 `conf/app.sample.ini` 的注释(换成正确入口 + 写明企业认证前提 + 三条出路)。
|
||||
- 报告更新为最终版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`(含 A/B/C 三方案对比表)。
|
||||
- 三条出路:**A** 迁增强版 2.0(个人可开,但要换 `green-cip` + `TextModeration` + 新 SDK,**风险标签体系完全不同,`policy.go` 维度清单需重写**);
|
||||
**B** 账号升级企业实名认证(代码零改动);**C** 放弃阿里云只用七牛(`auto`/`qiniu`,损失「七牛误报二次复核」能力)。
|
||||
- 已就方向征询用户(AskUserQuestion)。
|
||||
|
||||
### ✅✅✅ 已落地:阿里云通道迁移到「内容安全增强版 2.0」
|
||||
|
||||
用户通过 AskUserQuestion 选定:**迁移增强版 2.0** + **加一次性告警**(即原「启动预检」的折中方案)。当日完成编码,构建/vet/测试全绿。
|
||||
|
||||
**⚠️ 关键事实修正(最容易踩的坑)**:
|
||||
`green-20220302` SDK 中 `TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
|
||||
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》(`practice_detail/465315`)为准,
|
||||
2.0 通用服务**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
|
||||
`C_customized`(自定义词库)→ **底线四件套可完整覆盖,`policy.go` 的 `baselineBlockLabels` 无需放宽**。
|
||||
|
||||
**2.0 标签 → ForbiddenType 映射**(`formatAliyunForbiddenType`):
|
||||
`political_content`/`regional`→politics(底线);`violence`→terrorism(底线);`sexual_content`/`sexuality`→porn(底线);
|
||||
`contraband`→contraband;`ad`/`PullinTraffic`/`ad_compliance`→ad;`profanity`→abuse;`religion`→harmful;
|
||||
`negative_content`/`cyberbullying`→harmful;`nonsense`→meaningless;`spam`→spam;`C_customized`→customized(底线)。
|
||||
⚠️ 2.0 无「灌水(flood)」维度(同类归入 spam),`ForbiddenTypeFlood` 不再由阿里云产生(七牛仍会返回)。
|
||||
|
||||
**其他关键事实**:
|
||||
- 2.0 通用服务单次 `content` **硬上限 600 字符**;站点内容上限为 提问 1000 / 回答·追问·私聊 2000 /
|
||||
投票描述 5000 字 → 超长必然超限,故**按 550 字分段、段间重叠 50 字**,标签取并集、风险级别取最高。
|
||||
- 2.0 **不返回逐标签置信度**,只有整条内容的 `riskLevel` → 折算 high=1 / medium=0.6 / low=0.3,
|
||||
该响应内所有标签共用同一分值。副作用:`abuseScoreFloor=0.9` 在阿里云下的实际语义变成
|
||||
「标准档只拦 riskLevel=high 的辱骂」。
|
||||
- 场景固定 `comment_detection`(公聊评论;官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流)。
|
||||
- SDK:`alibabacloud-go/green-20220302/v3 v3.5.4`;`openapiutil.Config{RegionId:"cn-shanghai"}` 由 SDK 的
|
||||
EndpointMap 解析出 `green-cip.cn-shanghai.aliyuncs.com`;`ReadTimeout` / `ConnectTimeout` **单位毫秒**,
|
||||
直接设在 Config 上即可(不必用 RuntimeOptions)。
|
||||
- SDK 错误经 `dara.TeaSDKError` 包成 `*dara.SDKError`(有 `GetCode()` 与 `StatusCode` 字段);
|
||||
账号级故障用「结构化 code + 消息文本」双重识别(`aliyunIsAccountIssue`)。
|
||||
- 2.0 开通前提:**仅需实名认证,个人账号可开通**(`document_detail/477720.html`);
|
||||
1.0 才是「需企业实名认证」,个人账号 596 永久复现。
|
||||
|
||||
**两个设计取舍(容易被后人误删,务必保留)**:
|
||||
1. 归一化响应带 `api:"TextModeration"` 格式标记,解析器拒绝一切非该格式的载荷;
|
||||
`censor.go` 阿里云缓存回放解析失败时**退回实时调用**(原来是直接把错误返回上层)。
|
||||
作用:迁移前 1.0 落库的缓存既不会被误判成「通过」(那会成为绕过审核的后门),
|
||||
也不会因 fail-closed 让这些历史文本在 31 天缓存过期前一直发不出去。
|
||||
2. 首次账号级失败(596 / Forbidden / InvalidAccessKeyId …)打**一条**带开通指引的 WARN
|
||||
(`warnAliyunAccountIssueOnce` + `sync.Once`),之后静默。
|
||||
|
||||
**改动文件**:`internal/security/censor/aliyun.go`(整文件重写)、`aliyun_test.go`(新增测试套件)、
|
||||
`censor.go`(缓存回退)、`text.go` / `policy.go`(注释同步)、`policy_test.go`(删除 1.0 格式旧测试)、
|
||||
`conf/app.sample.ini` + `internal/conf/static.go`(改为 2.0 开通指引);
|
||||
`go mod tidy` 已移除不再引用的 `github.com/aliyun/alibaba-cloud-sdk-go v1.62.26`。
|
||||
|
||||
**校验**:`go build -overlay=... ./...` 通过;`go vet ./internal/...` 通过;
|
||||
`go test ./...` 仅 `internal/db` 失败(信息均为 `Unknown database type: ""`,需真实 PostgreSQL/MySQL,与本改动无关),
|
||||
其余全绿(含 `internal/security/censor`)。
|
||||
`gofmt -l` 仅列 `censor.go` / `text_test.go`(改动前既有的空格缩进,按约定未动)。
|
||||
|
||||
**报告已更新**:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md` 文首新增「最终落地」章节。
|
||||
|
||||
**仍需用户处理**:①到内容安全 2.0 控制台开通「内容审核增强版」按量付费,
|
||||
RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未记入
|
||||
(`dev/changelog-source.md` + `python dev/gen_changelog.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 第五轮:私聊审核拦截 UX 修复(用户实测反馈)
|
||||
|
||||
用户实测 2.0 功能正常,但发现**私聊页面被审核拦截后「无任何提示 + 输入框内容消失」**。
|
||||
|
||||
### 根因(两处)
|
||||
|
||||
1. **无提示**:`templates/chat/conversation.html` 没挂 `{{template "base/alert" .}}`,
|
||||
导致 `SendMessage` 里所有 `SetErrorFlash`(过长/被拉黑/审核拦截…)在这页都渲染不出来。
|
||||
2. **内容消失**:审核失败走 `SetErrorFlash` + `Redirect`,重渲染的 `ConversationPage`
|
||||
从不回填 `content`,输入直接清空。
|
||||
|
||||
### 改动
|
||||
|
||||
- `internal/route/chat/chat_handler.go`:把 `ConversationPage` 拆出共享渲染函数
|
||||
`renderConversation(ctx, domain, token, prefillContent, modErr)`;`ConversationPage` 改为其薄封装。
|
||||
私聊审核失败分支由 `SetErrorFlash + Redirect` 改为 `renderConversation(..., content, err)`
|
||||
——**就地重渲染**:`canSend` 为真时把 `prefillContent` 写入 `ctx.Data["Content"]`,
|
||||
并把 `modErr` 写成 `ctx.Data["HasError"]/["Error"]`(与提问链路 `ctx.SetError` 同源)。
|
||||
- `templates/chat/conversation.html`:①加 `{{template "base/alert" .}}`(容器开头);
|
||||
②`<textarea>` 内填充 `{{ .Content }}` 回填已输入内容(html/template 自动转义,安全)。
|
||||
|
||||
### 验证(Windows 沙箱用 overlay 跑,仅 `internal/db` 因缺真实 DB 失败、与本次无关)
|
||||
|
||||
- `go build -overlay=... ./...` 0;`go vet ./internal/...` 0;
|
||||
- `internal/route`、`internal/template`(模板解析)、`internal/conf` 全 ok;
|
||||
- **审核严格度仍生效**:`TestDecideBaselineAlwaysBlocks` / `TestDecideByLevel` /
|
||||
`TestDecideUnknownLevelFallsBackToStrict` / `TestBlockedLabelsIncludesBaselineAtEveryLevel` 全过
|
||||
(底线四件套任何档位强制拦截、未知档回退 strict,未受 2.0 改动影响);
|
||||
- **2.0 迁移未破坏**:`TestAliyunParser*` / `TestExtractAliyunChunkResult` / `TestMergeAliyunChunkResults`
|
||||
/ `TestAliyunIsAccountIssue*` 全过。
|
||||
|
||||
### 备注
|
||||
|
||||
- 新增 `base/alert` 后,私聊页**其它** `SetErrorFlash` 提示(过长/被拉黑/每日上限…)现在也会显示;
|
||||
但那些仍走 Redirect,输入内容仍会丢失。本次只修了用户反馈的审核拦截路径,其余校验失败保留内容属可后续优化项。
|
||||
|
||||
---
|
||||
|
||||
## 环境事实(用户更正,长期有效)
|
||||
|
||||
- **真实运行环境是 Debian**,不要为 Windows 相关问题花时间:本机沙箱才需要 `go build -overlay=...`
|
||||
绕开 libwebp/gcc(cgo),Debian 生产/开发机 `go build ./...` 原生即可,不需要 overlay。
|
||||
- 沙箱内若要用 overlay,注意 **overlay.json 的 Replace 键盘符必须小写**(`c:/...`),
|
||||
大写 `C:/...` 会被 Go 规范化成 `c:\` 后匹配不上、报 `deleted in overlay`;stub 在
|
||||
`dev/s3stub/s3_stub.go`。本次用 `C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-overlay2.json` 跑通。
|
||||
|
||||
---
|
||||
|
||||
## 第六轮:设置面板聚合(新增「防护设置」tab)+ 审核严格度排版修复
|
||||
|
||||
用户诉求:① 把防骚扰、屏蔽词、审核严格度、提问密码聚合到 `/user/profile` 下一个独立 tab;
|
||||
② 审核严格度档位处「一个字就换行」很难看。
|
||||
|
||||
### 决策(AskUserQuestion 已确认)
|
||||
|
||||
- 新 tab 名称:**防护设置**(i18n 键 `admin.tabSecurity`,tab id `security`)。
|
||||
- **「页面访问密码」一并迁入**(原先在「用户设置」面板)。
|
||||
- 旧位置**完全移除,不留跳转提示**。
|
||||
|
||||
### 改动
|
||||
|
||||
**后端**
|
||||
- `route/user/features.go`:新增 `ProfileTabSecurity = "security"`,并纳入 `ProfileTab()` 白名单
|
||||
(不加入的话回跳会落到 account 面板)。
|
||||
- `route/user/password.go`:`UpdateAskPassword` / `UpdatePagePassword` 的硬编码回跳
|
||||
(`profileURLWithTab(ProfileTabQuestion)`、`"/user/profile"`)全部改为 `returnTab(ctx)`,
|
||||
跟随表单携带的 `?tab=`。
|
||||
|
||||
**模板**
|
||||
- `templates/user/profile.html`:tab 栏新增「防护设置」按钮;新增
|
||||
`<div class="profile-panel" data-profile-panel="security">`,内含 防骚扰 / 屏蔽词 /
|
||||
审核严格度 / 页面访问密码 / 提问密码 五个表单(action 均带 `?tab=security`)。
|
||||
提问/私聊/投票面板中这些表单全部删除(**原先屏蔽词与审核严格度被重复渲染 3 份**)。
|
||||
account 面板移除「页面访问密码」卡(原编号 7 让位,账号操作改为 7)。
|
||||
JS 面板切换是全泛型(`[data-profile-tab]` / `[data-profile-panel]`),无需改动。
|
||||
- `templates/user/moderation-strictness.html`(**只用 shell/脚本改,禁用 Write/Edit**):
|
||||
1. 档位表格:档位列 `uk-table-shrink` + `white-space:nowrap`、范围列 `uk-table-expand`
|
||||
—— 修掉右侧长文案把 4 字档位名(畅所欲言/正常水平/不听坏话)挤成逐字折行。
|
||||
2. 选择区改三行:标题(显式 `display:block`)→ 下拉(独立 div)→
|
||||
「未设置时跟随站点默认档位。」(独立 div)。
|
||||
|
||||
**i18n**:四语字典新增 `admin.tabSecurity`(防护设置 / 防護設置 / Protection / 防護設定),
|
||||
插在「功能管理」组之后的**独立组**,未切开任何既有对齐组(避免 gofmt 重排污染 diff)。
|
||||
|
||||
### 验证
|
||||
|
||||
- `go test ./templates/`(含 profile 渲染、严格度回显断言 `selectedStrictnessOptions`)ok;
|
||||
`./internal/i18n/` ok(四语键一致)。
|
||||
- `go build` + `go vet` `./route/user/` 均 0。
|
||||
- ⚠️ **gofmt 提醒(重要,易误判)**:`gofmt -l` 会列出**所有 CRLF 文件**(含本次完全没碰的
|
||||
`route/user/moderation.go`、`vote.go`),因为 gofmt 把 CRLF 规范成 LF。
|
||||
剥离 CR 后比对,本次改动**零格式差异** → 格式是对的。
|
||||
**切勿跑 `gofmt -w`**,那会把文件转成 LF、造成全量 diff 污染。
|
||||
|
||||
---
|
||||
|
||||
## 第七轮:精简注释(删复述、留 WHY)
|
||||
|
||||
用户反馈「注释太多」,要求仅保留重要部分。原则(AskUserQuestion 已确认):
|
||||
**保守尺度** —— 删除复述型/装饰型,保留全部安全约束、踩坑、外部服务怪癖,长注释压成 1-3 行。
|
||||
|
||||
### 摸底数据(脚本 `comment_survey.py`,排除 outputs/dev/.workbuddy)
|
||||
|
||||
- `.go` 139 文件 29,185 行,注释 3,102 行(10.6%);`.html` 41 文件 7,784 行,注释 152 行(2.0%)。
|
||||
- 最重:`policy.go` 38.9%、`conf/limits.go` 37.8%、`shared/ip.go` 33.0%、`conf/static.go` 27.8%、
|
||||
`harassment.go` 25.2%、`aliyun.go` 24.7%(152 行,绝对量最大)、`text.go` 22.2%、`censor.go` 21.3%、
|
||||
`form.go` 19.8%、`gzip.go` 19.3%、`markdown.go` 18.1%、`route.go` 17.8%(80 行)。
|
||||
|
||||
### 第一批已完成(-106 行,-31%)
|
||||
|
||||
| 文件 | 原 | 现 | 减 |
|
||||
|---|---|---|---|
|
||||
| `policy.go` | 84 | 52 | -32(-38%)|
|
||||
| `conf/limits.go` | 45 | 25 | -20(-44%)|
|
||||
| `shared/ip.go` | 58 | 32 | -26(-45%)|
|
||||
| `aliyun.go` | 152 | 124 | -28(-18%,**仅处理到前 180 行,余下待续**)|
|
||||
|
||||
保留的关键信息(勿再删):fail-closed 语义、严格档 ⊇ 改造前、底线维度不可关、
|
||||
阿里云 596/600 字上限与分段重叠、格式标记防历史缓存误判「通过」、SDK 标签表不可信、
|
||||
IPv6 /64 聚合仅限流不可用于黑名单、源站未锁可被伪造头、双 CDN 必须删对方权威头。
|
||||
|
||||
### ⚠️ 复用方法(重要)
|
||||
|
||||
1. **删注释会触发 gofmt 重排**:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组,
|
||||
列宽会变。手算列宽易错 → 用脚本 `gofmt_keep_crlf.py <files>`:
|
||||
**跑 gofmt 后按原文件行尾还原 CRLF**(直接 `gofmt -w` 会把 CRLF 转成 LF,造成全量 diff 污染)。
|
||||
2. **仓库行尾本就不统一**(`policy.go`/`aliyun.go`/`text.go`/`dict_*`/`profile.html` 是 CRLF;
|
||||
`limits.go`/`features.go`/`password.go` 是 LF)。改动前先确认,改后保持一致。
|
||||
3. `grep -c $'\r' file` 在 Git Bash 下**不可靠**(`$'\r'` 可能被当空模式,导致「全部行命中」)。
|
||||
查行尾要用 Python 数 `b'\r\n'`。
|
||||
4. `templates/user/moderation-strictness.html` 仍**只能用 shell/脚本改**(Write/Edit 会注入
|
||||
`data-page-node-id`)。
|
||||
|
||||
### 验证
|
||||
|
||||
`go build ./internal/security/... ./internal/shared/... ./internal/conf/...` 通过;
|
||||
`go test ./internal/security/censor/` ok(严格度与 2.0 用例全过,确认删注释未影响行为)。
|
||||
|
||||
---
|
||||
|
||||
## 续:注释清理第二批 + 编译卡死根因(晚间)
|
||||
|
||||
### 第二批已清理(本轮继续)
|
||||
|
||||
`conf/static.go`、`conf/conf.go`、`security/censor/{text,censor,aliyun}.go`、`route/{gzip,route}.go`、
|
||||
`form/form.go`、`template/markdown.go`、`db/{db,feature_control}.go`、`captcha/math.go`、
|
||||
`route/user/harassment.go`、`route/user/vote.go`(最大,~149→少,N+1→2SQL、sanitizeCSVCell、BOM 等 WHY 保留)、
|
||||
`db/follow_ups.go`(部分,待构建释放后收尾)、`security/censor/policy.go`(最高比 28%→压缩,保 WHY)、
|
||||
`i18n/i18n.go`(删内联复述、修 typo)。
|
||||
|
||||
### 编译卡死根因(已定位并绕过)
|
||||
|
||||
- 真因:`proxy.golang.org` 在本沙箱 `HTTP=000`(连接超时),`go build` 触网即无限等待 → 表象「卡死」。
|
||||
`goproxy.cn` → 200。解法:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + **定向包构建**(勿 `./...`)。
|
||||
- `go env -w` 失败(`%AppData% is not defined`),无法持久化,只能每条命令 export。
|
||||
- 重活儿构建(`./route/user/ ./internal/route/ ./internal/db/`,overlay 换 s3 stub 避 cgo/webp)已用上述代理跑通、进行中(1C1G 首编 20+ 分钟)。
|
||||
|
||||
### 行尾结论
|
||||
|
||||
`core.autocrlf=true` → 工作区 CRLF、blob LF,Git 归一,**无 diff 污染**,不必手动转行尾。
|
||||
`gofmt_keep_crlf.py` 重建到 `dev/gofmt_keep_crlf.py`(Temp 版被清),保留工作区 CRLF 以免 gofmt -l 误报。
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# 2026-09-11 工作日志
|
||||
|
||||
## 代码审阅(全库,安全/正确性/可优化)
|
||||
|
||||
结论:**无 P0、无 P1 可利用漏洞**;仅 P2(一致性 / 健壮性 / 性能)。
|
||||
|
||||
### 已验证「没问题」的项(勿重复排查)
|
||||
|
||||
1. **越权**:所有写接口均有归属校验 —— `getOwnedVote` 显式比对 `vote.UserID != ctx.User.ID`(vote.go:388-401);
|
||||
question 路由挂在 `question.Questioner` 中间件组(route.go:262-274),`PublishAnswer` 二次校验 `question.UserID`;
|
||||
follow-up 写操作以 `question.ID` 为作用域(`AnswerByID`/`UpdateAnswerByID`)。profile/password/harassment/tags/features 一律用 `ctx.User.ID`。
|
||||
2. **SQL 注入**:`internal/db` 全部用 `?` 绑定参数;拼接仅出现在常量/配置(dedupeUniqueFields、tagCond、randomFunc)。
|
||||
3. **XSS**:`internal/template/markdown.go` 白名单消毒扎实 —— strip `on*`、禁 `style`、`safeURL` 协议白名单
|
||||
(http/https/mailto/相对)、拒 `//` 协议相对、强制 `rel="noopener noreferrer"`。
|
||||
4. **竞态**:follow-up 用事务 + `FOR UPDATE` 行锁;投票「一人一票」用复合唯一索引。
|
||||
5. **GORM bool 默认值陷阱**:已规避(`Enabled` 不加 `gorm:"default:true"`,见 feature_control.go:79-81),
|
||||
且 `reconcileLegacyChatEnabled`(:609-618)修复旧 backfill 脏行。
|
||||
6. **审核缓存回放无 fail-open**:`AliyunTextCensorParser`(aliyun.go:433-461)与 `QiniuTextCensorParser`
|
||||
(qiniu.go:133-170)都会从 raw 重建 `Details`,故 `Decide` 仍能按当前档位裁决(改档位对历史缓存同样生效)。
|
||||
⚠️ text.go:66 注释称「回放缓存结果时为 nil」**是错的**,需修正。
|
||||
|
||||
### P2 清单(待用户选批后落地)
|
||||
|
||||
| # | 文件:行 | 问题 | 建议 |
|
||||
|---|---|---|---|
|
||||
| 1 | censor.go:72,111,118-125 | 缓存按**原文** `text` 查,实际送审 `RemoveTrustedURL(text)`,落库 `Input` 存原文 → 审计与实际送审不一致 + 归一化相同文本重复调 API | 归一化一次,查找/送审/落库统一用归一化文本,原文另存字段 |
|
||||
| 2 | censor.go:85-102 | qiniu 缓存命中仅 `Pass` 才返回,aliyun 命中即返回(不看 Pass)→ 依赖「aliyun 恒为链尾」隐式前提 | 两分支语义对齐,或显式固化链尾不变量 |
|
||||
| 3 | text.go:66 | 注释失真(称回放时 Details 为 nil,实际会重建) | 修正注释 |
|
||||
| 4 | route/vote/vote.go:814-822 | 配额用「插入后计数」判定,非原子条件插入 → 并发可短暂超额(有界、自动结束自愈) | 改 `WHERE count<quota` 条件插入或 DB 计数列 |
|
||||
| 5 | route/user/profile.go:194-215 | Name/Intro/各 link 无长度校验直传 `db.Users.Update` | 补 rune 长度校验(对齐 vote/block-word) |
|
||||
| 6 | internal/db/users.go:89 | `User.Email` 带 `json:"email"`(对比 Password/BlockWords 为 `json:"-"`)→ 潜在 PII 泄露 | 改 `json:"-"` |
|
||||
| 7 | internal/db/db.go:215-222 | 用 `conf.Database.Schema` 拼 `CREATE SEQUENCE`(配置来源,纵深防御) | 校验/白名单 schema 标识符 |
|
||||
| 8 | text.go:114-141 | `RemoveTrustedURL` 对 12 host × 2 protocol = 24 次整串重建 | 单次正则/一次遍历 |
|
||||
|
||||
另:`question.go:214` 提问人 token 在 URL 中(邮件链接),**设计如此**,会出现在邮件日志/Referer,可接受。
|
||||
|
||||
### 编译状态(延续 09-10)
|
||||
|
||||
- 重包 `./route/user/ ./internal/route/ ./internal/db/` 再次 **30 分钟超时(exit 124,无报错)**
|
||||
→ 判定为 1C1G 沙箱编译整图慢,**非代码错误**(注释改动不影响编译,20 文件 `gofmt -e` 均可解析)。
|
||||
- 已确认可编译:GROUP1(security/censor、conf、form、template、shared、captcha)EXIT=0;`./internal/db/` EXIT=0。
|
||||
- 温缓存重试(task zGnX3L)仍在跑,25 分钟上限。
|
||||
@@ -1,30 +0,0 @@
|
||||
# 2026-09-27 工作日志
|
||||
|
||||
## Serverless 迁移可行性评估(只读,未改源码)
|
||||
|
||||
用户问:TamaBox-v4 能否像 OpenListTeam/OpenList-Worker 那样改造成 TypeScript + Cloudflare Workers + D1。
|
||||
|
||||
**产出**:`outputs/serverless-migration-feasibility-2026-09-27.md`
|
||||
|
||||
**结论:技术可行,但工程性价比低——是「整体重写」不是「改造」。**
|
||||
|
||||
关键判据(两个项目形态根本不同):
|
||||
- OpenList = **无状态文件列表 + 反代**(无会话、无可变状态、存储可降级 map/KV、无图像、无服务端模板)→ 边缘天然匹配。
|
||||
- TamaBox = **有状态多用户 Web 应用**(Session/CSRF/Redis cache、PG 关系模型、cgo 图像管线、42 个服务端模板)。
|
||||
|
||||
标定数据:
|
||||
- TamaBox:34,664 行 Go(route 7645 / db 6950 / i18n 3689 / security 2675 / internal-route 1653 / template 825)。
|
||||
- OpenList 原版 9.8MB Go / 24.8k star;OpenList-Worker 21.7MB TS / 416 个 .ts(约 2.6MB 源码)/ 官方团队 / 2025-08-23 立项 → 约一年,且**代码量反超原版**。
|
||||
|
||||
P0 硬阻断(11 项)要点:
|
||||
1. `internal/storage/s3.go` cgo WebP(chai2010/webp)+ `cwebp` 子进程 + `os.CreateTemp` → Workers 无 cgo/无子进程/无磁盘。
|
||||
2. `route/question/share.go` freetype + `os.ReadFile(font_path)` 渲染中文分享图 → 需整体重写,且 CPU 密集易超 Workers 限额。
|
||||
3. Session/缓存/限流/自动封禁/去重/统计全在进程内(`internal/cache`、`autoban`、`shared/duplicate.go`、`ratelimit.go`)→ 必须迁 Durable Objects。
|
||||
4. `internal/route/route.go:386-395` cache 中间件**无条件硬编码 Redis**(不判空配置)。
|
||||
5. D1=SQLite:无 PG 序列(`db.go:211-234`)、无 `FOR UPDATE`(`follow_ups.go:68`)、无 JSONB(`datatypes.JSON`)。
|
||||
6. SMTP(`internal/mail/mail.go:129-147`)Workers 禁裸 TCP → 换 HTTP 邮件 API。
|
||||
7. OTel/Uptrace gRPC 上报不可用。
|
||||
|
||||
顺带确认(对后续有用):`internal/template/minify.go` 当前**未被路由装配使用**(`route.go:99` 用的是 flamego template.EmbedFS),属死代码。`internal/security/ipinfo` 是离线签名、零 DNS。
|
||||
|
||||
建议方案:A 不迁移(推荐);B 只把静态资源+只读接口下沉边缘(EdgeOne/Workers),写路径与图像处理留 Go 源站——项目已有 EdgeOne 回源白名单脚本,该形态已在用;C 完全重写(需 2-3 个月专职 + 功能裁剪,优先 EdgeOne 因其有大陆节点)。
|
||||
@@ -1,13 +0,0 @@
|
||||
# 2026-09-28
|
||||
|
||||
本会话的工作实际发生在**另一个仓库** `C:/OtherFiles/Github/rond-with-you`(用户指定的 data 文件夹与该备份都在那边),
|
||||
TamaBox 仓库这次没动。
|
||||
|
||||
- 任务:用真机新导出的 `data/20260928_183908.rondbackup` 在本地测试环境(`conf/app.ini`)实测「rondbackup 合并」。
|
||||
- 结论:主链路生效(新增 70 地点 / 1 活动 / 134 到访,953 条到访 UUID 全命中、时间零误差,孤儿位移 0);
|
||||
查出 3 个问题(重复地点不幂等、到访冲突被活动改名放大且不收敛、21 个长沙地点坐标系差 ~660m)。
|
||||
- 笔记与报告落在该仓库:`.workbuddy/memory/2026-09-28.md`、`.workbuddy/memory/MEMORY.md`、
|
||||
`storage/.mergetest/报告-rondbackup合并实测-20260928.md`。
|
||||
- 随后按用户要求**回档数据库 → 修复前两个问题 → 复测**:改 `internal/rond/diff.go`(`DiffPlaces` 多值索引、
|
||||
到访活动按 `ZUID_` 身份比)与 `internal/web/backup_diff.go`(多取活动 UUID),共 5 文件 +172/−22。
|
||||
复测:到访冲突 253→6,地点新增 70→67 且合并后归零,重复合并不再产生重复地点。细节见该仓库记忆。
|
||||
@@ -1,9 +0,0 @@
|
||||
# 2026-09-29
|
||||
|
||||
工作仍发生在 `C:/OtherFiles/Github/rond-with-you`(TamaBox 仓库未动)。
|
||||
|
||||
- 站长手动合并后追问差异页「地点 X → X」为何算冲突;复现定性:`地点` 比的是**坐标**、页面只印**名称**,
|
||||
同名不同坐标(连锁店重名 236.8km/8.3km,坐标系 750m)就渲染成看似没变化的一行。
|
||||
- 顺带发现默认「用实机」已把站内 `中国体育彩票`(src_pk=71) 从贺州搬到佛山(236.8km);
|
||||
提出 3 处待修方案(名称兜底加距离上限、地点字段补坐标、mergePlaces 的单槽位映射),等批准。
|
||||
- 细节见该仓库 `.workbuddy/memory/2026-09-29.md`。
|
||||
@@ -1,226 +0,0 @@
|
||||
# 2026-10-15
|
||||
|
||||
## WebP 压缩链路评审(只读,未改代码)
|
||||
|
||||
产出:`outputs/webp-compression-review-2026-10-15.md`
|
||||
|
||||
### 关键事实(已实证)
|
||||
- `internal/storage/s3.go:186` 的 `maxWidth=1920` + `Quality:80` 对**所有用途一刀切**;
|
||||
前端 `profile.html:1390` 头像 cropper 只出 512,服务端却按1920 存 → 头像实测多传 ~7倍。
|
||||
- `chai2010/webp v1.4.0` 的 `Options` **只有 Lossless/Quality/Exact**;
|
||||
C 层是裸 `WebPEncodeRGB/RGBA`(`internal/src/webp.c:194-215`)= cwebp method4 默认档。
|
||||
→ method/sns/filter/alpha_q/near_lossless 全都不可控。实测 method6 再省 3~6%。
|
||||
- 🔴 **`Dockerfile:16` 用 Alpine apk 但未装 `webp` 包** → 容器内无 `cwebp`
|
||||
→ `s3.go:201` LookPath 必失败 → GIF 动画 WebP 分支是**死代码**,线上一直原图 GIF,且无日志。
|
||||
- 🔴 `s3.go:99` / `page.go:707` 用 `image.Decode` **不读 EXIF Orientation**;
|
||||
`templates/question/item.html:206,238` 的 `accept="image/*"` 是原图直传(无 canvas 归一)
|
||||
→ 手机竖拍照片转 WebP 后横躺。
|
||||
- 缺 `len(webp) >= len(orig)` 回退兜底(小图/线框图可能越转越大)。
|
||||
- `format=="webp"` 会二次有损重编(实测 160KB→140KB,体积收益不确定但画质丢一代)。
|
||||
- `s3.go` 对同一张图**解码两遍**(99 行取format + 181 行完整解码),4096² 下峰值 ~64MB/张。
|
||||
- `route/question/page.go:740` PutObject **缺 CacheControl**(`s3.go:158` 有 immutable)。
|
||||
- `s3.go:187` 只判 `Dx()>1920`,不限制长边 → 竖图 1080×10000(10.8M 像素 < 16.7M 上限)不缩放。
|
||||
|
||||
### 实测方法
|
||||
本机无 libwebp/GCC,Go 侧未编译。体积基线用 Pillow(绑定同一份 libwebp 1.4.0)
|
||||
对 `outputs/*.png` 真实截图测 m4/m6、q75/80/85、lossless。
|
||||
`imaging.Lanczos` vs Pillow LANCZOS 的差异未纳入。
|
||||
**method=6 的编码耗时未实测** —— 这是决定是否采用的关键约束(m6 约为 m4 的 5~10 倍耗时,1C1G 需实测)。
|
||||
|
||||
### 方案分批
|
||||
- 批次 A(低风险):按用途分档(头像 512/q78、背景 1920/q80、通用 1600/q80)、
|
||||
加大小回退、webp 透传、限长边、补 CacheControl、GIF 缺 cwebp 补 Warn。
|
||||
- 批次 B:method=6(需先Dockerfile 装 webp + 实测耗时)、EXIF 归一、统一 GIF 实现、降内存。
|
||||
|
||||
---
|
||||
|
||||
## 批次 A 已落地(同日)
|
||||
|
||||
用户确认**不使用 Docker**(webp 包/cwebp 相关影响可忽略),故批次 A 全部走 Go 侧实现。
|
||||
|
||||
### 改动文件
|
||||
1. `internal/storage/s3.go`(LF)
|
||||
- 新增导出 `EncodeSpec{MaxSide, Quality}` + 档位 `avatarEncodeSpec(512/78)`、
|
||||
`backgroundEncodeSpec(1920/80)`、**导出** `GeneralEncodeSpec(1600/80)`
|
||||
(导出是因为 `route/question/page.go` 要传参给 `ConvertStaticImageToWebP`)。
|
||||
- `uploadPictureToS3` 新增第 4 参 `spec EncodeSpec`;3 个 Upload* 入口各自传档位。
|
||||
- `ConvertStaticImageToWebP(r, spec)`:签名变了,用 `imaging.Fit` **同时限宽高**
|
||||
(原 `Resize(img, maxWidth, 0, ...)` 只限宽,1080×10000 竖图不缩放)。
|
||||
- 新增 `case "webp"`:已是 WebP 且尺寸合规 → 原样透传,不再二次有损。
|
||||
- 所有转换分支加 `len(webpData) >= len(原图)` 回退兜底。
|
||||
- `cwebp` 缺失时 `logrus.Warn`(原先完全静默);新增 `sirupsen/logrus` 导入。
|
||||
2. `route/question/page.go`(CRLF,792→794 行)
|
||||
- 同款 webp 透传 + 体积兜底;用 `image.DecodeConfig` 拿尺寸避免多余全量解码。
|
||||
- PutObject 补 `CacheControl: public, max-age=31536000, immutable`。
|
||||
3. `dev/s3stub/s3_stub.go`、`dev/webpstub/webp.go`:桩签名同步。
|
||||
4. 新增 `internal/storage/encode_spec_test.go`(`fitsWithin` 边界 + 档位值断言)。
|
||||
|
||||
### 验证结果(全部实测)
|
||||
- **编译**:本机无 libwebp/GCC,用**临时副本 + `go mod edit -replace github.com/chai2010/webp=../webpstub`**
|
||||
让**真实 s3.go** 参与编译(比 `s3stub` overlay 强,后者会把 s3.go 换成桩、等于没检查它)。
|
||||
`go build ./...` EXIT=0,`go vet ./...` EXIT=0。
|
||||
- **测试**:`go test ./internal/...`(带 PG)全绿,含 `internal/storage`。
|
||||
- **兜底逻辑有效性实测**(Pillow,同 libwebp 1.4.0):
|
||||
- 头像真实路径(前端 cropper 出 512×512 **PNG**,quality:null 无损)→ WebP 10,486 vs PNG 103,054,用 WebP。
|
||||
- 线框图 200×200 → WebP 908 vs PNG 652,**回退 PNG**(兜底生效,这就是不加兜底会踩的坑)。
|
||||
- 纯色 1600×900 → WebP 2,672 vs PNG 6,390,用 WebP。
|
||||
|
||||
### 关键坑
|
||||
- **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 明确报错)。
|
||||
唯一可行的本机方案是**复制项目到临时目录 + `go mod edit -replace`** 到本地 stub 模块。
|
||||
- 前端头像 cropper 输出 PNG(`profile.html:1391` `fileType:'image/png', quality:null`),
|
||||
背景是 jpeg q0.92 —— 服务端转 WebP 的收益主要来自这里。
|
||||
- 站点模板实际展示尺寸:头像 120px(`.tb-frame-avatar`),背景铺满宽度,
|
||||
私聊图 `max-width:75%`,提问图 100×100 缩略。→ 头像档 512 有据可依。
|
||||
|
||||
### 批次 A 未覆盖
|
||||
- method 仍锁在 4(`chai2010/webp` 只暴露 Quality/Lossless/Exact)。
|
||||
- EXIF 方向未处理、GIF 双实现未统一、同一张图仍解码两遍 —— 见下方批次 B。
|
||||
|
||||
---
|
||||
|
||||
## 批次 B1+B2 已落地(同日,用户批准)
|
||||
|
||||
**用户澄清:生产是裸 Linux(不用 Docker),且能确保部署机装了 `cwebp`。**
|
||||
|
||||
### 🔴 重要:原报告的 method=6 结论被实测推翻
|
||||
原报告写「m6 耗时是 m4 的 5~10 倍」——**未实测,错的**。实测(取中位数、颠倒顺序防干扰):
|
||||
|
||||
| 场景 | m4 | m6 | 倍数 | 体积差 |
|
||||
|---|---|---|---|---|
|
||||
| 1600x4830 (7.7MP) | 1867ms | 2229ms | 1.2x | -2.9% |
|
||||
| 1920x1080 背景 | 464ms | 655ms | 1.4x | -3.3% |
|
||||
| 1200x800 配图 | 253ms | 492ms | 1.9x | -3.0% |
|
||||
| 512x512 头像 | 60ms | 81ms | 1.4x | -3.5% |
|
||||
|
||||
**结论:代价远小于预估,但收益只有 3%——1.2~1.9x CPU 换 3% 不划算,决定不做 method=6。**
|
||||
(另注:Pillow 绑的是 libwebp 1.6.0,项目是 1.4.0,耗时数值仅供相对比较。)
|
||||
|
||||
### B1 EXIF 方向归一
|
||||
- 新增 `internal/storage/exif.go`:`jpegOrientation`(手工解析 APP1/Exif + TIFF IFD0,
|
||||
**不引新依赖**,项目原本无 EXIF 库)、`applyJPEGOrientation`(走 `orientedImage` +
|
||||
`draw.Draw` 一次性拷贝,支持全部 8 种方向)。
|
||||
- 接入 `ConvertStaticImageToWebP`,**顺序必须是「先归一方向,再限边缩放」**——
|
||||
归一会交换宽高,顺序反了会按错误的边长做缩放判断。
|
||||
- 为何不可事后修:**实测转码成 WebP 后 EXIF 被整体丢弃**(字节流无 `Exif` 标记),
|
||||
方向信息永久消失。且 `image.Decode` 与 Pillow 默认都不读 EXIF,已实证。
|
||||
|
||||
### B2 GIF 统一
|
||||
- `convertGifToAnimatedWebP` 导出为 `ConvertGifToAnimatedWebP`,`page.go` 的 GIF 分支
|
||||
改为复用同一实现(此前 `page.go` 完全跳过转换)。
|
||||
- 新增 `maxGIFFrames = 200` + `gifFrameCount`(遍历数据块统计 Image Descriptor,
|
||||
**不解码像素**,常数级开销)。`CheckPixelLimit` 只看 GIF 逻辑屏尺寸,
|
||||
500 帧 2000x2000 的 GIF 体积小但会让 cwebp 长时间吃 CPU。
|
||||
- cwebp 参数补 `-metadata none`。
|
||||
- 两处 GIF 分支都加 `len(webp) >= len(原图)` 兜底。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` EXIT=0、`go vet ./...` EXIT=0、
|
||||
`go test ./internal/... ./route/...`(带 PG)**全绿**。
|
||||
- 新增测试 `exif_test.go`(JPEG 手工构造 APP1 段,覆盖大小端/越界/截断/非 JPEG;
|
||||
8 种方向逐像素校验)、`gif_test.go`(1/12/200/201 帧 + 垃圾输入)。
|
||||
|
||||
### 踩坑记录(都是我自己写错的)
|
||||
1. **`gifFrameCount` 恒返回 0**:漏跳 7 字节 Logical Screen Descriptor,
|
||||
|
||||
`pos` 停在 LSD 首字节(宽度低位)→ 首轮命中 `default` 返回 0。
|
||||
LSD 的 packed 在 `data[10]`(=pos+4),**必须无条件 pos += 7 再按 flag 加色表**。
|
||||
2. **`buildJPEGWithOrientation` panic**:`entry[10:12]` 只有 2 字节却调 `PutUint32`。
|
||||
IFD 条目固定 12 字节,next-IFD 偏移占最后 2 字节,保持 0 即可。
|
||||
3. **`image.Config` 没有 `Dx()/Dy()`**,是 `Width`/`Height` 字段。
|
||||
4. **`color.RGBA().RGBA()` 返回 alpha 预乘值**,不能简单和 `v<<8` 比较,
|
||||
要四个通道一起比。
|
||||
5. **WebP 桩不产出字节** → 依赖真实编码器的端到端断言在本机无法成立,
|
||||
已改为 `t.Skip`(桩环境跳过),像素级映射由单测覆盖。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 部署后线上验证(用户已部署 box-dev-api)
|
||||
|
||||
新增 `dev/devcheck-avatar.py`(头像链路,可复跑)。**A 分档线上确认生效**:
|
||||
上传 1600x1600 JPEG(124,941 B)→ 存储为 **512x512 WebP, 1,496 B**。
|
||||
|
||||
### 🔴 线上验证暴露的真实缺口(待修)
|
||||
**EXIF 归一的位置太晚,比例校验看不到归一后的方向。**
|
||||
- `s3.go:110` `asp.check()` 只用 `image.DecodeConfig` 拿宽高,**不读 EXIF**;
|
||||
- EXIF 归一在 `ConvertStaticImageToWebP` 内部(第 240+ 行),**晚于比例校验**。
|
||||
- 后果:手机竖拍照片(像素 4032x3024 + Orientation=6,实际显示 3024x4032)
|
||||
直传头像会被判「比例不符 1:1(偏差 0.333)」而拒绝——**尽管用户看到的是方形**。
|
||||
实测线上确实如此:传 1536x512+EXIF=6 被拒,头像 URL 未变。
|
||||
- 前端走 cropper(canvas 会归一方向)不受影响,故只影响**绕过前端的直传**。
|
||||
- 修法方向:`asp.check` 先读 `jpegOrientation`,对 5/6/7/8 交换宽高再比比例。
|
||||
别为一次校验就完整解码(大图内存),只做 EXIF 段解析 + 宽高交换即可。
|
||||
|
||||
### 验证脚本踩坑
|
||||
- 提问成功页**不渲染图片**(`templates/question/success.html` 无图片区),
|
||||
图片只在 `question.go:50` 的**详情页**渲染;且提问图 key **不带 `picture/` 前缀**
|
||||
(是 `<年>/<月>/<纳秒>.<ext>`),头像才带 `picture/`。→ 断言正则要分开写。
|
||||
- `/user/profile/update` 是完整资料表单,`UpdateProfile` 有 12+ 个 `required` 字段
|
||||
(`internal/form/user.go:7`)。只发 `_csrf` 会被挡在 Bind 阶段、头像根本不上传。
|
||||
做法:从 profile 页**抓取所有 input/select/textarea 的 name=value 回填**。
|
||||
- 头像上传成功是 **302**,头像 URL 在跳转后的页面里(不是响应体)。
|
||||
|
||||
## 补完 B1 + B3(2026-10-15 夜)
|
||||
|
||||
### B1 补完:EXIF 比例校验缺口
|
||||
- `aspect.check` 签名改为 `(cfg image.Config, orientation int) error`——**不再自己DecodeConfig**,
|
||||
由调用方传入已解析的宽高,顺带消除一次重复的文件头读取。
|
||||
- 内部:`orientationSwapsDimensions(orientation)` 为真(5/6/7/8)则交换 w/h 再比比例。
|
||||
- `imagecheck` 新增 `CheckAspectRatioSize(w, h, wantW, wantH, tol)`;
|
||||
`CheckAspectRatio(r,...)` 改为薄封装(内部走 DecodeConfig + 调Size 版),行为不变。
|
||||
- `exif.go` 抽出 `orientationSwapsDimensions`,`applyJPEGOrientation` 里的 switch 改为调它(去重)。
|
||||
|
||||
### B3:合并重复解码(峰值内存减半)
|
||||
- 根因:`uploadPictureToS3` 里 `image.Decode` 为拿 format 解一遍 → `ConvertStaticImageToWebP` 内再解一遍。
|
||||
- 改法:`image.DecodeConfig` 一次同时拿 **format + 宽高**(不解像素),
|
||||
供「像素上限校验 / 比例校验 / WebP 透传判断」三处共用;完整解码只发生在真正要转码时。
|
||||
- `ConvertStaticImageToWebP` 签名 `(io.Reader, spec)` → `([]byte, spec)`:
|
||||
调用方手里已有原始字节,不必再包一层 `bytes.Reader`(也不必内部 `io.ReadAll` 复制)。
|
||||
内部随之删掉重复的 `CheckPixelLimit`(调用方已在解码前校验过)。
|
||||
- `route/question/page.go` 同步:删掉 `image.Decode`取 format 与 `DecodeConfig`取 cfg 的两次调用,
|
||||
改为一次 `DecodeConfig`;新增本地 `fitsWithinSide(cfg, maxSide)`。
|
||||
- `dev/s3stub/s3_stub.go` 同步签名(去掉 `io` import)。
|
||||
- **实测堆峰值增量**(runtime.MemStats,含保留引用强制不回收):
|
||||
| 输入 | 一次解码 | 两次解码 |
|
||||
|---|---|---|
|
||||
| JPEG 4000×3000 (2.3MB) | 18.1 MB | 36.1 MB |
|
||||
| PNG 3000×3000 (33KB) | 36.1 MB | 72.1 MB |
|
||||
→ 正好 2 倍。1C1G 上并发上传大图时这是决定性的。
|
||||
|
||||
## 线上验证通过(2026-10-16 上午)
|
||||
|
||||
脚本:`dev/devcheck-upload.py`(**取代 `dev/devcheck-avatar.py`**),12/12 全通过。
|
||||
|
||||
### ⚠️ 重要纠正:头像路径测不出 EXIF 比例校验缺口
|
||||
头像要求 **1:1 是对称比例** —— EXIF 归一(5/6/7/8 交换宽高)后比例**不变**。
|
||||
所以修没修缺口,头像路径表现完全一样。之前 `devcheck-avatar.py` 那个
|
||||
「1536x512 + ori6」用例**根本测不到点上**(3:1 和 1:3 都不是 1:1,两边都会被拒)。
|
||||
**能触发缺口的是背景 16:9(非对称)**。
|
||||
|
||||
### 核心用例(背景 16:9 + EXIF Orientation=6)
|
||||
像素 1080x1920(竖,比例 0.5625)+ ori6 → 肉眼看到 1920x1080(16:9)
|
||||
- 修复前:按像素判比例,偏差 |0.5625 - 1.7778| = 1.215 >> 0.01 → **被误拒**
|
||||
- 修复后:先交换宽高,偏差 0 → 放行
|
||||
|
||||
### 线上实测数据
|
||||
| 用例 | 输入 | 存盘结果 |
|
||||
|---|---|---|
|
||||
| A 分档头像 | 1600×1600 JPEG 125,865 B | webp (512,512) **1,542 B** |
|
||||
| 背景正向对照 | 1920×1080 JPEG 93,484 B | webp (1920,1080) 7,582 B |
|
||||
| **背景 EXIF ori6** | 1080×1920 JPEG 138,842 B | webp (1920,1080) 9,900 B |
|
||||
| 反向用例 | 1080×1920 无 EXIF | **被 16:9 校验拒绝** ✅ 未把校验放宽 |
|
||||
|
||||
### 像素级方向验证(尺寸对≠ 方向对)
|
||||
与 **Pillow `ImageOps.exif_transpose` 作黄金标准**比「红色标记条包围盒」:
|
||||
```
|
||||
黄金标准(Pillow exif_transpose) 红条bbox=(0, 0, 1916, 88) -> 顶部
|
||||
线上存盘(本项目 WebP) 红条bbox=(0, 0, 1916, 88) -> 顶部
|
||||
```
|
||||
**完全一致** → 方向归一正确。这条已固化进脚本(`redness_bbox()` + 一条断言)。
|
||||
|
||||
### 踩坑
|
||||
- 用「上下左右取平均亮度」判断红条位置**不可靠**:采样带(H//10=108px)比红条本身
|
||||
(w//12=90px)还宽,红色被稀释成背景色 → 误判「方向未归一」。
|
||||
正确做法是**按颜色阈值 r>150&&g<90&&b<90 定位像素包围盒**。
|
||||
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。
|
||||
@@ -1,461 +0,0 @@
|
||||
# 2026-10-18
|
||||
|
||||
## 运营侧内容治理:饭圈黑话拉踩/挂人问题(诊断 + 方向)
|
||||
|
||||
用户观察到部分箱主页面出现拉踩、黑话辱骂、挑动群体对立(样例 `/_/52mxtx1314`,85 条问答,
|
||||
内容为「避雷/壁垒/挂人 + 圈内代称」,如咸菜坤/县城坤/猪肝/澄毒/忘羡姐/端水洁癖/蟹脚coser)。
|
||||
|
||||
### 已核实的事实(代码侧)
|
||||
|
||||
- `moderation.Check` 已覆盖提问入口(`route/question/page.go:441`)、回答、私聊、投票、追问;七牛只硬拦
|
||||
politics/terrorism/porn,`review` 一律放行 → **通用审核对黑话零命中**,样例文本无一个传统脏字。
|
||||
- 箱主自建屏蔽词上限:`conf.Limits.MaxBlockWords=10`、`MaxBlockWordLen=10`(`internal/conf/limits.go`),
|
||||
对「一人一个代称」的场景容量不匹配。
|
||||
- **全站无举报入口**:`grep Report` 无结果;处置能力只有箱主侧 Delete / SetPrivate / SetPublic。
|
||||
- 现有成本控制:30 次/分钟限流、数学验证码、同 IP+同内容重复拦截(`shared.DuplicateGuard`)、
|
||||
`harassment_setting` 的 register_only 档。`autoban` 只针对后台路径扫描,不涉及内容行为。
|
||||
- `Question.FromIP` 已落库但**未被任何运营/聚合逻辑消费**。
|
||||
- 单题页/提问箱页已全局 noindex(`internal/context/context.go:243` 的非公开页分支)→ SEO 不是主要传播面,
|
||||
扩散主要靠截图外传。
|
||||
|
||||
### 关键判断
|
||||
|
||||
根因是**激励结构**而非审核强度:匿名零身份 + 提问进后台默认不公开 + 箱主逐条回答后公开(样例中连「?」这类
|
||||
单字回答也算)+ 页面可分享。攻击者收益是「被朗读、被围观」,不是「发出去」。因此加词库/调最严档
|
||||
投入产出极低且放大误伤。方向按三层:结构层降低收益(已回答≠自动公开、含第三方指涉的投稿默认私密)、
|
||||
成本层用行为信号(来源配额、同 token 聚合)替代词库、处置层补举报-下架-按来源封禁并留痕。
|
||||
|
||||
本次仅给方向,未改代码。下一步待用户选定:出具体变更清单,或先落「举报 + 批量处置」最小闭环。
|
||||
|
||||
## 追加:箱主主动引导投稿(处罚口径讨论)
|
||||
|
||||
用户澄清:本例是**箱主引导用户投稿挂人**,不是被动受害;提议"禁用提问 N 天,多次累计直接封号"。
|
||||
|
||||
### 新核实的事实
|
||||
|
||||
- **单题分享卡是公开的一键外传通道**:`QuestionShareCard`(`route/question/share.go:792`)只要
|
||||
`answer != "" && !IsPrivate && question.UserID == user.ID` 就渲染整张卡片(含箱主头像/背景/水印/二维码),
|
||||
路由 `GET /_/{domain}/{questionID}/share-card` 在公开的 `Questioner` 分组内。挂人内容因此可以变成无痕图片流出站外。
|
||||
- **首页/探索分发无任何信誉门槛**:`users.show_in_home` 由箱主自选,`GetShowInHome` / `GetByTag` /
|
||||
`getRandomUsers` 只按 created_at 或随机排序,不查举报/处置记录。高互动挂人箱可直接获得平台曝光。
|
||||
- **per-user 功能开关已存在**:`feature_controls`(user_id + feature + enabled + 时间窗 + quota),
|
||||
`FeatureOpen` / `FeatureStateOf` 是唯一判断点 → 但它是**箱主自设**,`Save` 由本人调用,
|
||||
若复用为处罚,箱主可自行 `enabled=true` 解除,语义也混淆。处罚需独立 "平台限制" 层,优先级高于用户设置。
|
||||
- `User` 模型无任何限制/处罚字段;现有只有 Deactivate(自停用)、BanUserAndCleanup(物理删)、restore。
|
||||
- 「公开可见 = 已回答」(`Questioner` 已挡私密/未回答),所以**「回答」这个动作同时是生产与公开**,处罚打这里最准。
|
||||
|
||||
### 立场(待用户确认)
|
||||
|
||||
- 赞同罚箱主:主动引导投稿属滥用产品组织围攻,不是内容治理能覆盖的。
|
||||
- 反对罚「提问(收信)」:伤害来自「发布」,禁收信对已公开的 85 条无效,且给箱主"被平台打压"叙事。
|
||||
正确梯子是从传播面往下收:停止分发 → 收窄公开 → 冻结发布(回答/公开)→ 停用页面 → 封禁。
|
||||
- 反对「多次被禁自动封号」:本场景举报本身是攻击武器,自动级联等于给组织化举报装自动扳机。
|
||||
必须"举报成立(带证据快照)+ 人工判定 + 可申诉 + 留痕"。
|
||||
- 判据必须用行为指纹而非文本关键词:挂人内容占比、独立指涉对象数、处置后复发率。
|
||||
- ⚠️ 本例箱主自述被"澄毒长期辱骂",可能是互殴一方 → 流程必须双向取证,否则单边处罚会激化对立。
|
||||
|
||||
## 追加:改为运营者人工裁定(用户倾向)
|
||||
|
||||
用户提出:被举报不自动封号,交由运营者本人判断。此方向认可,但补三条前提,否则会退化成"看心情处置":
|
||||
|
||||
1. 判定标准必须**先书面化**(什么算挂人:可识别第三方 + 贬损/号召规避/曝光主页 + 无公共利益),
|
||||
否则同一人不同时间判出不同结果,比误封更伤公信力。
|
||||
2. **证据包自动生成**(内容快照 + 来源 IP 聚合 + 该箱主历史处置 + 同 token 的关联条目),
|
||||
人工只做裁决,不做取证的体力活。
|
||||
3. 处置必须**留痕 + 可申诉 + 有冷静期**(举报后 24h 再判),这是平台履行"通知-删除"义务的自证材料;
|
||||
自动封号反而更难自证。
|
||||
|
||||
分级矩阵(裁定 × 内容性质)作为处置口径:不成立→不处理(C 类转人工);成立首次→A 提示整改、
|
||||
B L1 停止分发、C L1+L2;成立复发→A L1、B L2、C L3;恶意持续→A L2、B L3、C L4→L5。
|
||||
|
||||
互殴/选边风险:无法判定过错方时**对等处置**(双方同 L1),不选边;运营者本人有圈内立场,
|
||||
封号类必须二次复核 + 统一模板公告,不写个人评价。L1 类建议静默生效(不单独通知),
|
||||
避免暴露"平台在盯内容"后转为图片/缩写等更隐蔽表达。
|
||||
|
||||
运营侧还缺一个"处置台":举报列表、证据聚合、一键执行 L1–L5、留痕查询 —— 目前完全没有。
|
||||
|
||||
## 追加:用户明确要求「不要后台」,改为无 UI 实现
|
||||
|
||||
审阅后确认项目结构:`/user` 是箱主自助面板(`reqUserSignIn`),**无 admin 角色、无站方后台**;
|
||||
CLI 骨架在 `cmd/tamabox.go`(`internal/cmd/*.go` 每个命令一个文件,范式见 `ban.go`:`conf.Init` → 拼 DSN →
|
||||
`db.Init` → 操作 → 确认 → 执行)。告警可用 `conf.Security.SecurityAlertEmail`(`internal/conf/static.go:70`,
|
||||
autoban 已在用)+ `mail.SendSecurityAlertMail`。
|
||||
|
||||
因此"处置台"改为三条非 UI 腿:
|
||||
1. **举报采集**:单题页举报按钮 → `content_reports` 表;对箱主可见走 `/user/questions?filter=reported`
|
||||
(这是他自己的收件箱,不是站方后台),举报的处置第一责任人仍是箱主。
|
||||
2. **告警**:达到阈值(同一箱主 24h 内 N 条举报)发邮件到 `security_alert_email`,这是运营者唯一需要的"入口"。
|
||||
3. **处置**:`TamaBox reports --domain` 出证据包、`TamaBox restrict --domain --level --days --reason`、
|
||||
`TamaBox unrestrict`。状态写 `user_restrictions`(带 `expires_at`,判断时比较时间 → **过期自动失效,不需要 cron**)。
|
||||
|
||||
生效点(已定位):`QuestionShareCard`(share.go:808)、`GetShowInHome`/`GetByTag`/`getRandomUsers`
|
||||
(users.go:620/643/749,注意 GetRandom 有 30s 缓存)、`FeatureOpen`(feature_control.go:364,可一处覆盖箱主自设)、
|
||||
`PublishAnswer`/`SetPublic`/`AnswerFollowUp`、`question.Pager`(L4 整页停用)。
|
||||
|
||||
关键约束(已向用户说明):不能复用 `feature_controls`(箱主可自行 `enabled=true` 解除,语义混淆);
|
||||
不用配置文件方案(要重启、无法表达到期时间);无后台 ⇒ 只能**事后处置**,不能事前审核,口径需写成
|
||||
"发布后 24–48h 内可被处置";举报必须防刷(同 IP + 同 question 唯一),否则邮箱会被刷爆。
|
||||
|
||||
## 已落地(第 1–2 步 + L1 两个出口),已实测
|
||||
|
||||
备份:`C:/OtherFiles/Github/TamaBox-v4-mainline-before-abuse-restrict-20261018.tar.gz`(3.2M,排除 .git/.workbuddy)。
|
||||
|
||||
新增/改动:
|
||||
- `internal/db/restriction.go`(新):`RestrictionLevel` L1–L4、`UserRestriction`、`ContentReport`、
|
||||
`RestrictionsStore`(Create 替换语义 / RevokeActive / ActiveLevel / ActiveLevels / ListByUserID)。
|
||||
两个模型都**刻意不嵌 dbutil.Model**:处置记录与举报都不软删(撤销走 `revoked_at`),
|
||||
且带 `DeletedAt` 的唯一索引会留下"删了仍占槽位"的陷阱。
|
||||
- `internal/cmd/restrict.go`(新):`reports` / `restrict` / `unrestrict`,复用 `fmtScan` 做 YES 确认。
|
||||
- `internal/db/db.go`:`AllTables` 加两表,`db.Init` 装配 `Restrictions`。
|
||||
- `tamabox.go` + `cmd/tamabox.go`:注册三个命令(并给根入口补上一直缺失的 `restore`)。
|
||||
- `route/question/share.go`:`QuestionShareCard` 在任何级别处置下都拒绝生成分享图(fail-closed)。**L1 核心出口**。
|
||||
- `internal/db/users.go`(CRLF,用 python 脚本改):`GetShowInHome` / `GetByTag` / `getRandomUsers`
|
||||
加 `id NOT IN (activeRestrictionsSubQuery(...))`,把被处置箱主从首页/探索/标签/随机位摘除。
|
||||
`GetRandom` 有 30s 缓存 → 随机位最多滞后 30s,其余即时。
|
||||
- `internal/db/restriction_test.go`(新):覆盖替换 / 升级 / 到期失效 / 撤销幂等 / 曝光排除。**测试全绿**(3.87s)。
|
||||
|
||||
实测踩到的两个坑:
|
||||
1. **只改 `cmd/tamabox.go` 命令不生效** —— 真正入口是根 `tamabox.go`,报 `No help topic for 'reports'`。
|
||||
2. 证据包比例首版写成「举报次数 / 公开回答数」,实测出现 **166.7%**(5 次举报 / 3 条回答)。
|
||||
已改为「被举报**条目数** / 公开回答数」,举报次数单独展示。
|
||||
|
||||
CLI 冒烟(临时 PG 库 + 临时 ini):reports 证据包 → restrict L1 → 证据包显示生效 → pending 列表 →
|
||||
unrestrict → 显示"已撤销";升级 L1→L3 后旧记录 `revoked_at` 置位、新记录 L3 生效。清理已 DROP 临时库。
|
||||
|
||||
未做(下一步):L2/L3 发布侧拦截(`PublishAnswer`/`SetPublic`/`AnswerFollowUp` + `FeatureOpen` 覆盖箱主自设)、
|
||||
L4 整页停用、举报按钮与箱主面板筛选、邮件告警。分享卡拦截未做 web 端到端验证(仅编译 + 单测覆盖 store 层)。
|
||||
|
||||
## 已落地(第二批:L2 / L3 / L4 发布侧),编译与单测通过,web e2e 受沙箱限制未做
|
||||
|
||||
- `internal/db/restriction.go`:加 `restrictionLevelRank` + `RestrictionAtLeast(level, target)`。
|
||||
显式权重而非字符串比较(将来加 L10 不会静默排错)。空/未知级别恒 false,防止脏数据锁死箱主。
|
||||
- `route/question/question.go`(CRLF,python 改):
|
||||
- `PublishAnswer`:L3+ 拒绝(`question.errRestrictedPublish`);L2 **先 SetPrivate 再 AnswerByID**,
|
||||
保证任何时刻都不出现「公开 + 已作答」的中间态(不是先答后藏)。
|
||||
- `SetPublic`:L2+ 拒绝(`question.errRestrictedPublic`),否则等于箱主自行解除处置。
|
||||
- `route/question/follow_up.go`:`AnswerFollowUp` L2+ 拒绝 —— 追问回答同样是公开内容且没有私密档可降。
|
||||
- `route/question/page.go`:`Pager` 里 L4 → `Redirect("/restricted")`。此处 **fail-open**
|
||||
(DB 出错放行):它不承担外传通道职责,fail-closed 会让所有箱子一起白屏。
|
||||
- `route/user/restriction.go`(新)+ `templates/user/restricted.html`(新)+ `internal/route/route.go`
|
||||
注册公开路由 `/restricted`(必须在 reqUserSignIn 之外)。
|
||||
- i18n 4 语言各加 5 键(`question.errRestricted*` / `question.flash.answeredPrivate` / `restricted.*`),
|
||||
`TestDictionaryComplete` 通过。i18n 与 route 文件均为 CRLF:用 python 插行 → `gofmt | 转 CRLF` 写回。
|
||||
|
||||
修正上一轮的判断:**`FeatureOpen` 不需要改**。L3 的语义是「冻结发布、仍可收信」,而 feature_controls 控制的是
|
||||
收集(收信);关掉它反而违背 L3 定义。L4 已经由 Pager 整页挡住。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`TestRestrictions` / `TestRestrictionLevelValidation` / `TestRestrictionAtLeast`
|
||||
/ `TestDictionaryComplete` 全绿。**L4 与分享卡的 HTTP 行为未验证** —— 本沙箱无 redis,
|
||||
`TamaBox web` 启动即 panic,详见 MEMORY.md 的构建与测试一节。
|
||||
|
||||
## 追加:部署自检脚本(用户要部署到测试服务器)
|
||||
|
||||
新增 `dev/devcheck-restrict.sh`:串起「基线 → 施加 L4(断言 302 + /restricted 200 + 文案)→ 降级 L1
|
||||
(断言页面 200 + 分享卡 302)→ 撤销」,默认 dry-run,`RUN=1` 才执行,`trap EXIT` 兜底撤销。
|
||||
只依赖 CLI 与 curl,不需要 psql。`bash -n` 通过,LF 行尾。
|
||||
|
||||
同时核实:`Dockerfile` 的构建命令是 `go build ... ./cmd/`(不是根目录),所以两个入口必须同步 ——
|
||||
已写进 MEMORY.md。部署前必须先确认 `./NekoBox --help` 里有 `restrict`,否则是旧产物。
|
||||
手册里另注明两项只能人工手测:L2(回答应保存为私密)、L3(回答应被拒绝)。
|
||||
|
||||
### 首次实跑反馈(用户 dry-run)
|
||||
|
||||
用户给 `DOMAIN=admin56` 跑 dry-run,结果 2 项 FAIL。日志证据:`SELECT * FROM "users" WHERE domain='admin56'`
|
||||
→ **record not found**,说明该 domain 不存在(很可能把账号名当成了 domain)。所有 `/_/admin56` 的 302
|
||||
都来自 `GetByDomain` 失败后的 `Redirect("/")`,与处置无关 → **L4 那项 PASS 是假阳性**。
|
||||
同时 dry-run 不施加处置,前后状态没变,状态断言本来就无意义 —— 是脚本设计缺陷。
|
||||
|
||||
已修 `dev/devcheck-restrict.sh`:
|
||||
1. 基线不是 200 直接中止并打印排查建议(302 常见原因 + 查真实 domain 的命令);
|
||||
2. dry-run 在基线后短路,不再做状态断言(只验命令存在、基线、停用页);
|
||||
3. 新增 1b「停用页是否部署」+ 文案断言,dry-run 也能验 i18n/模板。
|
||||
|
||||
教训(已与既有坑同类):**302 / 200 都不能单独证明处置生效**,必须先确立「基线 200」这个前提。
|
||||
|
||||
### 二次实跑:7/7 全绿(真实环境)
|
||||
|
||||
`DOMAIN=admin6`:基线 200 → L4 302 → L1 200 + 分享卡 302(qid=73)→ 撤销后 200。
|
||||
证明 L1 分享卡拦截、L4 整页停用、撤销、以及 L4→L1 的**替换语义**在真实库上都按预期工作。
|
||||
|
||||
**随后发现并修掉一个顺序缺陷(L2)**:原实现把「强制私密」放在 `moderation.Check` 之前,
|
||||
一旦审核失败(内容违规或审核源不可用,fail-closed),题目已被改成私密——箱主一次失败的编辑
|
||||
会让原有公开内容凭空消失。现已移到审核通过之后、`AnswerByID` 之前,仍满足
|
||||
「任何时刻不出现公开+已作答」(此刻它是未作答的私密题)。编译 EXIT=0、gofmt 干净。
|
||||
⚠️ 该修复需重新部署才可在测试站手测 L2/L3。
|
||||
|
||||
L2/L3 的手测断言(比看 flash 可靠):
|
||||
- L2 后 `select is_private, answer from questions where id=<qid>` → `t` 且 answer 非空,公开页不显示;
|
||||
- L3 后 `answer` 仍为空串(未被写入),`is_read` 不变。
|
||||
- 干扰项:回答会走七牛审核,审核不可用会 fail-closed 报「审核服务不可用」,那不是处置问题。
|
||||
|
||||
### L2 线上实测:功能生效,但「箱主无感知」被误判成没效果
|
||||
|
||||
用户报「L2 没效果」。我用匿名视角实测(这是我能独立做的部分):
|
||||
- 公开页 `/_/admin6` 只出现 73,**不含 88**;
|
||||
- 匿名访问 `/_/admin6/88` → **302 到首页**;对比 `/_/admin6/73` → 200。
|
||||
|
||||
结论:L2 私下里是生效的。用户误判的两个原因:
|
||||
1. 他**以箱主身份**看单题页 —— 箱主本来就能看自己的私密内容,页面当然「一切正常」;
|
||||
2. **我的 bug**:`question.flash.answeredPrivate` 只写进了 4 个 i18n 字典,**代码里从未使用**,
|
||||
成功后仍提示「回答发布成功」。已修:L2 分支改用 `forcedPrivate` 布尔切换 flash 文案。
|
||||
|
||||
教训:这些 i18n 键写完必须 grep 调用点;「加了文案」不等于「接上了逻辑」。
|
||||
|
||||
### 用测试账号自测(已跑通登录态全链路,不需要用户点击)
|
||||
|
||||
用户提供测试箱主 `admin6`(密码不落盘)。用 python + urllib 跑通:
|
||||
`GET /login` 取 `_csrf` → `GET /api/captcha/generate` 取 `{id, question}`(形如 `5 × 5 =`,
|
||||
注意乘号是 `×` 不是 `*`)→ 算答案后提交 `g-recaptcha-response="<id>:<答案>"` → 登录成功;
|
||||
再 `GET /_/admin6` 取表单 csrf → `POST /_/admin6`(字段 `content`)发提问 →
|
||||
`POST /_/admin6/{id}/answer`(字段 `answer`)。
|
||||
|
||||
实测结论(qid=89):
|
||||
- **L2 服务端生效**:回答保存成功,但该题变私密 —— 匿名访问 302 到首页,公开页只有 73;
|
||||
- **flash 仍是旧文案**「回答发布成功」→ 服务器跑的是 21:22 部署的二进制,我 21:45 的 flash 修复未部署。
|
||||
- 88(用户手测)与 89(我自检)都是测试数据,均已被 L2 强制转私密,测完需在后台清理或保持私密。
|
||||
|
||||
### L3 验证(重新部署 + 切 L3 后)
|
||||
|
||||
qid=90:发提问成功(L3 允许收信 ✔);`POST /answer` **没有重定向**、URL 停在 `/answer`、
|
||||
重新加载单题页仍有回答表单 → **发布确实被拒绝、内容未写入**。
|
||||
但响应里**没有任何提示文案**(`uk-alert` 只出现在 CSS 里)→ 又一个 bug:
|
||||
|
||||
⚠️ **同请求渲染不能用 `SetErrorFlash`**。`ctx.Success("question/item")` 是直接出页面(没有 302),
|
||||
而 `SetErrorFlash` 写的是 session、要等下一次请求才读 → 提示静默消失,箱主看到的是「点了没反应」。
|
||||
已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`,
|
||||
并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。
|
||||
**该修复需再部署一次**;部署后当前仍是 L3,可直接验提示是否出现。
|
||||
|
||||
### 重新部署后 L3 提示验证:通过
|
||||
|
||||
qid=90 再答一次:`ALERT: 当前处于平台处置期,已暂停发布回答` 已渲染;仍无重定向、回答表单仍在
|
||||
(未写入)。L3 全链路闭环。待办:用户要求后续做「`SetErrorFlash + Success` 全量清理」专项。
|
||||
|
||||
剩余未验:L2 的「已保存为私密」提示(需把 admin6 切到 L2 后自测),以及举报采集链(未开始)。
|
||||
|
||||
### L2 提示验证:通过(qid=90)
|
||||
|
||||
```
|
||||
post url: /_/admin6/90 (正常 302 回题目页)
|
||||
ALERT: 已保存为私密:当前处于平台处置期,回答不会公开
|
||||
匿名访问 90 -> 首页;公开页 ids: [73]
|
||||
```
|
||||
L1/L2/L3/L4 至此全部有真实环境实测证据:
|
||||
- L1:页面 200 + 分享卡 302(qid=73)
|
||||
- L2:回答成功 + 转私密 + 匿名不可见 + 专用提示
|
||||
- L3:回答被拒(无重定向、表单仍在、未写入)+ 拒绝提示
|
||||
- L4:整页 302 → /restricted 200 + 四语言文案
|
||||
|
||||
测试数据:88(用户手测)、89(L2 自检)、90(L3/L2 自检,现已答且私密),均需在测试站清理。
|
||||
admin6 当前处于 L2(到期次日),必要时 `unrestrict` 解除。
|
||||
|
||||
## 举报采集链(已落地,编译 + 单测 + 模板检查通过,未部署验证)
|
||||
|
||||
新增/改动:
|
||||
- `internal/db/report.go`(新):`ReportsStore` —— `Create`(唯一索引冲突静默为幂等成功)、
|
||||
`QuestionIDsByUser`(箱主列表标标,排除已删除提问)、`CountByUserSince`(告警阈值)。
|
||||
理由枚举 `harassment/doxxing/divisive/other` + `IsValidReportReason`。
|
||||
- `internal/form/question.go`:`ReportQuestion{Reason}`(`valid:"required;maxlen:32"`)。
|
||||
- `route/question/report.go`(新):`Report` handler。**不要求登录**(目击者是匿名访客);
|
||||
理由白名单;同 IP 同题幂等且提示与首次一致(避免变成内容存在性探测接口);只写库不改可见性。
|
||||
- `route/question/abuse_notify.go`(新):阈值告警 24h≥8 或 7d≥20,24h 冷却(进程内 map + mutex),
|
||||
复用 `conf.Security.SecurityAlertEmail`,`shared.SafeGo` 异步发送。**只提醒不自动处置**。
|
||||
- `internal/mail/mail.go` + `templates/mail/abuse-alert.html`:`SendAbuseAlertMail`。
|
||||
邮件只给计数与下一步命令,**不贴举报内容原文**(邮件会被转发归档,等于又复制一份挂人内容)。
|
||||
- `internal/route/route.go`:`POST /_/{domain}/{questionID}/report`,`rateLimitMiddleware(10, 1*time.Minute)`。
|
||||
- `templates/question/item.html`:回答下方 `⚑ 举报这条内容`,`<details>` 折叠 + 固定理由下拉,
|
||||
仅 `{{ if not .CanDelete }}`(非箱主)可见。
|
||||
- `templates/user/question-list.html` + `route/user/question.go`:箱主列表加「被举报 N 次」红色 badge,
|
||||
**只给数量不给举报人 IP**。
|
||||
- i18n 4 语言共 11 个新键(report.* / qlist.reported*)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`TestReports` PASS(首次写入 / 同 IP 幂等 / 异 IP 计数 /
|
||||
时间窗口 / 驳回后不计入);`TestDictionaryComplete` PASS;`go run ./dev/checktpl` → all templates syntax OK
|
||||
(项目自带模板语法检查工具,改模板后必跑)。
|
||||
|
||||
未做:箱主列表的「只看被举报」筛选(目前只有 badge);举报驳回/成立的处置动作(目前 status 只有 pending)。
|
||||
|
||||
### 举报链线上验证:4/4 通过
|
||||
|
||||
- 入口:单题页 `/73` 出现「举报这条内容」+ form action `/_/admin6/73/report`;
|
||||
- 首次举报:POST 302 → flash「已收到举报,感谢反馈」;
|
||||
- 同 IP 重复:同样 302 + 同样文案,无失败提示(幂等);
|
||||
- 箱主 `/user/questions`:出现「被举报 **1** 次」(多次重复提交后计数仍为 1,反证幂等生效)。
|
||||
|
||||
**过程教训**:中途 python urllib 报「读超时 25s」,一度怀疑服务端挂起。实测 curl 同一接口
|
||||
1.6~1.7s 正常返回 302,`location=/_/admin6/73`,手动 GET 也 200。真因是 python 走本地代理
|
||||
(`https_proxy=127.0.0.1:54881`)偶发挂起 + `/tmp` 路径在 Windows python 与 MSYS 之间不互通。
|
||||
已写进 MEMORY.md:**验证线上行为优先用 curl**。
|
||||
|
||||
### 告警触发验证(进行中)
|
||||
|
||||
直接 SQL INSERT 不会触发告警(`notifyAbuseIfNeeded` 只在 HTTP 举报 `created=true` 时调用)。
|
||||
正确姿势:SQL 补到 7 条 → 删掉自己那条旧记录 → 再用 HTTP 提交一次,使 count 恰好到 8。
|
||||
已按此执行(curl 提交返回 302 + 「已收到举报」),等待确认日志
|
||||
`Abuse alert mail sent` / `Failed to send abuse alert mail` / 什么都没有(= `security_alert_email` 为空)。
|
||||
|
||||
### 邮件告警验证通过
|
||||
|
||||
配置 `security_alert_email` + 重启后,补到 7 条再 HTTP 提交第 8 条,邮件成功收到;测试数据已清理。
|
||||
举报链至此全部闭环。
|
||||
|
||||
### 静默丢失专项清理(用户要求的那项)
|
||||
|
||||
静态扫描 `route/**/*.go`:flash 调用后第一个 `Success(`/`Redirect(` 判定,共 **9 处**缺陷
|
||||
(`follow_up.go` 2、`page.go` 1、`question.go` 1、`profile.go` 5),全部改为
|
||||
`SetError(err[, f])` / `SetInternalError()`。`Set*Flash + Redirect` 112 处正常,未动;
|
||||
`vote.go:702/704` 是扫描误报(其后 switch 之后才是 Redirect)。
|
||||
`go build ./...` EXIT=0;`go test ./internal/db/ ./internal/i18n/` 全绿(36s)。
|
||||
⚠️ 这 9 处只做了编译与静态验证,未逐条构造触发条件(需传超大图/失效追问 id),
|
||||
部署后实际遇到对应错误时观察提示是否可见。
|
||||
|
||||
### 举报加验证码 + 箱主列表「只看被举报」筛选
|
||||
|
||||
- 举报验证码:`form.ReportQuestion` 加 `Recaptcha`(**刻意不加 valid:required**,空值交给
|
||||
`captcha.Verify` 判定,否则 Bind 失败会被统一提示成「请选择一个举报理由」);
|
||||
`Report` handler 签名加 `recaptcha recaptcha.RecaptchaV3`,调 `captcha.Verify`(与提问/私聊同源)。
|
||||
模板按 `CaptchaType == "math"` 双分支:math 用隐藏域 + `.math-captcha-trigger`(header 的全局弹窗
|
||||
用 `btn.closest('form')` 定位表单、`form.querySelector('[name="g-recaptcha-response"]')` 回填,
|
||||
**不依赖 id**,所以举报表单可直接复用);非 math 用 `.g-recaptcha` + 自定义 callback `tbReportCaptchaOk`。
|
||||
- 筛选:`/user/questions?reported=reported`,复用已查出的 `reportCounts` 在内存过滤,
|
||||
新增 `SelectedReported` + 模板第 5 个下拉(reportFilter / reportedOnly)+ 4 语言文案。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`go run ./dev/checktpl` → all templates syntax OK;
|
||||
`go test ./internal/i18n/` 通过。**未部署**,且加了验证码之后原来的 curl 测试方式要改成先算题再提交。
|
||||
|
||||
### 部署后验证:4/4 通过
|
||||
|
||||
- 无验证码提交 → 302、**未写库**、提示「验证码不能为空」(i18n key `captcha.errEmpty`,非硬编码);
|
||||
- 带正确算数验证码 → 302 + 「已收到举报,感谢反馈」;
|
||||
- 同 IP 重复(换新验证码)→ 同样成功提示(幂等,未新增记录);
|
||||
- 筛选:全部页 `73, 88, 89, 90`,`?reported=reported` 只剩 `73`,下拉 `option value="reported" selected`。
|
||||
|
||||
测试遗留:73 号题有 1 条举报(我的测试),需 `DELETE FROM content_reports WHERE question_id = 73;` 清理。
|
||||
|
||||
## 新增 `TamaBox hide-public`(存量批量处置)
|
||||
|
||||
`internal/cmd/hide_public.go` + 两个入口注册。默认只动「公开且已回答」的题;
|
||||
`--only-reported` 只处理有 pending 举报的(推荐);`--since N` 限时间窗。
|
||||
编译 EXIT=0,gofmt 干净。**未在服务器验证**(需部署)。
|
||||
|
||||
## 待办与建议(已告知用户,按优先级)
|
||||
|
||||
- P0 举报状态闭环:箱主删除/设私密后,该题 pending 举报应置 `accepted`,否则告警阈值会被已处理的旧举报
|
||||
不断推高(狼来了),且无法统计「成立率」。当前 status 永远停留在 pending。
|
||||
- P0 箱主侧处置可见性:`/user/questions` 顶部常驻条幅(读 restrictions),避免像今天这样 L2 生效但箱主不知情。
|
||||
- P1 箱主侧举报通知(某条 ≥3 次时告知箱主自查)、`reports --stats` 全站视图(发现恶意刷举报)。
|
||||
- P2 社区规则页 `/rules`,处置时引用条款。
|
||||
- 明确不建议:举报数达标自动处置(武器化)、箱主申诉后台(违背无后台约束)。
|
||||
|
||||
## 建议落地(P0/P1/P2 全部完成,编译 + 单测 + 模板检查通过,未部署)
|
||||
|
||||
- **P0 举报闭环**:`ReportsStore.MarkAcceptedByQuestion`,在箱主 `Delete` / `SetPrivate` 成功后调用
|
||||
(失败只记日志,不回滚用户操作);`hide-public` CLI 批量后同样标记。新增 `reportPendingTTL = 30d`:
|
||||
`QuestionIDsByUser` / `CountByUserSince` / `CountByQuestion` 都按时间窗过滤,**超期 pending 视为过期**,
|
||||
用查询过滤而不是定时任务(无后台项目不需要调度器)。
|
||||
- **P0 处置期条幅**:`/user/questions` 顶部读 `Restrictions.ActiveLevel` 渲染常驻 warning
|
||||
(`qlist.restrictionBanner`,四语言,带级别与到期日)。解决「L2 生效但箱主不知情」。
|
||||
- **P1 箱主自查提醒**:单条内容待处理举报**恰好等于 3** 时给箱主发邮件
|
||||
(`mail/question-reported.html`),用「恰好等于」避免每条举报都发;箱主 `Notify=none` 时不发。
|
||||
邮件明确写「不代表平台已判定违规」。
|
||||
- **P1 `reports --stats`**:全站 Top 被举报箱主 + Top 举报 IP,用于识别「举报被武器化」。
|
||||
- **P2 `/rules` 社区规则页**:公开页 + `templates/rules.html` + 四语言 10 个键。
|
||||
内容含「明确禁止的 4 类」「处置梯度 5 级」「被举报怎么办」「被攻击怎么办」,是处置的引用依据。
|
||||
⚠️ 文案由我起草,**需用户过目**(尤其对外承诺的措辞)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;`checktpl` OK;`TestReports`/`TestRestrictions` 全绿。
|
||||
|
||||
### 部署后验证:3 项通过,1 项 500 待查
|
||||
|
||||
- ✅ 处置期条幅:登录 admin6 的 `/user/questions` 出现「限制期」文案(1 处);
|
||||
- ✅ 举报 badge:「被举报 1 次」;
|
||||
- ✅ 闭环:`POST /set-private` → badge 归 0、公开页不再出现 73;
|
||||
`set-public` 返回 200(不是 302)是**预期** —— L2 禁止设公开,不是 bug。73 现处于私密,需先 `unrestrict` 才能恢复。
|
||||
- ❌ **`/rules` 返回 500**(其余公开页 `/`、`/about-me`、`/sponsor`、`/deactivated` 均 200)。
|
||||
已在本地排除:模板语法 checktpl 通过、i18n 四语言键齐全(grep 确认在 map 内)、`t` 函数返回 string 不抛错。
|
||||
**待用户提供服务端日志**(500 只回 22 字节 "Internal Server Error",无细节)。
|
||||
|
||||
顺带修掉一个**我引入的回归**:`templates/render_test.go` 的 `mockItem` 缺 `ReportCount`,
|
||||
导致 `user/question-list` 渲染测试失败(`can't evaluate field ReportCount in type interface {}`)。
|
||||
已补字段;同时把 `{{ if gt .ReportCount 0 }}` 改为 `{{ if .ReportCount }}`(更宽容,零值即假)。
|
||||
`go test ./templates/` 恢复全绿。
|
||||
|
||||
### /rules 500 根因:模板没进 embed 清单
|
||||
|
||||
日志:`template: rendering error="html/template: \"rules\" is undefined"`。
|
||||
|
||||
**`templates/embed.go` 是显式文件清单**(并非我原先以为的整目录 embed)。新增的顶层
|
||||
`rules.html` 没写进 `//go:embed ...` 那行 → 没被打包 → `ctx.Success("rules")` 找不到模板 → 500。
|
||||
编译、checktpl、单测全部无感(测试不渲染该页),只有实际访问才炸。
|
||||
|
||||
已把 `rules.html` 加进清单,编译 EXIT=0、`go test ./templates/` 通过。
|
||||
⚠️ 以后新增**顶层**模板必须改 embed 指令;子目录下的文件已被目录项覆盖。
|
||||
MEMORY.md 里那条错误认知已纠正。
|
||||
|
||||
### /rules 修复后验证:四语言全通过
|
||||
|
||||
`rules=200`(39KB);标题依次为「社区规则与内容边界 / 社群規則與內容界線 /
|
||||
Community rules and content boundaries / コミュニティルールとコンテンツの境界」;
|
||||
处置梯度段落存在。
|
||||
|
||||
发现规则页**没有任何入口**(页脚原本全是外链,无站内链接),已加
|
||||
`<a href="/rules">` 到 `templates/base/footer.html` + `site.footer.rules`(四语言)。
|
||||
编译 EXIT=0、checktpl OK、i18n 与 templates 测试全绿。**需再部署一次生效**。
|
||||
|
||||
补充(用户要求):处置条幅里「如有疑问请通过页脚找站长联系」后面再挂一个 `/rules` 链接,
|
||||
用 `·` 分隔(`qlist.restrictionRulesLink`,四语言)。模板压成单行避免多余空白。
|
||||
编译 EXIT=0、checktpl OK。仍需部署。
|
||||
|
||||
## 规则页扩展 + 注册强制同意(用户要求)
|
||||
|
||||
- **规则分两类**(关键设计):`rules.forbidden` 重写为
|
||||
「一、严重违规(直接删内容 + 按情节封禁,**不适用分级**)」6 条(涉政/暴恐/色情涉未成年/违禁品赌博诈骗/侵害未成年人/隐私),
|
||||
「二、社区冲突(**按影响面分级**)」4 条(挂人、辱骂、群体对立、垃圾广告)。
|
||||
`rules.enforce` 相应说明「分级只适用于社区冲突;严重违规不走分级,涉法配合有关部门」。
|
||||
四语言同步。这样避免「挂人只停分发、涉政也停分发」的错误预期。
|
||||
- **注册强制同意**:`form.Register` 加 `AgreeRules string`(`form:"agree_rules" valid:"required"`,
|
||||
msg `form.register.agreeRules`);注册页在 language 之后加 checkbox + `/rules` 链接(`target="_blank" rel="noopener"`)。
|
||||
**服务端校验是必须的**:前端 required 可绕过,而这个勾选是「用户已知悉规则」的唯一凭证。
|
||||
- 顺带同步 `dev/devcheck-e2e.py` 的三处注册 POST,加 `"agree_rules": "1"`,否则 e2e 注册步骤会全部失败。
|
||||
|
||||
验证:`go build ./...` EXIT=0;checktpl OK;i18n 与 templates 测试全绿。
|
||||
未用 modal 而用新标签页:规则正文很长,iframe 弹窗体验差且要重复维护内容。
|
||||
|
||||
### 部署后验证(规则扩展 + 注册同意):全通过
|
||||
|
||||
- `/rules`:含「严重违规」(6 条底线条目 + 涉政表述)、「社区冲突」(4 条);
|
||||
- 四语言标题正确;
|
||||
- `/register` 有 `agree_rules` 勾选框 + `/rules` 链接;
|
||||
- **不勾选直接 POST 被服务端拦下**(提示走站点默认语言 ja:
|
||||
`コミュニティルールをお読みのうえ同意してください`),且**未创建账号**(`/_/tbprobe` 仍 302);
|
||||
- 页脚与处置条幅里的「查看社区规则」链接都在。
|
||||
|
||||
### UI 微调(已改,未部署)
|
||||
|
||||
- `rules.html` 底部「返回首页」容器加 `uk-text-center`(原先左对齐)。
|
||||
- `question/success.html` 两个按钮改为 flex 等宽:`flex: 1 1 160px; max-width: 220px`,
|
||||
容器 `uk-flex uk-flex-center uk-flex-wrap`。**用 flex 均分而不是固定宽度** —— 各语言文案长短差异大,
|
||||
等宽不能靠文字长度。纯布局改动,不涉及 i18n,四语言自动一致。
|
||||
|
||||
补充:验证码弹窗「验证并提交 / 取消」两个按钮也不等长,按用户要求把 `captcha.submit` 改短:
|
||||
提交 / 送出 / Submit / 送信(该键只被这个弹窗使用)。
|
||||
|
||||
⚠️ 用户反馈「成功页按钮还是老样子」时,先确认**是否真的部署了**:直接抓线上 HTML 看有没有新标记
|
||||
(`grep 'uk-margin-large-top uk-text-center'` 在 `/rules` 上)。实测那次线上仍是 `uk-margin-large-top"`
|
||||
旧结构 → 与浏览器缓存无关,是代码没更新。以后别先怀疑缓存。
|
||||
(第二次复测已生效:`uk-text-center` 在、弹窗按钮 `>送信<`、成功页 `flex: 1 1 160px` 出现 2 次。)
|
||||
|
||||
## 受控用户强制最高审核档(用户要求:不分级,全部级别)
|
||||
|
||||
- `moderation.EffectiveStrictness(ctx, userID, raw)`:**有任何处置(L1–L4)就返回 strict**,忽略账号设置。
|
||||
**处置查询失败时回退账号自身设置**,而不是"失败即强制 strict" —— 否则 DB 抖动会让全站突然加严、
|
||||
正常提交被大面积误拒,影响面远大于漏放一个受控账号。
|
||||
- 7 个审核调用点全部从 `StrictnessFor(raw)` 改为 `EffectiveStrictness(...)`:chat_handler、follow_up×2、
|
||||
page、question、user/vote×3、vote/vote。`StrictnessFor` 保留为纯解析函数(模板回显用)。
|
||||
- `UpdateModerationStrictness`:处置期内直接拒绝保存并 flash `admin.flash.strictnessLocked`。
|
||||
- 设置页 `templates/user/moderation-strictness.html`:`$root.StrictnessLocked` 时提示 + select/按钮 disabled。
|
||||
⚠️ 该文件仍只能用 shell/python 改(Edit/Write 会注入 `data-page-node-id`),本次用 python 精确替换。
|
||||
- Profile handler 注入 `StrictnessLocked`(读 ActiveLevel)。
|
||||
|
||||
验证:`go build ./...` EXIT=0;checktpl OK;db/i18n/templates 测试全绿。
|
||||
性能:每次内容提交多一次 `user_restrictions` 查询(走 user_id 索引),审核本身要走七牛网络,1ms 可忽略。
|
||||
@@ -1,290 +0,0 @@
|
||||
# 2026-10-20
|
||||
|
||||
## 01:10 shiroko 主题补「空气层」
|
||||
|
||||
- 用户问「还有没有其他元素,贴合主题又不突兀」。结论:场景压白 38% 后整页亮度只有 235~246,
|
||||
再摆物件(书包/粉笔/纸飞机)一定会像贴纸,所以走**纯渐变造光**路线。
|
||||
- 做了 4 个候选截图(A 纯窗光 / B 白散景 / C 光环符号 / D 组合),用户选 D。
|
||||
⚠️ **B 是反面教材**:白色散景叠在已经压白的场景上,实测差异 0.14 级,肉眼完全不可见。
|
||||
要"提亮"必须先有暗部,真正起作用的是「底部压深 + 暖色光斑」的组合。
|
||||
- 最终 `--tb-deco` 后 4 槽:左窗光柱(112deg)、右侧 120px 暖散景(88% 30%)、
|
||||
脚下 560×170 地面光斑(28% 94%)、底部 24% 压深。亮度差 3~9 级。
|
||||
- 验证手段(本轮**模型读不了图**,全靠数值):留白区差分(与候选 D 差 0.0)、
|
||||
分区亮度表、横向扫描相邻像素差 max 4(判色阶断层)、PNG 体积比对。
|
||||
- ⚠️ 造预览页两个坑:① 覆盖块必须包在 `<style>` 里,裸写会被当正文渲染,整页布局炸掉;
|
||||
② `--tb-deco` 各层之间**漏逗号**会让整条声明失效 → 人物和所有背景层全没,且各变体看起来"完全一样"。
|
||||
以后先 diff 两个变体的 CSS 文本再截图。
|
||||
- `StaticVersion` → `20261020a`;提交 `fb94b8c` 已推送。文档 `docs/theme-shiroko.md` 加「空气层」一节。
|
||||
|
||||
## 08:30 重画光环
|
||||
|
||||
- 用户反馈「光环变形太奇怪,而且中间没有扣干净」。根因两条:
|
||||
① AI 画的环本身歪(bbox 291,93–584,232,比头还宽、偏移到左边);
|
||||
② 环内是**封闭白孔**(面积 5190px 的包围区域),四角 floodfill 进不去 → 抠完环里糊着一团白。
|
||||
- 做法:非白像素从环上一点漫水拿到独立连通域(与头发不连通,头部顶端在 y≈265,环只到 232),
|
||||
整块抹白;**先把 alpha 建好,再在 RGBA 上画新环**——环心天然就是透明的,不用给封闭孔补洞。
|
||||
- 新环参数:中心 (515,160)、100×34、线宽 10、-7° 倾斜、4 倍超采样后 LANCZOS 缩小、
|
||||
外加 45% 模糊柔光;颜色 (127,211,220),压白 16% 后实测环心 (164,220,224)。
|
||||
- 坑:擦除矩形右边界要绕开**灰色狼耳**(x>630,y 190-260,颜色 (139,140,146) 不是青色),
|
||||
差点一起抹掉。判据是连通域 bbox 而不是"上方全是空白"。
|
||||
- 验证:alpha 字符图确认环闭合、环心 alpha=0;渲染后页面里青色像素 bbox (201,467)-(271,649),
|
||||
环在 y 468-486,头顶约 505,间距正常。
|
||||
- `StaticVersion` → `20261020b`,图片 `?v=20261020b`;提交 `2e9bd6b` 已推送。
|
||||
|
||||
|
||||
## 09:40 部署反馈三修 + 自行车/步枪
|
||||
|
||||
- **狼耳顶端被削平**:上一版用矩形 `(281,83)-(594,242)` 抹旧光环,右下角正好切到左耳尖
|
||||
(耳尖在 y≈185、x 420-480)。改成只抹光环的**非白连通域**(bbox 291,93-584,232,与头发不连通)。
|
||||
- **手内侧 / 大腿内侧白没抠干净**:那是**闭合**的白色区域,四角 floodfill 进不去。
|
||||
加封闭白孔检测(>=246 且不与边缘连通、面积>=40)整块掏成透明。
|
||||
⚠️ 阈值必须 >=251 才能当"背景":背景恒为 (253,253,253),而**白袜子是 237**,
|
||||
用 246 当背景阈值会把袜子一起吃掉(第一版就吃穿了一只袜子)。
|
||||
- **人物像站在课桌上**:原场景前景全是课桌,根本没有地面。重出场景:
|
||||
前景留空地、课桌退到右后方,人物脚下才是地板。
|
||||
- **自行车 + 步枪**:独立素材 `props/bike.webp`(镜像成车头朝右、与人同向、压白 26%、
|
||||
自带接触阴影),放在**人物身后偏左**。deco 层顺序 = 画在上面的在前,所以第一层是人、
|
||||
第三层是车,车天然被她挡住。
|
||||
- ⚠️ **`background-position` 的百分比是相对 `(定位区宽 - 图片宽)`**,不是相对视口宽。
|
||||
人(117px)和车(397px)用同一个 `calc(50% - 901px)` 会差出 280px,车直接跑出屏幕只剩一片轮圈。
|
||||
按各自宽度反解后:人 `calc(50% - 524px)`、车 `calc(50% - 612px)`。
|
||||
- ⚠️ 道具**不能烘焙进背景**:背景走 `cover`,视口一变裁切量就变,和固定 px 的人物必然分家。
|
||||
- 场景重出后色调用「逐通道均值/方差对齐旧场景」保持同一雾度;移动端从同图竖裁,避免两个房间。
|
||||
- `StaticVersion` → `20261020d`;提交 `15aebd6` 已推送。
|
||||
|
||||
## 09:55 素材规格书
|
||||
|
||||
- 用户打算换工具自己生成素材 → 写了 `docs/theme-shiroko-assets-spec.md`:
|
||||
画布/导出/显示尺寸、比例(人 1:3.41、道具 1.53:1、背景 16:9)、雾度(人 16%、车 26%、场景 30~38%+模糊 1.2~1.4px)、
|
||||
构图硬约束(左侧 22% 前景必须是空地,否则又回到"站在课桌上")、换图清单与定位公式。
|
||||
- 配色给的是**压白后的显示值** + 反算公式 `原色=(显示值-40.8)/0.84`,并特别标注袜子要用 `#ededed`
|
||||
而不是纯白(纯白会和背景粘连,抠图必然吃穿)。
|
||||
|
||||
## 10:40 主题生成器(dev/theme-builder)
|
||||
|
||||
- 单文件 `dev/theme-builder/index.html`:上传背景/人物/道具 → 拖拽调位置 → 导出 zip,
|
||||
解压到项目根即可新增主题。不联网、不起服务、图片全在浏览器本地转 WebP。
|
||||
- 预览用**和线上同一套机制**渲染(pattern + deco 6 层 + page-image,size/position 对齐),
|
||||
不是另画示意图;拖出来的「左沿像素」反解成 `calc(50% - Npx)`,换视口不跑位。
|
||||
- 导出包:`static/css/themes/<slug>.css` + `static/images/themes/<slug>/*.webp` + `INSTALL-<slug>.txt`。
|
||||
CSS 同时写浅色/深色两块,10 个必需令牌齐全——**实测通过 `TestThemeCSSComplete`**。
|
||||
- zip 自己实现(STORE + CRC32,约 40 行),不引 CDN;`unzip -t` 无错。
|
||||
- 踩坑:① 面板里 `c.text2` 写成 undefined(light 对象里才算出来的派生色,要用 `light.text2`);
|
||||
② `contain` 模式如果不按内容裁画布,会留大片透明边 → 导出宽高比全错;
|
||||
③ 预览容器 `grid-template-columns:340px 1fr` 会被 1512px 的 frame 撑破 → `minmax(0,1fr)`;
|
||||
④ 无头 Chrome 下 `canvas.toBlob` 的回调在 `--dump-dom` 前不触发 → 全部改 `toDataURL` 同步化,
|
||||
自检才能跑(`?selftest=1` 输出 CSS/zip 字节,`?demo=1` 载入示例素材)。
|
||||
- ⚠️ 预览里深色必须走「暗色渐变 + deco none」,否则会拿白天场景图压深底(和导出结果不一致)。
|
||||
|
||||
## 11:05 主题生成器重做:真实页面画布 + 按页面作用域
|
||||
|
||||
用户原话:旧生成器「只针对当前遇到的问题,需要更通用」「示例页面和我的项目完全不一样」
|
||||
「想针对箱主页面、问题页面、问题列表、设置页面分别配置」。
|
||||
|
||||
**机制层(新)**
|
||||
- `internal/context.pageScope()` 按路径归类,`<html data-page-scope>`:
|
||||
`box`(/_/{domain}) / `question`(/_/{domain}/{id}) / `admin`(/user/questions,/user/votes*)
|
||||
/ `profile`(/user/*) / `vote`(/vote/{token})。首页与 `/explore` 返回了值但**不加载主题 css**,
|
||||
所以生成器里不列它们(列了就是骗人)。
|
||||
- ⚠️ 深色覆盖必须是**三重选择器** `html[data-theme="dark"][data-theme-style][data-page-scope]`:
|
||||
浅色作用域块特异性 0,2,1 与深色块相同、且写在后面,二重选择器会被它盖掉。
|
||||
- 装饰槽位 **6 → 10**(base.css 背景 8 → 12 层 = pattern1 + deco10 + image1)。
|
||||
`TestDecoSlotCount` 逐文件逐声明检查,变异验证有效(写 6 个会 FAIL)。
|
||||
|
||||
**生成器(重写)**
|
||||
- 画板 = iframe 加载 `outputs/preview-*.html`(真模板渲染)。**必须起本地服务**:
|
||||
file:// 下父页面读不到 iframe 的 DOM(即使同目录),预览只能退回示意图。
|
||||
`dev/theme-builder/serve.py` 把项目根当静态目录(只读)。
|
||||
- 每个作用域独立「整页背景 + 最多 10 层装饰」,默认跟随全局;不产覆盖块就自动回退。
|
||||
- 图层 = 图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移;图片层可在画板上直接拖。
|
||||
- ⚠️ 位置统一 `calc(0%|50%|100% ± Npx)` 两值:百分比是相对 (容器 − 图片)。
|
||||
**不要** `left/bottom` 关键字混搭 —— 「水平居中 + 垂直贴底」是三值语法,非法时整条声明作废
|
||||
→ 图层静默掉到左上角(这是之前踩过的坑,这次直接规避)。
|
||||
- ⚠️ 配色只让填 6 个基础色,另 4 个必需令牌派生:`surface-2` 往 **text** 混(浅色下变深、
|
||||
深色下变亮,方向自动对),`text-secondary/muted` 往 bg 混,`primary-weak` 加 alpha。
|
||||
漏了 `--tb-primary-weak` 时 `TestThemeCSSComplete` 只报「缺少」,页面看着没问题,很容易混过去。
|
||||
- 自检钩子:`?dump=css` / `?dump=zip`(写进 `<pre id="dump">`),配合 `--dump-dom` 回归。
|
||||
|
||||
**验证**(都是实测,不是推断)
|
||||
- 生成器导出的 CSS 放进 `static/css/themes/` → `TestThemeCSSComplete` + `TestDecoSlotCount` 双绿;
|
||||
删掉深色块做变异 → FAIL,说明测试真的在查。
|
||||
- 真实箱主页计算样式:image/size/position/repeat/attachment **各 12 层**,前几层尺寸与 shiroko 一致
|
||||
(auto 400 / 96x13 / auto 259 / 100%×3),确认扩槽没打乱顺序。
|
||||
- 临时主题 `zztest` 验证:box 页有人物、question 页 `--tb-deco: none×10` 后人物消失;
|
||||
不写覆盖块时回退全局(人物仍在)。
|
||||
|
||||
**顺手发现**:`.gitignore` 写的是 `./outputs`(无效写法),导致 247 个 outputs 文件被跟踪,
|
||||
每次跑 `TestGenPreview` 都会产生一堆 M。没擅自改(会让人以为要清理历史产物),留待用户决定。
|
||||
|
||||
## 12:20 outputs 不再纳入版本控制
|
||||
|
||||
- 根因:`.gitignore` 里写的是 `./outputs`,**gitignore 不认 `./` 前缀**,整条规则等于失效,
|
||||
247 个产物(预览快照、截图、历史 review 报告)一直被跟踪,每次跑 `TestGenPreview` 都产生一堆 M。
|
||||
- 改成 `/outputs/` + `git rm -r --cached outputs`:索引删除 247 个,**磁盘文件一个没动**。
|
||||
`git check-ignore -v outputs/x` 确认命中。
|
||||
- 另两条同样无效的规则没动:`./workbuddy`(目录根本不存在,笔误)、`./scripts`(有 1 个文件被跟踪)。
|
||||
用户只要求 outputs;要清理得单独确认。
|
||||
|
||||
## 13:10 生成器能力盘点(含实测)
|
||||
|
||||
用户问「有什么改进建议」,顺手实测了一个之前没验证过的点:**窄屏(390 宽)**。
|
||||
|
||||
- 没有 `@media` 规则的主题,在 390×844 下:16:9 桌面背景被 `cover` 裁成一条无意义纹理,
|
||||
人物被占满屏的卡片完全盖住 —— 等于白配。
|
||||
- shiroko 因为手写了 `@media (max-width:900px)`(换 `scene-mobile.webp` + deco 全关),
|
||||
同一视口下背景是协调的竖版、装饰退场。
|
||||
- 结论:**生成器导出的主题没有窄屏段**,这是真实缺口,不是理论问题。
|
||||
|
||||
其它已确认的缺口(grep 过,不是猜):无 localStorage / 无 json 导入导出(关掉全丢)、
|
||||
无图层上移下移(遮挡关系由顺序决定,只能删了重加)、无导出前校验、无既有主题导入。
|
||||
|
||||
⚠️ 测窄屏时必须让 **iframe 宽度**一起变(`verify.html?w=390`)。第一次只改外层 window,
|
||||
iframe 内部仍是 1512,媒体查询按 1512 判定 → 结论是错的。
|
||||
|
||||
## 14:20 生成器补 P0 四项
|
||||
|
||||
用户问建议 → 我给了 P0/P1 清单 → 他选「P0 四条一起做」。已实现并推送。
|
||||
|
||||
1. **配置留得住**:`localStorage` 自动草稿(renderAll 后 700ms debounce)+ `theme-<slug>.json`
|
||||
保存/打开(图片 base64 内嵌,单文件带走);zip 里也塞一份 json;`?fresh=1` 跳过恢复。
|
||||
2. **图层 ↑↓**:顺序即层级(`background-image` 第一层画在最上)。
|
||||
3. **窄屏段**:竖版背景图 + 隐藏装饰 + 断点(默认 900),导出 `@media` 块。
|
||||
⚠️ **只写浅色规则**:深色块特异性 0,2,1 > 浅色 0,1,1,本来就压住它;多写一份反而容易出错。
|
||||
4. **导出前校验**:slug / 图片层缺图 / 引用已删素材 / 超 10 层 /
|
||||
生成 CSS 每处装饰是否真 10 个值(与 Go `TestDecoSlotCount` 同规则,给工具自身兜底)。
|
||||
|
||||
**实测**
|
||||
- 390 视口首层装饰 `url(char.webp)` → `none`,1512 下不变;两种视口都 12 层对齐。
|
||||
- 草稿往返:带 `?demo=1` 保存 → 不带 demo 打开,`validate` 仍为空(说明恢复成功)。
|
||||
- 示例 CSS 过 `TestThemeCSSComplete` + `TestDecoSlotCount`(27 处声明)。
|
||||
|
||||
**踩到的小坑**
|
||||
- `makeDemo()` 里 `S.assets.push` 不检查重复 → 重复点「载入示例」会撞 id(`assetById` 取第一个),
|
||||
导出时静默用错图。改成开头 `S.assets = []`。
|
||||
- 拖拽标签写 `top:-17px` 会贴到画板边缘被压线/裁切,改成放在框内左上角;
|
||||
整块半透明填充会把人物罩成一层米色(看着像"框住空气"),改为纯虚线 + 角标签。
|
||||
- 面板一多左栏就滚不到底:按使用频率重排(图层 → 背景 → 窄屏 → 图片池 → 配色)。
|
||||
|
||||
## 15:20 生成器:图片处理(透明度 / 压白 / 去色 / 裁边 / 去白底)
|
||||
|
||||
用户问「图片的透明度或者一些简单的处理能否安排上」。已实现并推送 `c3d34b5`。
|
||||
|
||||
**非破坏模型(关键设计)**
|
||||
- `orig` 存上传时的**原始字节**(`FileReader.readAsDataURL`,不重编码),`fx` 只存参数,
|
||||
每次处理都从 orig 重算 → 「压白 16% → 20%」不会叠加两次 webp 压缩。
|
||||
- 打开面板看一眼就关:参数没变就**跳过重编码**,一个像素都不动(`fxSnapshot` 比对)。
|
||||
这条不加,用户点开看看就会把图越磨越糊。
|
||||
- 预览按 420px 宽算(滑杆跟手),完成时全分辨率重算;用序号 `fxSeq` 丢弃过期结果,
|
||||
否则快速拖动时旧渲染覆盖新的。
|
||||
|
||||
**去白底两个坑(都实测过)**
|
||||
- 手内侧 / 两腿之间是**闭合**白区,四边漫水进不去 → 按连通域单独掏,面积 ≥ 24 才掏
|
||||
(更小的多是眼睛高光,掏了变透明洞)。
|
||||
- **容差默认 16,不是 22**:背景 253、白袜子 237 只差 16。容差 30 实测把袜子吃穿(alpha 0)。
|
||||
|
||||
**验证(跑出来的)**
|
||||
- `?dump=fx` 13 项断言全过:封闭孔开/关、容差 16 保袜子 / 30 吃穿、收边后轮廓 1px、
|
||||
alpha 50%→128、压白 20% 时 237→241、去色后 R=G=B、裁边 200×160→80×40。
|
||||
- `?dump=fxpipe` 链路:弹窗开关、预览 300×960、参数重开后仍在、改参后 dataURL 变、
|
||||
**无改动时 dataURL 一字节不变**(这条是防回归的关键)。
|
||||
- `?dump=fxkey` 端到端:把 shiroko 立绘合成到 253 白底再去白底,透明占比 46.3%,
|
||||
与手写 floodfill 抠出来的 46.6% 差 0.3%。
|
||||
- 生成的 CSS 仍过 `TestThemeCSSComplete` + `TestDecoSlotCount`。
|
||||
|
||||
**顺带**
|
||||
- 工程文件升 v3:存 `orig` + `fx`,未处理时不重复存 dataURL(否则 json 平白翻倍);
|
||||
读 v2 旧工程把 dataURL 当 orig 用。
|
||||
- 新增 `?loadurl=` 把项目里的图直接当素材载入(不必先下载再上传),`?fxjson=` 给初始参数。
|
||||
- 截图看出两个 UI 问题:拖拽标签 `top:-17px` 贴到画板边缘被压线、半透明填充把人物罩成米色
|
||||
→ 改框内左上角标签 + 纯虚线(上一轮已修,本轮沿用)。
|
||||
|
||||
## 16:10 生成器:图层换不了图 / 背景关不掉 / 草稿清不掉
|
||||
|
||||
用户反馈三条,都是**交互设计**问题,核心逻辑其实没坏(实测换图是生效的)。已推送 `937a890`。
|
||||
|
||||
- **「每层都只有这张图、换不了」**:`add-layer` 里 `l.asset = S.assets[0].id`,连加三层全是同一张;
|
||||
而且换图的下拉只在「选中那一层的编辑器」里,用户看不出每层能各自换。
|
||||
→ 新层不再自动绑图(素材池只剩一张时才绑);**每行带缩略图 + 「图片」下拉**直接换。
|
||||
- **「背景关不掉」**:`inherit` 时背景下拉 `disabled` —— 用户在箱主页看到背景还在又点不动。
|
||||
→ 一律可点,一改自动 `ensureOwn(true)`:切成独立配置 + **把全局当前值复制过来当起点**
|
||||
(否则改背景会顺带把图案弄丢);复制图层要**重发 id**,不然两个作用域共用一批 id,拖拽/高亮会串。
|
||||
- **「上次的数据一直在」**:`localStorage` 草稿只有隐藏参数 `?fresh=1` 能绕过,UI 无出口。
|
||||
→ 顶栏加「新建」(清空 + 删草稿 + 二次确认)。
|
||||
|
||||
⚠️ **跟随全局时不能给拖拽框**:那时 `cfgOf(S.scope)` 返回的是 global 对象,一拖就改到全局去了。
|
||||
`syncOverlay` 里已加提前 return。同理 `ensureOwn()` 是「任何写操作前」的必调项。
|
||||
|
||||
回归(`?dump=layers`):加层后 `[null,null]`、行内下拉 2 个、换第 2 层后 `[null,blue]` 且 CSS 只出现 blue;
|
||||
box 页 inherit 时 `addDisabled/bgDisabled` 都 false;在 box 加层后本页 3 层而**全局仍 2 层**;
|
||||
新建后 assets 0、层 0、草稿已删、scope 回 global。
|
||||
|
||||
## 17:5x 生成器:视口自适应 + 导出画质
|
||||
|
||||
用户两个问题:插入素材后不同屏幕被裁、导出的 webp 太大。都在 `dev/theme-builder/index.html` 里解决。
|
||||
|
||||
**尺寸单位 4 档**(`l.unit`):`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
输出 `auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)` 这种。
|
||||
- 关键点 1:**内层 `min(视口 - 24px, 基准×55%)`** —— 下限让位给视口,自适应单位因此永不溢出。
|
||||
写死 55% 下限的话,390 竖屏放不下 1067 宽的图,照样裁。
|
||||
- 关键点 2:**必须有 `fitw`**。`fit` 只按高度缩,宽幅素材宽度按比例涨:1920×1080 素材 h=600 在
|
||||
390 视口下 fit 出 1001px 宽(裁),fitw 出 366px(完整)。实测见 `?dump=fit` 的 `fitw` 数组。
|
||||
- 关键点 3:`layerSize()`(出 CSS)与 `unitGeom()`(出预览框/溢出检测)**必须共用一套换算**,
|
||||
两套各算迟早漂成「框没超、图已裁」。
|
||||
- ⚠️ 下拉 value 要用 `l.unit`,不能用 `unitOf()`(把 fit 归一成 vh → 选了 fit 却显示「按视口高比例」)。
|
||||
|
||||
**视口预设带高度**(`VIEWPORTS` 改成 `{w,h,label}`,加了 390×844)。原来只有宽度、iframe 恒 900 高,
|
||||
768 的旧笔电和 844 手机根本模拟不出来 —— 而裁切几乎全由高度决定。
|
||||
|
||||
**溢出检测 + 一键修好**:`layerMetrics(l,a,VW,VH)` 给 overL/R/T/B,图层编辑区显示
|
||||
「1512×900 下显示 131×420,完整可见」或橙色「⚠ 左右各 88px 越界」+「按当前视口修好」按钮
|
||||
(切 px 并算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍,只提醒不拦。
|
||||
|
||||
**窄屏每层策略** `l.mob`(inherit/scale/hide)+ 全局 `S.narrow.scale`(默认 0.62)。
|
||||
⚠️ 顺手修了一个既有 bug:`@media` 块原来只写 `html[data-theme-style]`(0,1,1),
|
||||
被页面覆盖块 (0,2,1) 压住 → **独立配置的页面在手机上从来没生效过**。现在每个独立 scope 各写一条。
|
||||
⚠️ 竖版图只替换 `cfg.bg` 非空的页面(否则给纯色页塞图 = 改了他的设计)。
|
||||
|
||||
**背景焦点** `cfg.bgPos` → `--tb-page-position`;base.css 里 `var(--tb-page-position, center center)` 兜底
|
||||
(旧主题不写也不坏),StaticVersion → `20261020f`。
|
||||
|
||||
**导出画质**:`S.export = {quality: 0.88, maxSide: 1600}`,改完全量 `rebakeAll()`(从 orig 重算,不叠加压缩)。
|
||||
- ⚠️ **`toDataURL('image/webp', 1)` 是坑**:Skia 在 q=1 切无损,输出反常 —— 实测同一张噪声图
|
||||
q=1 只有 52KB、q=0.95 是 440KB。`canvasAsset` 与滑块都夹到 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点;实测平涂图各档都是 1KB,噪声图 0.6→0.95 涨 75%)。
|
||||
所以文案写「先砍长边再降质量」,不承诺「降 q 一定更小」。
|
||||
- 缩小加 `imageSmoothingQuality='high'`。
|
||||
|
||||
**自检**:新增 `?dump=fit`(视口占用/溢出/fitw/旧工程兼容/@media 覆盖各 scope)与
|
||||
`?dump=quality`(q 单调性、q=1 被夹、长边真压像素),两项 `ok:true`;`?dump=validate` 空、layers/fx/fxpipe 全绿。
|
||||
生成 CSS 临时存成 `static/css/themes/gentest.css` 跑 `go test ./internal/theme` → 30 处声明全过(`clamp` 里的逗号没被当层分隔符)。
|
||||
⚠️ 顺带发现 **`//go:embed` 的 `*` 不匹配 `_` 开头文件** —— `_scaffold.css` 与 `_gentest.css` 都不进 embed,
|
||||
所以那个校验一开始"跑了但数字没变",改名后才真正生效。
|
||||
|
||||
UI 踩坑:面板仅 ~240px,`input[type=range]` 有固有宽度 → 必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且质量数值要放进 label 里(作第三个 flex 项会被顶出可视区,表现为"滑块没有数字")。
|
||||
|
||||
## 18:2x 排查用户部署的 test01:窄屏素材消失 + 主题名显示成 key
|
||||
|
||||
用户自己导出 test01 部署到 box-dev-api(admin6),报两个问题。**两个都复现了,都不是"环境坏了"。**
|
||||
|
||||
**测试环境体检(都正常)**:`/_/admin6` 200、`test01.css` 200 且与本地 md5 一致、
|
||||
`base.css?v=20261020f`(新版,含 `--tb-page-position`)、`<html data-theme-style="test01" data-page-scope="box">`。
|
||||
部署本身是干净的。
|
||||
|
||||
**问题 1:窄屏素材消失** = 工程里「窄屏隐藏全部装饰层」勾着 → 导出 CSS 的 `@media` 把 10 槽全写 `none`。
|
||||
桌面完全不受影响,所以只看桌面发现不了。定位过程绕了两步弯路,记下来:
|
||||
- 先怀疑 CSS 表达式非法(`clamp(min(calc(...)),…)`)→ 最小复现页证明**表达式没问题**,图能显示。
|
||||
- 再怀疑层级:其实素材位置 `calc(50% - 0.67vh)` 正好在**卡片正下方**,卡片 `rgba(255,255,255,0.86)`
|
||||
只剩 14% 可见,本来就非常淡。
|
||||
- ⚠️ **本地 preview 快照没有 `data-page-scope`**(TestGenPreview 不走 Contexter),所以
|
||||
`[data-page-scope="question"]` 覆盖块在快照里根本不生效,读到的是 global 的 `--tb-deco: none`。
|
||||
本地验证主题必须手动给 `<html>` 补 `data-page-scope`,否则会误判成"主题没生效"。
|
||||
- 修:删掉 test01.css 的 @media 段(装饰是 fit 单位,浏览器自己会缩)。390 截图确认素材回来了。
|
||||
- 生成器侧:勾选时橙色警告(几层会消失)+ 图层行标「窄屏隐藏」+ 加层/导出提醒 +
|
||||
「窄屏」下拉被覆盖时标注;新增 `?narrow=hide` 复现。
|
||||
|
||||
**问题 2:设置页显示 `theme.name.test01`** = 模板直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`,
|
||||
`i18n.T` 缺词条返回 key。修:`theme.DisplayName(lang, slug)` 缺词条回退 slug,模板函数 `themeName`。
|
||||
加了 `TestDisplayName`(自建主题必须回退 slug,不能返回 key)。这样自建主题依旧零改代码。
|
||||
|
||||
StaticVersion → `20261020g`(主题 css 内容变了,不换缓存键浏览器 1 天内还是旧文件)。
|
||||
@@ -1,617 +0,0 @@
|
||||
# 2026-10-21
|
||||
|
||||
## 扫描者处置方案设计(未实施,仅设计文档)
|
||||
|
||||
- 触发:线上频繁收到「疑似批量扫描已自动封禁」告警,命中路径以 wp 探测 + `/.ssh/**` 为主,来源多 Azure 云 IP 池。
|
||||
- **实测纠正一处错误认知**:`internal/route/route.go:432` 的注释称「flamego 的 NotFound 会先写 404 状态再 Redirect」——
|
||||
**错的**。flamego v1.9.4 的 `NotFound` 只执行 handler,不写状态码;`ctx.Redirect("/")` 实测输出 **302 → "/"`。
|
||||
最小复现验证:`/wp-admin/includes/...` 与 `/.ssh/id_rsa` 均得 302 + Location:/。
|
||||
后果:扫描器跟随重定向 → 每个探测路径都触发一次首页渲染(1C1G 纯浪费),且被判为「站点有价值」。
|
||||
同时告警邮件文案「产生大量 404」与实际 302 不符。
|
||||
- 方案分五层(L0 CDN 路径/UA 规则 → L1 真 404 分流 → L2 蜜罐假文件/假凭据 → L3 tarpit + 递进封禁 → L4 上游 abuse 举报):
|
||||
- 核心判断:**对云厂商僵尸网络做 IP 封禁无效**,有效的是按**路径**在 CDN 拦 + 蜜罐取证 + 上游举报。
|
||||
- 明确排除反向攻击/DoS/回打(法律风险 + Azure 共享出口伤及无辜 + 招致 CDN 封站)。
|
||||
- 蜜罐:命中 `/.ssh/id_rsa`、`/.env`、`/wp-login.php` 等诱饵路径 → 短路返回假内容(含 canary 标记)+ 永久封禁 + 留证。
|
||||
- 封禁需落库(当前内存 map,重启清零)+ 时长递进 15m→1h→6h→24h→7d→永久 + 多维(IP / IPv6 /64 / UA 家族)。
|
||||
- tarpit 必须带全局并发上限 + 单连接超时,否则 1C1G 自身变 DoS 入口。
|
||||
- 设计文档:`outputs/scanner-defense-design-2026-10-21.md`(含分级变更清单、文件级改动点、验收指标)。
|
||||
- 未动任何生产代码,等用户决定实施范围。
|
||||
|
||||
## 蜜罐(虚假后台登录界面 + 破解凭据记录)已实施
|
||||
|
||||
用户明确需求原文:「能不能把这些页面指向一个简单的虚假后台登录界面,然后记录这些破解的用户名密码等信息」。
|
||||
|
||||
### 落点决策(关键)
|
||||
- **蜜罐不改写成新中间件,而是改写 `internal/route/route.go` 的 `f.NotFound`**:
|
||||
- NotFound 只在「路由表完全落空」时触发 → 与 `/login` `/register` `/_/{domain}` `/user/**` `/api/v1/**` 天然零交集;
|
||||
- 位于全局 `f.Use` 链之后 → `requestBodyLimitMiddleware` 已给 body 加 12 MiB 上限;
|
||||
- 组级中间件(含 CSRF)只挂具体路由,不作用于 NotFound → POST 表单可安全解析;
|
||||
- flamego `context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即安全短路。
|
||||
- **双重计数坑**:autoban 中间件对敏感路径已 `Record` 一次,蜜罐再记一次会让阈值实际减半。
|
||||
解决:`autobanMiddleware(b, hp)` 传入蜜罐匹配器,蜜罐命中的路径由蜜罐自己计数。
|
||||
|
||||
### 新增文件
|
||||
- `internal/security/honeypot/honeypot.go` — 匹配器(段前缀 + 片段子串 + 双重保险 `forbiddenPrefixs` 排除业务路由)、
|
||||
凭据提取(表单 / JSON / BasicAuth / 查询串)、`CanaryToken = "tb-hp-7f3a1c9e"`。
|
||||
- `internal/security/honeypot/page.go` — 仿 WordPress 假登录页(经典字段 `log`/`pwd`/`redirect_to`),内联 CSS 不依赖站点静态资源。
|
||||
- `internal/db/honeypot.go` — `HoneypotHit`(**不嵌 dbutil.Model**,审计证据只追加)+ `HoneypotStore`。
|
||||
- `internal/route/honeypot.go` — 处理器:GET 假登录页 / POST 假失败响应 / 落库 / 复用 autoban 封禁 / 每 IP 告警一次。
|
||||
- `internal/cmd/honeypot.go` — `TamaBox honeypot hits|stats|prune`,密码默认脱敏(`--show-password` 显示明文)。
|
||||
- `templates/mail/honeypot-alert.html` — 告警邮件模板(`mail/` 已在 embed 清单,无需改 embed.go)。
|
||||
|
||||
### 改动文件
|
||||
- `internal/route/route.go`(CRLF):NotFound 分流 + 蜜罐实例装配 + `hph.sendAlert`。
|
||||
- `internal/route/autoban.go`(LF):`autobanMiddleware(b, hp)`,蜜罐路径跳过自身 Record。
|
||||
- `internal/db/db.go`(LF):`AllTables` 加 `&HoneypotHit{}`,`Init()` 加 `Honeypots = NewHoneypotStore(db)`。
|
||||
- `internal/conf/static.go`(CRLF):`enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`。
|
||||
- `conf/app.sample.ini`(CRLF):对应说明段。
|
||||
- `internal/mail/mail.go`(LF):`SendHoneypotAlertMail` + `HoneypotAlert`。
|
||||
- `cmd/tamabox.go` + 根 `tamabox.go`:注册 `cmd.Honeypot`(两处必须同步)。
|
||||
|
||||
### 踩坑
|
||||
- `normalize()` 里 `for len(p)>1 { TrimSuffix(p,"/") }` **无限循环**(路径不含尾斜杠时字符串不变)→ 改成 `HasSuffix` 判断。
|
||||
- `url.ParseQuery` 遇非法百分号编码返回 error 但仍给出部分字段 → 蜜罐要「尽可能多拿凭据」,忽略 error 继续用部分结果。
|
||||
- JSON 凭据提取:手写解析器又脏又易错 → 改用 `jsonPairRe` 正则扫描扁平字段。
|
||||
- `templates/mail/` 已在 embed 清单内 → 新增邮件模板**不需要**改 `templates/embed.go`(顶层文件才需要)。
|
||||
|
||||
### 验证
|
||||
- `go build ./...` 通过;`go test ./...` 全绿(含 DB 测试,postgres 门控)。
|
||||
- 新增测试:`internal/security/honeypot/honeypot_test.go`(匹配/业务路由零交集/编码绕过/凭据提取)、
|
||||
`internal/route/honeypot_test.go`(假页含 canary、业务路由不被劫持、关闭时回落 302、POST 返回失败页)、
|
||||
`internal/db/honeypot_test.go`(写入/倒序/三张聚合表/保留期清理)。
|
||||
|
||||
## 测试站黑盒验证 + P0 缺陷修复(蜜罐封禁被 enable_auto_ban 连坐)
|
||||
|
||||
测试站:`https://box-dev-api.337845818.best`(Cloudflare Tunnel + CF 边缘,`cf-cache-status: DYNAMIC`、无 `Age` → 不缓存,请求确实回源)。
|
||||
生产站 `https://box.tama.guru` 强制 301 → www,本机不可达,无法做对照。
|
||||
|
||||
### 黑盒验证结果(全部通过)
|
||||
- 诱饵路径全部 200 + `text/html`:`/wp-login.php` 2529B、`/wp-admin/` 2517B、`/.ssh/id_rsa` 2525B、`/.env` 2497B、`/administrator` 2533B。
|
||||
- 响应体含 canary `tb-hp-7f3a1c9e`(2 处)、`<title>Log In ‹ WordPress</title>`、`name="log"`/`name="pwd"`/`name="redirect_to"`。
|
||||
- 响应头:`Cache-Control: no-store, no-cache, must-revalidate`、`x-robots-tag: noindex, nofollow`、`x-frame-options: DENY`。
|
||||
- POST 凭据提交 → 200 + `id="login_error">ERROR: Invalid username or password.`。
|
||||
- 路径变体全命中:`/WP-ADMIN`(大小写)、`/wp-admin%2f..%2fetc`(编码绕过)、`//wp-admin//x.php`(双斜杠)、`/.git/config`、`/.aws/credentials`、`/phpmyadmin/`、`/actuator/env`、`/xmlrpc.php`、`/vendor/phpunit/x.php`、`/console`、`/cgi-bin/test.cgi`、`/wp-content/plugins/x.php`、`/.env.bak`、`/wp-json/wp/v2/users`。
|
||||
- 凭据来源全覆盖:表单 POST、JSON body、BasicAuth、查询串、POST 到 `/.ssh/id_rsa`。
|
||||
- **业务路由零交集(红线)**:`/` 34192B、`/login` 23420B、`/register` 25194B、`/_/admin6` 39358B、`/user` 302、`/api/v1/questions` 302、`/robots.txt` 145B;非诱饵随机路径 `/nonexistent-random-xyz` 仍 302→`/`。
|
||||
|
||||
### P0 缺陷:蜜罐命中 70+ 次但站点零封禁
|
||||
- 根因:`autoban.Record()` 第一行 `if !b.enabled { return }`,`b.enabled` 来自 `enable_auto_ban`。
|
||||
运维为避免共享出口误伤而关掉 `enable_auto_ban` → **蜜罐被另一个开关连坐**,只落库不封禁。
|
||||
- 对照组证明非蜜罐专属:`/index.php`(autoban 敏感、蜜罐不匹配)连打 40 次 + 30 并发同样零封禁。
|
||||
- 排除项:CF 缓存(DYNAMIC/无 Age)、flamego NotFound 链(源码确认全局 Use 链生效)、蜜罐匹配器、阈值配置。
|
||||
|
||||
**修复(已实施,测试全绿)**:
|
||||
- `autoban.Options` 新增 `HoneypotEnabled` / `HoneypotThreshold`(默认 1)/ `HoneypotDuration`(默认复用常规)。
|
||||
- 新增 `Banner.RecordHoneypot(ip, path) bool`:独立计数、独立阈值,**不受 `enabled` 约束**;键与 `Record` 一致(同样 `shared.LimitKey` 归一化),否则 `IsBanned` 判不出来。
|
||||
- `IsBanned()` 移除 `!b.enabled` 早退 —— `enabled` 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
|
||||
- `internal/route/honeypot.go`:`banned = h.ab.RecordHoneypot(ip, path)`。
|
||||
- `internal/route/route.go`(CRLF)装配三个蜜罐字段;`conf/static.go` + `conf/app.sample.ini`(CRLF)新增 `honeypot_threshold` / `honeypot_ban_minutes`。
|
||||
- 告警:仍走既有 `sendAlert`(「已封禁」)+ 蜜罐自己的告警邮件(「捕获了什么凭据」),互补。
|
||||
|
||||
### 后缀匹配补缺
|
||||
- `/backup.zip`、`/db.sql`、`/id_rsa.pem` 这类泄露扫描文件名主体随机,**只有后缀稳定**。
|
||||
- 原 `honeypotFragments` 用 `"/.zip"` 子串匹配 → `.zip` 前是 `k` 不是 `/`,全漏网。
|
||||
- 新增 `honeypotSuffixes`(`.zip .tar .tar.gz .tgz .gz .rar .7z .bz2 .sql .sql.gz .dump .bak .old .orig .save .swp .env .env.bak .env.local .pem .key .p12 .pfx .jks .kdbx .ovpn`),按 `path.Base()` 结尾匹配。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ **测试里 `Duration: 60` = 60 纳秒**,封禁瞬间过期 → 表现为「时序巧合的 200/200/403」,测试假失败。一律写 `60 * time.Minute`。
|
||||
- ⚠️ 路由测试只注册路由 + NotFound 会被绕过:执行 `IsBanned` → 403 的是 **`autobanMiddleware`**,测试服务器必须 `f.Use(autobanMiddleware(ab, hp))`。
|
||||
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;`grep | cat -A` 不显示 `^M`,极易误判为 LF。Python 脚本按 `\n` 锚点替换会静默失败 → 用 `\r\n` 锚点 + 写后统计 `b'\r\n'` 复核。
|
||||
- 假页纯静态 HTML(内联 CSS,不落盘)、告警走 SMTP → **取证数据只在 `honeypot_hits` 一张表**,服务器无文件副本;autoban 封禁表是内存态(重启清零)。
|
||||
- 测试阶段产生的噪声凭据(`testadmin/S3cr3tP@ss` 等)可用 `TamaBox honeypot prune` 清理。
|
||||
|
||||
### 遗留(需用户侧)
|
||||
- 本轮修复需**重新部署**才在测试站生效;部署后 `curl` 一次 `/wp-login.php` 应 200、再一次 403。
|
||||
- 需确认测试环境 `conf/app.ini` 的 `[security] enable_auto_ban`(零封禁直接原因)+ `enable_honeypot = true`。
|
||||
- 无法 SSH(`100.79.185.45:19999` 全部账号 Permission denied),落库验证需用户执行 `TamaBox honeypot hits --since 24h --show-password`。
|
||||
|
||||
---
|
||||
|
||||
## 蜜罐表体积实测 + 自动清理(commit 63e851d,已 push)
|
||||
|
||||
用户问「这些记录会不会导致数据库很臃肿」。**不靠推断,本地 PostgreSQL 建同构表灌仿真数据实测。**
|
||||
|
||||
### 实测数字
|
||||
- 典型行 **244 字节**(heap 201 + 索引 43),10.5 万行 = 24 MB;常规 UA 下实测 281 B/行。
|
||||
- 字段均值:path 13B、user_agent 111B(单行最大头)、fields 3B、raw_body 3B。
|
||||
- `raw_body` 被 `maxCredentialFieldLen = 512` 截断,故字段数量随之受限(200 字段场景 rawBody 仍 512B)。
|
||||
- 最坏情况(手工构造 50 字段 × 500B)约 2375 B/行。
|
||||
- **UA 截短收益**:截到 100 省 1129 kB / 截到 64 省 4824 kB(10 万行)→ 仅 5–20%,
|
||||
远小于自动清理的确定性上界且损失取证信息 → **决定不做**。
|
||||
|
||||
### PostgreSQL 死元组语义(本轮最关键认知)
|
||||
- `DELETE` 只标记死元组,**表文件不会变小**;空间需 VACUUM 后才可被后续 INSERT 复用。
|
||||
- 对照(10 万行,每轮删 25% 插 25%,行数恒定):有 VACUUM 22→24 MB **收敛**;无 VACUUM 22→52 MB **膨胀**。
|
||||
- ⚠️ **VACUUM 不缩小文件,只让空间可复用**。二次实测(5 万行、连续 4 轮删 12000 插 12000):
|
||||
行数恒定 5 万、文件稳定 15 MB 不再增长 —— 这才是要的效果(止住增长,不是让文件变小)。
|
||||
想让文件立刻变小只有 `VACUUM FULL`(重建整表 + 锁表),蜜罐表万级行没必要。
|
||||
- ⚠️ `VACUUM` **不能在事务块内执行**(SQLSTATE 25001),也不能在 PL/pgSQL 函数体内执行(VACUUM 无法从函数执行)。
|
||||
GORM `db.Exec()` 走 `callbacks.Raw()` 处理器、**不包事务** → `WithContext(ctx).Exec("VACUUM ...")` 可用(已实测)。
|
||||
- PG18 已移除 `pg_stat_user_tables.n_tup_vacuum`,改用 `last_vacuum` 验证。
|
||||
|
||||
### 索引实测(EXPLAIN 驱动决策)
|
||||
- `idx_honeypot_hits_provider`:10 万行 648 kB,**全代码库零查询使用**(TopSources 用 max(provider) 随 group by 顺带取)→ 已删。
|
||||
- `idx_honeypot_hits_created_at`:EXPLAIN 显示 `Index Scan Backward`,被 `ListSince` 实际使用 → 保留。
|
||||
- `idx_honeypot_hits_ip`:4.7 MB(最大),但 GROUP BY ip 走 Seq Scan + HashAggregate;**保留**(`ListSinceByIP` 需它)。
|
||||
- ⚠️ **GORM 去掉 struct tag 的 `index` 不会删除既有库上的旧索引** → 需显式 `HasIndex` + `DropIndex`(已加 `dropUnusedHoneypotIndexes`,启动时跑,失败仅告警)。
|
||||
|
||||
### 落地改动
|
||||
- `internal/db/honeypot_retention.go`(新):`HoneypotRetention` 后台清理器,每小时一轮,两道闸
|
||||
(时间 `honeypot_retain_days` + 数量 `honeypot_max_rows`)→ 清理后调 `Compact()`。
|
||||
保留期 <=0 视为不自动清理(只告警);**`runOnce` 开头必须再挡一次 `retainDays<=0`**,
|
||||
否则 before=now 会把历史全删光(「保留 0 天」被误解释成「只留此刻」)。
|
||||
- `internal/db/honeypot.go`(新方法):`Compact()`(PG `VACUUM (ANALYZE)`)、`DeleteOldestBeyond()`
|
||||
(跨方言通用,子查询按 id 倒序取 keepRows 边界)、`ListSinceByIP()`、`Size()`。
|
||||
- `internal/cmd/web.go`:接入清理器,`stopRetention` 用 defer close。
|
||||
- `internal/cmd/honeypot.go`:新增 `honeypot size` 子命令(行数/磁盘占用/时间跨度 + 与两道闸对照)。
|
||||
- 修 CLI `hits --ip` 真 bug:原先取回最近 N 条再在 Go 侧筛 → 指定 IP 的历史命中基本查不出来 → 改 SQL 层过滤。
|
||||
- 新配置:`honeypot_auto_clean`(默认 true)、`honeypot_max_rows`(默认 200000 ≈ 53 MB)。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ 测试预期别自己推算错:`DeleteBefore` 是**严格小于**(`created_at < before`),边界那条不删。
|
||||
- ⚠️ Python 补丁脚本里写 Go struct tag 的反引号 → bash 命令替换报错,**必须写成 .py 文件执行**(不能 `python -c`)。
|
||||
- ⚠️ `psql` 传中文 SQL 报编码错 → 用英文输出串或 `PGCLIENTENCODING=UTF8`。
|
||||
- ⚠️ 库名带连字符要用双引号:`CREATE DATABASE "tamabox-cli-test"`。
|
||||
- ⚠️ Edit 工具改 CRLF 文件会保持 CRLF,但**改写时我打的字会转码损坏**(出现过「实测指事/程定/不图」)→ 改完必须逐行读回复核。
|
||||
- 仓库内统一存 LF,工作区 CRLF 由 `core.autocrlf=true` 自动转换 —— 提交时 warning 属正常,不是行尾被改。
|
||||
|
||||
---
|
||||
|
||||
## 配置键名静默失效的根治(commit d300167,已 push)
|
||||
|
||||
### 现场故障
|
||||
用户配置 `honeypot_retention_days <= 30`,日志报「蜜罐自动清理未启用:honeypot_retention_days <= 0」。
|
||||
|
||||
### 根因(探针实测 ini v1.67.0,不是读源码猜的)
|
||||
1. **键名多一个 t**(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0。
|
||||
2. **`<= 30` 这种写法**让 ini 把键名解析成 `"honeypot_retention_days <"`(带尾随比较符),
|
||||
实测输出:`key="honeypot_retention_days <" value="30"` —— 更匹配不上。
|
||||
3. **我之前那条警告文案里写的也是错键名** `honeypot_retention_days`,
|
||||
等于在误导运维往错的键上配 —— 教训:警告文案里的键名必须与代码读的键名逐字一致。
|
||||
|
||||
### 实测到的 ini 行为(重要,写 ini 相关代码前必读)
|
||||
- `MapTo` 在**键缺失或值非法时不覆盖**目标字段 → 可以安全预置缺省值。
|
||||
- 键名**大小写敏感**(`Honeypot_Auto_Clean` 不生效)。
|
||||
- `HasKey` 能区分「缺失」与「显式 0」。
|
||||
- 段不存在时 `MapTo` 返回 nil 且不修改目标。
|
||||
- bool 的解析是「有键才赋值」→ **未配置与显式 false 无法用零值区分**,必须靠 HasKey。
|
||||
|
||||
### 修复三层
|
||||
- `internal/conf/keynorm.go`(新):键名归一化(修剪空白、剥尾随 `<` `>` `=`、别名表纠错)。
|
||||
⚠️ 归一化结果必须命中该段**合法键名白名单**才采用 —— 不认识的键不猜,交给未知键告警。
|
||||
- `internal/conf/unknown.go`(新):启动逐段比对键名,未知键 WARN + 给出最接近的合法键名。
|
||||
合法键名由**反射从结构体 ini tag 得出**(单一事实来源,新增配置项自动纳入)。
|
||||
- `internal/conf/honeypot_defaults.go`(新):蜜罐缺省值语义修正
|
||||
(保留期缺省 30、auto_clean 缺省 true 用 HasKey 判、max_rows 缺省 200000、threshold 缺省 1)。
|
||||
- 归一化必须在 `MapTo` **之前**做 —— 等 MapTo 跑完,错拼的键早被丢掉了。
|
||||
|
||||
### 顺带清理
|
||||
`app.sample.ini` 里两条死配置:`app.title`(代码从未读,站点标题走 i18n `site.title`)、
|
||||
`server.xsrf_expire`(无对应字段)。留着会让新检测对官方模板误报。
|
||||
|
||||
### 踩坑
|
||||
- ⚠️ **Python 脚本里写 Go struct tag 的反引号** → bash 命令替换报错。
|
||||
改用 Edit 工具逐处改(Edit 对 CRLF 文件保持 CRLF,但**我打的字可能转码损坏** → 改完逐行读回复核)。
|
||||
- ⚠️ `gofmt -l` 对 CRLF 文件一律报警(gofmt 要求 LF)—— 那是既有状态,不是我的改动。
|
||||
- ⚠️ 我自己的测试预期写错过两次(别名生效后的期望值、static_version 不是 ini 键)。
|
||||
**测试失败要先怀疑自己的预期**,别急着改产品代码。
|
||||
|
||||
## 蜜罐实现审计(第三方 agent 写完,我复审)
|
||||
|
||||
### P0:蜜罐封禁整条链路未接线(已实测复现)
|
||||
`internal/route/route.go:85` 的 `autoban.New(...)` **没传** `HoneypotEnabled / HoneypotThreshold /
|
||||
HoneypotDuration`,而 `autoban.RecordHoneypot` 首行就是 `if !b.honeypotEnabled { return false }`
|
||||
→ 蜜罐命中永不封禁、落库 `banned` 恒 false、`IsBanned` 永不为真。
|
||||
同一提交又把 `autobanMiddleware` 里蜜罐路径的**常规计数也跳过**了
|
||||
(`route/autoban.go:38 if hp == nil || !hp.Match(path)`,理由「蜜罐处理器自己计数」)
|
||||
→ 净效果:蜜罐路径从「10 次封禁」退化为**零计数零封禁**,是 regression。
|
||||
`honeypot_threshold` / `honeypot_ban_minutes` 两个配置项全代码库**零读取**(死配置,样例 ini 里却写了)。
|
||||
测试为什么绿:`route/honeypot_test.go` 自建 banner 时显式传了 `HoneypotEnabled`,
|
||||
注释还写着「与 route.go 一致」—— 实际不一致,测试绕过了生产装配。
|
||||
|
||||
### P1
|
||||
- 蜜罐 GET 探测(无凭据)也逐条 INSERT,把「零成本 404」变成「每请求一次 DB 写」,
|
||||
且 NotFound 链路上没有 rate limit → 扫描器可直接放大成 DB 写压力;20 万行闸每小时才跑一轮。
|
||||
- `honeypotHandler.alerted`(map[string]bool)无 TTL、无清理,随源 IP 数量无界增长;
|
||||
autoban 的同名 map 也是(既有问题,蜜罐放大)。
|
||||
|
||||
### P2
|
||||
- `Path` 原样落库但列是 `size:512` → 超长路径直接 PG 报错丢证据;`Query` 用 `query[:1000]`
|
||||
字节截断,`honeypot.go:243/296` 的 `v[:limit]`、`raw[:limit]` 同样是字节截断
|
||||
→ 多字节字符被切半产生非法 UTF-8,PG 拒插。同文件里已有按 rune 的 `truncate()` 却没用于这些点。
|
||||
- `credentialKeys` 含 `id` / `name` / `token` → 任意 JSON 探测带 `{"id":1}` 就会被当成「破解凭据」
|
||||
触发告警邮件 + 记为 HadFields,污染取证与告警。
|
||||
- `page.go:54` `<meta name="generator" content="TamaBox honeypot tb-hp-7f3a1c9e">` 把「这是蜜罐」
|
||||
和「这是 TamaBox」同时明示给扫描器(nuclei 类模板可据此跳过)。
|
||||
- `serveLoginFailure` 手抄了一遍 `WriteLoginPage` 的响应头逻辑(没复用),已开始漂移
|
||||
(Title 一处写 `‹`、一处写 `\u2039`)。
|
||||
- `Honeypot.Describer()` 零调用死代码,且 `path.Join(keys...)` 语义错误(会把前缀拼成一条路径)。
|
||||
|
||||
### 结论
|
||||
总体设计(挂 NotFound、零业务交集、只回假内容、脱敏 CLI、独立取证表)方向正确;
|
||||
问题集中在「装配层没接上」与「写入侧的边界处理」。P0 必须先修,否则蜜罐只是个记日志的旁观者。
|
||||
|
||||
## 蜜罐审计问题的修复实施(已改完,未提交)
|
||||
|
||||
### 1. P0 装配(route.go)
|
||||
抽 `newBanner()` 作为唯一装配入口,补 `HoneypotEnabled / HoneypotThreshold / HoneypotDuration`。
|
||||
新增 `internal/route/banner_config_test.go` 四个用例,全部经由 `newBanner()` 断言。
|
||||
**变异验证**:分别把 HoneypotEnabled 改成 false、Threshold 改成 0,对应用例确实失败。
|
||||
|
||||
### 2. 诱捕语义修正(审计时漏掉、比 P0 更影响功能价值)
|
||||
原实现 `RecordHoneypot` 对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
|
||||
永远走不到「POST 提交凭据」那一步,蜜罐核心价值(拿字典)直接归零。
|
||||
改为:`creds.HadFields` → RecordHoneypot(立即封);纯探测 → 常规 `Record`(阈值 10)。
|
||||
新增 `TestHoneypotProbeDoesNotBanImmediately`,同上做了变异验证。
|
||||
连带把 `honeypot_threshold` 的语义在 static.go / defaults / autoban / sample.ini 统一为
|
||||
「提交凭据次数」。
|
||||
|
||||
### 3. 落库边界(实测取证)
|
||||
- `Path` 原样落库、列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
|
||||
实测 600 字符失败、500 字符成功。
|
||||
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL 可稳定触发)。
|
||||
全部改为 rune 截断(`clipRunes` / route 的 `truncate`),并按列宽逐个对齐。
|
||||
|
||||
### 4. 资源与噪音
|
||||
- `honeypotHandler.alerted`(无界 map)→ 换成带 TTL 的 `deduper`(容量上限 2 万,
|
||||
过期清理 + 超限硬裁),同时承担「告警 24h 冷却」与「纯探测落库采样(每 IP 每分钟一条)」。
|
||||
- `credentialKeys` 去掉 `id` / `name`(普通 JSON 探测不再被当成凭据提交触发假告警)。
|
||||
- canary 从 `<meta name="generator">` 移到 HTML 注释(`<!-- build: ... -->`),
|
||||
generator 伪装成 `WordPress 6.4.3`;不再向扫描器自曝「TamaBox 蜜罐」。
|
||||
- `serveLoginFailure` 不再手抄响应头,统一走 `WriteLoginPage`;`LoginPageTitle` 抽常量。
|
||||
- `Describer()` 死代码 → 改为 `Prefixes()` 并接到新 CLI `TamaBox honeypot paths`。
|
||||
- `honeypot_paths` 解析挪到 `honeypot.SplitPaths`(CLI 与 route 共用),补前导斜杠、拒绝 `/`。
|
||||
|
||||
### 5. 新发现的配置陷阱(影响面比蜜罐大)
|
||||
`conf.Init` 用 `ini.LoadOptions{IgnoreInlineComment: true}`,该选项语义是
|
||||
**不剥离行尾注释**(实测:`port = 1440 ; 注释` → 值 `"1440 ; 注释"`,int 解析失败,
|
||||
`MapTo` 却返回 nil error)。后果:
|
||||
- `app.sample.ini` 照抄**无法启动**(`text_censor_source` 值带注释被 validate 拒绝);
|
||||
- 所有带行尾注释的 int/bool 静默回退零值 —— `enable_auto_ban`、`honeypot_max_rows`、
|
||||
`honeypot_auto_clean` 全部中招(后两者靠 sanitize 兜底才没出事,max_rows 则真的失效)。
|
||||
已把 sample 的**蜜罐段**改成「注释独占一行 + 裸值」,并在段首写明原因。
|
||||
其余段落仍是老写法,未动(需用户决定是否全面清理)。
|
||||
|
||||
### 待办
|
||||
- 用户确认是否提交这批改动。
|
||||
- `app.sample.ini` 其余段落的行尾注释问题未修,建议后续统一。
|
||||
- 蜜罐 GET 探测目前受 `enable_auto_ban` 约束(关了就不计数),这是有意的:
|
||||
纯探测无害,不该在运维关掉自动封禁后仍凭空封人。
|
||||
|
||||
## 后续:sample 注释清理 + 凭据外置(同日晚)
|
||||
|
||||
### 1. app.sample.ini 行尾注释全量清理
|
||||
17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF。
|
||||
新增 `TestSampleIniValuesHaveNoInlineComment`:遍历所有段的键,断言 `Value()` 不含 `;`。
|
||||
变异验证:把 `ban_window_seconds = 60` 改成带行尾注释 → 测试按预期失败。
|
||||
清理后 `conf.Init` 读 sample 的报错从「值含注释」变成「server.xsrf_key 未填」,
|
||||
说明解析路径已通(必填项留空是模板的预期行为)。
|
||||
|
||||
### 2. 凭据改为外部文件,数据库不再存用户名/密码
|
||||
- `honeypot.CredRecord` + `CredStore`(`internal/security/honeypot/credstore.go`):
|
||||
按天分文件 `creds-YYYY-MM-DD.jsonl`,一行一条 JSON;目录 0700、文件 0600;
|
||||
坏行跳过;`PurgeBefore` 只删「整天都早于保留期」的文件。
|
||||
- `db.HoneypotHit` 删掉 `Username/Password/Fields/RawBody`,只留 `HadFields` 元数据。
|
||||
- `HoneypotStore` 删掉 `TopCredentials` / `ListSinceByIP`(CLI 已改读文件,零调用)。
|
||||
- 新增 `LegacyCredentialColumns` / `PurgeLegacyCredentialColumns`:
|
||||
AutoMigrate 不删既有列,老库会残留历史明文 → 启动时 WARN,`honeypot purge-creds` 显式 DROP。
|
||||
- 清理器 `HoneypotRetention` 加 `CredPurgeable` 参数,与 DB 记录共用保留期;
|
||||
`honeypot prune` 也一并删过期凭据文件。
|
||||
- CLI:`hits` 不连 DB(DB 挂了也能查),`stats` 的凭据排行读文件,新增 `purge-creds`。
|
||||
- 配置 `[security] honeypot_cred_dir`,缺省 `data/honeypot-creds`;`.gitignore` 加 `/data/`。
|
||||
|
||||
### 测试
|
||||
`credstore_test.go`(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
|
||||
`TestHoneypotLegacyCredentialColumns`(ADD COLUMN 模拟老库 → 检测 → DROP)、
|
||||
`TestHoneypotWritesCredentialsToFile`(POST 后文件有记录)。
|
||||
CLI 端到端手动验证:`honeypot hits` / `--ip` / `--show-password` 输出正确。
|
||||
全量 `go test ./...`(含 PostgreSQL)通过。
|
||||
|
||||
### 遗留
|
||||
- **历史备份/主从复制里可能仍有旧明文凭据**,代码无法处理,需运维自行清理。
|
||||
- 邮件告警仍会带明文用户名/密码(用户未要求改)。
|
||||
|
||||
### 已提交(2026-10-21,未推送)
|
||||
- `566b13e` fix(conf): 样例配置去掉行尾注释(2 文件)
|
||||
- `c29e1d0` fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库(21 文件)
|
||||
- `main` ahead of `origin/main` 2;用户要部署测试环境验证后再决定推送。
|
||||
|
||||
### 测试环境实测反馈(同日晚,已修并推送)
|
||||
现象:purge-creds 删 4 列成功、GET 200、POST 后 GET 403、文件已生成。
|
||||
但凭据文件里 `"path":" /wp-login.php"`(前导空格)、`"ip":"127.0.0.1"`。
|
||||
|
||||
- **path 脏字符**:反代链路(CF → nginx → 源站)传出的 `req.URL.Path` 带前导空白。
|
||||
`Match` 内部 normalize 会 TrimSpace 所以仍命中,但落库与假表单 action 用原始值
|
||||
→ 取证数据脏、爆破脚本可能提交到错误路径。本地 httptest 复现不出来,是环境相关。
|
||||
修复:`cleanPath()` 在 Serve 入口 trim + 补前导斜杠(不改大小写)。
|
||||
- **IP = 127.0.0.1**:说明 `trust_proxy` 未开。后果不只是取证失真 ——
|
||||
**一次蜜罐封禁会封掉 127.0.0.1,等于封掉整站所有访客**(封禁是内存态,重启即恢复)。
|
||||
修复:命中回环地址且 `!trust_proxy` 时打一次性 WARN(24h 冷却),不改封禁行为。
|
||||
sample.ini 的 trust_proxy 段补了蜜罐相关说明。
|
||||
- `846bb8a` 已推送(`5e8deca..846bb8a`)。
|
||||
|
||||
### 收尾:path 脏字符最终定位(同日晚)
|
||||
用户重启进程后,`od -c` 显示新记录的 `"path":"/wp-login.php"` —— 干净。
|
||||
说明之前那条带空格的是**旧进程**写的(16:07 那次 `./tamabox web` 并没有真正替换掉在跑的实例)。
|
||||
期间补了两处加固并推送:
|
||||
- `74312fb`:`cleanPath` 改用 `TrimFunc` 覆盖 U+200B/200C/200D/FEFF
|
||||
(`TrimSpace` 只认 White_Space,零宽字符会漏,且会因首字符不是 `/` 多补斜杠);
|
||||
清洗前后不一致时 WARN 打出 `%q` 原始值,不可见字符靠日志才定位得到。
|
||||
- 教训:`strings` 不认 UTF-8 中文(已记入 topics/build.md);替换二进制后必须重启进程。
|
||||
|
||||
### 文档(`3d45374`,已推送)
|
||||
- 新增 `docs/honeypot.md`:生效范围、配置、**两条封禁路径**(GET 走常规阈值 /
|
||||
提交凭据才即封)、凭据文件格式与不入库的理由、`trust_proxy` 部署必读、
|
||||
旧列清理、排障表、设计取舍。
|
||||
- `docs/cli.md`:§0 补「替换二进制必须重启」「strings 用 grep -ac」;
|
||||
命令总览加 honeypot;新增 §7 honeypot 命令(原 7/8/9 顺延为 8/9/10);
|
||||
排错表加 5 条。
|
||||
# 2026-10-21
|
||||
|
||||
## 新旧前端对比(正式 vs 测试环境),只测量未改码
|
||||
|
||||
**方法**:curl 抓两环境 4 个页面 → 本地注入脚本取计算样式 / 资源体积 / 对比度 / 点按区;
|
||||
无头 Chrome 截图(`--headless=old`)后用 Pillow 算像素指标。
|
||||
⚠️ 本模型读不了图片,所以结论全部来自数值与结构,不靠"看截图"。
|
||||
|
||||
**踩坑**:file:// 打开抓下来的页面时,`href="/static/css/base.css"` 会解析成 `file:///static/...` → CSS 不加载,
|
||||
于是 dev 页量出来全是 UIkit 旧样式(圆角 0、旧阴影)。改法:抓下来后把 `/static/` 重写成 dev 域名绝对地址,
|
||||
再重新采集。**以后本地复现线上页面必须先重写站内绝对路径,否则测的是"无 CSS 的裸 HTML"。**
|
||||
另:`body.scrollWidth` 会被隐藏面板撑大(dev explore 1499),判断横向溢出要看 `documentElement.scrollWidth`(实测 1249 = 无溢出)。
|
||||
|
||||
**实测差异(1280 宽)**
|
||||
| 指标 | 正式(旧) | 测试(新) |
|
||||
|---|---|---|
|
||||
| 首页 HTML | 42.9KB | 34.2KB |
|
||||
| 探索页 HTML | 77.4KB | 53.4KB |
|
||||
| `<style>` 块 | 9–10 | 2 |
|
||||
| 行内 style 属性 | 8–16 | 3–5 |
|
||||
| 卡片圆角 / 阴影 | 0px / 单层硬阴影 | 12px / 双层柔阴影 |
|
||||
| 圆角取值种类 | 2px×73、50%、8、6、4、9 | 6、8、12、14、5、50% |
|
||||
| 正文色 | #333/#666/#999 混用 | 令牌 #1f2329/#5c6370/#8a919c |
|
||||
| 字体栈 | UIkit 默认 | system-ui + 苹方/雅黑 |
|
||||
| 图标 | emoji(🌙☀️) | 内联 SVG |
|
||||
| 首页截图像素:色彩数 / 边缘密度 | 2206 / 10.3% | 1174 / 5.0% |
|
||||
|
||||
**共同短板(新旧都没解决)**
|
||||
- `--tb-text-muted #8a919c` 在 #fff 上只有 **3.18:1**(AA 需 4.5);旧版 #999 更低(2.85)。
|
||||
建议改 #6b7280(4.83:1);暗色 muted #8b93a1 在 #242830 上 4.77 已达标。
|
||||
- 顶栏导航链接(UIkit rgba(0,0,0,.5))2.68–2.85:1,同样是低对比。
|
||||
- 移动端点按区:两版都有大量 19px 高的纯文字链接(不满足 44px 建议值)。
|
||||
|
||||
**新版自身的毛病(我写的,待修)**
|
||||
- `templates/home.html` 字号用了 0.75/0.78/0.86/0.9/0.95/0.98 rem → 7 个肉眼难辨的档位;
|
||||
`static/css/base.css` 里还有 0.78/0.8/0.82/0.84/0.88/0.95。**排版层级比旧版更碎**,应收敛到 4 档。
|
||||
- 残留圆角 5px(header 验证码框、sponsor)、9px(profile-tabs,list.html/explore.html 各一处)。
|
||||
|
||||
## 按上轮结论改代码(字号收敛 / 对比度 / 圆角收敛)
|
||||
|
||||
先确认过:本地 `static/css/base.css` 与 dev 部署版 **diff 0 行**,本地就是新版基线。
|
||||
|
||||
1. **新增字号阶梯令牌**(base.css `:root`):`--tb-fs-xs/md/lg/xl/hero` = 12/14/16/24/28px。
|
||||
`lg` 取 **1rem 而非 17px** —— UIkit 的 `.uk-card-title`/h4 就是 16px,顺着它才能让自定义标题
|
||||
与组件标题同档,否则会留下 16 与 17 差 1px 的两档并存(第一版就是这么写的,实测发现后改回)。
|
||||
2. **替换范围**:home.html 全部 7 档 → 5 档;explore / question-list / question-list(list.html) 三页
|
||||
的 0.7/0.72/0.82/0.84/0.85/0.9/0.98/1/1.15/1.2 全换令牌。实测 explore 由 **9 档 → 5 档**。
|
||||
3. **`--tb-text-muted` #8a919c → #6b7280**(#fff 上 4.83:1)。**关键坑**:
|
||||
UIkit 自带 `.uk-text-muted{color:#999!important}`,我原先的普通声明**从未生效**,全站仍是 #999(2.85:1)。
|
||||
必须 `!important` 才能压过。改完 box 页 fails 15→11、explore 14→9。
|
||||
4. **顶栏导航**:UIkit 默认 rgba(0,0,0,.5) = 2.68:1 → 改 `var(--tb-text-secondary)` = **6.05:1**,
|
||||
hover/active 走主色;暗色分支原有 `!important` 规则自然接管(实测 10.75:1)。
|
||||
5. **圆角全量收敛**:9px/5px/4px/2px/7px/3px 以及等值的 6/8/10/12/14px 全部换成
|
||||
`var(--tb-radius[-sm|-xs])`。全站(排除邮件模板)现在只剩 3 处 `999px`(胶囊,语义正确)。
|
||||
6. `conf.StaticVersion` 20261020g → **20261021a**(base.css 改了必须 +1)。
|
||||
|
||||
**验证**:go vet / go test(templates+i18n+conf)/ go build 全绿;预览页内联本地 base.css 后用无头 Chrome
|
||||
量计算样式,首页 12/14/16/24/28、explore 12/14/16/24/32,nav 6.05:1。
|
||||
|
||||
**遗留(未改,属设计取舍)**:主色链接 `#1e87f0` 在纯白上 3.37–3.42:1(<4.5)。改深会影响品牌观感,
|
||||
需用户决定;Waline 第三方赞数绿色 2.87 不在本站控制内。
|
||||
|
||||
**注意**:工作区有 4 个**非本次改动**的删除(`static/css/themes/test01.css`、`static/images/shiroko-cutout.png`、
|
||||
`static/images/themes/test01/*.webp`),是之前就存在的未提交删除,提交时勿一并 add。
|
||||
|
||||
## 17:4x 用户前端设计令牌化 → 主题/生成器的连带影响
|
||||
|
||||
用户改了 12 个模板 + base.css(未提交):`--tb-text-muted` #8a919c→#6b7280(AA 4.83:1)、
|
||||
新增 `--tb-fs-xs/md/lg/xl/hero` 阶梯、圆角改走 `--tb-radius(-sm/-xs)`、顶栏链接改 `--tb-text-secondary`、
|
||||
`.uk-text-muted` 加 `!important`(压 UIkit 的 `#999!important`)。
|
||||
|
||||
**关键结论:主题会把这套修复盖回原样。** 主题 css 覆盖同名令牌 → 页面用主题的值,不是 base.css 的。
|
||||
实测(worse of bg / 压平后的 surface):
|
||||
- default.css 不定义文字色 → 跟随 base ✓;sakura muted `#a08c94` **2.80:1**、shiroko `#8794a7` **2.76:1**(都看不见)。
|
||||
- 已调到 AA 下限:sakura `#78696e`(4.61)、shiroko `#656e7a`(4.63),同色相只压深;深色块两个主题本来就 ≥4.7,不动。
|
||||
- 主色仍不达标(未改,等用户定):base/shiroko `#4a7fc1` 3.69、base `#1e87f0` 3.26、sakura `#e5678f` 2.80。
|
||||
候选:`#3d6fac`(4.63)、`#0f6ac6`(4.83)、`#b53d61`(4.91)。
|
||||
|
||||
**生成器的必修项**:`paletteVars` 原来 `text-muted = mix(text,bg,0.55)` 硬算 → 浅底掉到 2.9:1,
|
||||
等于每导一次主题就把 base.css 的修复覆盖掉。改成 `mixToContrast(text, [bg, flatten(surface,bg)], 4.5)`:
|
||||
二分找「最淡但达标」的比例;文字本身不达标时原样返回(宁可不够淡,不能看不见)。
|
||||
- 半透明卡片(`rgba(255,255,255,0.86)`)必须**压平到底色**再算,否则按纯白算会偏低。
|
||||
- 次要 5.5 / 弱化 4.5 两档,保证层级不倒挂。
|
||||
- 配色面板加读数 `正文 12.5:1 · 次要 5.5:1 · 淡文字 4.6:1 · 主色 4.6:1`,<4.5 变橙(手填主色只提醒不拦)。
|
||||
- 默认 primary 由 `#4a7fc1`(3.69) 压到 `#3d6fac`(4.63) —— 默认值不该一上手就踩线。
|
||||
- 新增 `?dump=palette`(四种配色:默认亮/默认深/低对比手填/白卡彩底),`ok:true`。
|
||||
|
||||
**顺手补的一致性**:深色块顶栏原来 `!important` 定成 `--tb-text`(正文色),亮色块新的 secondary 规则
|
||||
让同一顶栏在深浅两模式权重跳一档 → 加 `html[data-theme="dark"] .uk-navbar-nav > li > a { secondary !important }`。
|
||||
实测计算值:无主题 nav #5c6370 / muted #6b7280;shiroko #51607a / #656e7a;sakura #6b545c / #78696e。
|
||||
|
||||
**⚠️ 用户删了 test01(css + 2 张图)与 `shiroko-cutout.png`,未提交,我没动。** 若测试站 `[theme] available`
|
||||
还登记着 test01,启动日志会打「主题文件不存在,已从可用列表忽略」,设置页不再出现,箱主已选 test01 的会静默回退站点默认。
|
||||
`shiroko-cutout.png` 只在 `docs/theme-shiroko.md` 里作参考图引用,不参与 embed。
|
||||
|
||||
**令牌化的残留**(只提醒,没改):`templates/user/profile.html` 还有 16 处 `font-size: 0.XXrem`;
|
||||
`base.css` 自身 11 处(0.78/0.82/0.88/0.95/1.1 —— 正是被点名要消掉的那档);圆角只剩 50%/999px(语义正确)。
|
||||
`--tb-fs-*` 在 base.css 里只用了 3 处(主题格、chip),阶梯主要落在模板层。
|
||||
`StaticVersion` 20261021a → **20261021b**(主题 css 内容变了,必须换键)。
|
||||
|
||||
## 18:2x 提交 test01 删除
|
||||
|
||||
`2d13c31` 删掉 `static/css/themes/test01.css` + `static/images/themes/test01/` 两张 webp(用户 18:19 要求)。
|
||||
`go test ./internal/theme` 仍全绿(测试里的 "test01" 只作 DisplayName 回退样例,不依赖文件)。
|
||||
⚠️ `static/images/shiroko-cutout.png` 的删除**仍未提交**(属另一件事,docs/theme-shiroko.md 还引用它)。
|
||||
|
||||
## 18:2x 删除 shiroko-cutout.png + 清文档引用
|
||||
|
||||
用户确认一并删。`docs/theme-shiroko.md` 三处引用改成「参考图不入库」(不再指向具体文件路径);
|
||||
顺手修了同页一处过时说法:**「--tb-deco 保持 6 个值」→ 10 个**(槽位早就扩到 10,漏改会让人以为写 6 个就够)。
|
||||
删除后 embed 无影响(`static/fs.go` 只嵌 `images/themes`,这个文件本来就不参与打包)。
|
||||
|
||||
## 18:2x 提交模板令牌化(用户改动,一次性提交)
|
||||
|
||||
`001306d`:11 个模板的硬编码字号/圆角换成 `--tb-fs-*` / `--tb-radius*`,工作区已干净。
|
||||
`TEST_PREVIEW=1 go test ./templates ./internal/i18n` 全绿(顺带刷新了生成器用的快照)。
|
||||
提交信息里记了 4 处**故意改变观感**的替换,免得以后被当成 regression 查:
|
||||
explore `.explore-name` 1.2rem→1rem、`.hot-vote-title` 0.98→0.875、首页 `.tb-stat-num` 手机 1.25→1rem、
|
||||
`.hot-vote-action` 0.82→0.875(变大)。头像 50% / 胶囊 999px 有意保留。
|
||||
下一轮若要彻底统一:`templates/user/profile.html` 16 处 + `base.css` 自身 11 处;`mail/*` 不要动(邮件客户端不保证 var())。
|
||||
|
||||
## 18:3x 收尾:字号全令牌化 + 主色补到 AA(新增 --tb-on-primary)
|
||||
|
||||
**字号**:`templates/user/profile.html` 20 处、`base.css` 9 处全部换成 `--tb-fs-*`。
|
||||
base.css 里那 4 档导航字号(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」,logo 三档统一 lg。
|
||||
**保留未改**(有理由):`.tb-pin-flag` / `.md-body code` 的 `0.9em`(相对父级,换成 rem 会破坏继承)、
|
||||
验证码的 `1.4rem/1.2rem`(等宽字形尺寸,不是正文阶梯)、chat 关闭按钮 `40px`、`password.html` 的 `3em`、mail/*(邮件客户端不保证 var())。
|
||||
行内 `12px` / `0.75rem` 这几处恰好等值,顺手换了;chat 里还剩一个 `13px`(13→12 会缩,没动)。
|
||||
|
||||
**主色**(同色相压亮度,目标 4.7 而非 4.5,留余量):base/screw `#1e87f0`(3.26~3.42) → **`#0e6fd0`**(4.70),
|
||||
shiroko `#4a7fc1`(3.69) → **`#3b6dac`**(4.74),sakura `#e5678f`(2.80) → **`#ba3e66`**(4.70,饱和度压到 .5 保持柔和粉)。
|
||||
`--tb-primary-weak` 与 shiroko 的两条 box-shadow 一并换 RGB;`TestSwatch` 的断言改成 `#ba3e66`。
|
||||
|
||||
⚠️ **主色有两个互相矛盾的角色**:当链接色要「深」,当按钮底色配白字却要「更深的底」——
|
||||
但深色模式的主色是亮强调(`#5c8dff`/`#ff8ab0`/`#6fa3e0`),白字只有 **2.2~3.1:1**。
|
||||
所以新增 **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`),`.uk-button-primary` 与
|
||||
`.uk-label:not(danger|warning|success|secondary)` 都吃它 → 深色模式按钮是「浅底深字」。实测 6.10/8.65/7.27。
|
||||
|
||||
其它泄漏点:header 验证码 focus 的 `#1e87f0`/`rgba(30,135,240)` → 令牌;profile `.feat-day.is-on` 写死的
|
||||
`rgba(30,135,240,.14)` → `var(--tb-primary-weak)`;explore 随机投票的 JS outline、vote-results 的行内色 → 令牌。
|
||||
|
||||
**深色 muted 之前算漏了**:只对 `--tb-bg` 算(4.7~4.8 以为过了),对**压平后的卡片**才是真实背景
|
||||
(sakura 4.31 / shiroko 4.17)。已提亮到 `#a18790` / `#7f8ea1`(4.62),base 深色 4.77 本来就过。
|
||||
|
||||
**生成器**:`paletteVars` 增加 `--tb-on-primary`(白字/深字里挑更优);`?dump=palette` 增加
|
||||
`onPrimaryCr` / `onPrimaryPickedBest` / `warnings` —— 主色卡在中间调时**不断言失败**(那是主色要调,
|
||||
不是挑错),列进 warnings;配色面板给橙色提示「压深配白字 / 提亮配深字」。
|
||||
`_scaffold.css` 令牌数 29 → 30。`StaticVersion` → **`20261021c`**。
|
||||
最终审计 8 种组合(base 亮暗 + 三主题亮暗)全部 ≥4.5。⚠️ 写 CSS 用 python 改时注意:`git checkout` 会把
|
||||
base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。
|
||||
|
||||
## 18:5x 检查测试环境部署 → 抓到设置页色块 ZgotmplZ
|
||||
|
||||
**部署核对(都对)**:`base.css?v=20261021c`、三份 CSS(base/sakura/shiroko)与本地**逐字节一致**;
|
||||
设置页主题列表 = 跟随站点默认 / 基础外观 / 樱花 / 砂狼白子,**test01 已消失**、名称无 `theme.name.*` key;
|
||||
登录按钮是新蓝(#0e6fd0)白字,箱主页排版正常。
|
||||
⚠️ **admin6 的主题被重置成「跟随站点默认」**(它原来选的是已删除的 test01)→ 箱主页 `<html>` 没有
|
||||
`data-theme-style`,现在是基础外观。要验证主题得重新选 sakura/shiroko。
|
||||
|
||||
**P1 缺陷(既有,非本次改动引入)**:设置页四个主题色块全是 `style="background: ZgotmplZ"`。
|
||||
`html/template` 会对 `style` 属性做 CSS 过滤,未标记安全的 `linear-gradient(...)` 一律替换成 ZgotmplZ。
|
||||
`swatchOf` 一直在生成渐变串,所以这个色块**从来没显示过**。
|
||||
修法:FuncMap 加 `safeCSS` → `template.CSS`,模板 `{{ safeCSS .Swatch }}`(值只来自 swatchOf,不接受用户输入)。
|
||||
加了 `TestThemeSwatchRenders`(渲染 user/profile,断言不含 ZgotmplZ 且含渐变串),并做变异验证:
|
||||
把 `safeCSS` 去掉 → 测试如期 FAIL。
|
||||
⚠️ 教训:任何 `style="..."` 里塞模板值都要过一遍这个过滤器;模板名在测试里**不带 `.html`**(`user/profile`)。
|
||||
|
||||
## 19:0x 复检部署:色块修复已上线 + 主题链路验证
|
||||
|
||||
`1f29d3e` 部署后复检,**全部通过**:
|
||||
- 设置页 HTML 里 `ZgotmplZ` 计数 **0**,四个 `tb-theme-swatch` 都是真渐变
|
||||
(跟随默认灰斜块 / `#f7f8fa→#0e6fd0` / `#fceef3→#ba3e66` / `#eef3f9→#3b6dac`),截图确认有色块。
|
||||
- 主题链路:把 admin6 的 `theme` 设为 `shiroko`(`POST /user/profile/theme`,该表单只有 `_csrf`+`theme`
|
||||
两个字段,单独提交安全),随后
|
||||
`/_/admin6` → `data-page-scope="box"`、`/_/admin6/95` → `question`、
|
||||
`/user/questions` → `admin`、`/user/profile` → `profile`,四处都带 `data-theme-style="shiroko"`
|
||||
与 `themes/shiroko.css?v=20261021c`。桌面渲染正常(人物站地面、车与人同向、光环完整、卡片磨砂)。
|
||||
- ⚠️ **admin6 现在选着 shiroko**(为验证链路设的),要还原就把主题改回「跟随站点默认」。
|
||||
- ⚠️ **`--window-size=390,844` 得到的不是 390 CSS 视口**:同一参数截本地快照也会「右侧被切」,
|
||||
真实排版没问题 —— 移动端必须用**宽度锁定的 iframe**(生成器那套)验,别据 window-size 结论判 bug。
|
||||
shiroko 的窄屏块(≤900px)只有一条 `html[data-theme-style]`(0,1,1),但它没有任何
|
||||
`[data-page-scope]` 块,所以不会被压住;深色块的 `--tb-page-image` 覆盖窄屏属预期(深色不用实景照)。
|
||||
|
||||
## 19:2x shiroko 单车/步枪「白色没抠」→ 白上白,只能全白转透明
|
||||
|
||||
**根因不是抠图参数**:素材是「白底 + 白车架」。采样确认车架涂装与背景白是同一个值
|
||||
(都在 `#f9f9f9`,差 0~3 级),颜色上完全分不开;四角 floodfill 只吃掉外圈,
|
||||
轮圈内、车架三角、车筐、枪与车架之间的白整块留着 → 看着像没抠。
|
||||
量化:修复前**不透明像素里近白(>240) 占 50%**,全透明只有 43%。
|
||||
|
||||
试过三种程序化方案(都写了脚本):
|
||||
1. 只按「近白厚度」区分白洞/白涂装(腐蚀测厚度)→ **失败**:轮辐间的扇形白楔宽 9~13px,
|
||||
与车架管 5~10px 重叠,阈值怎么调都会留下白色楔子(更难看)。
|
||||
2. 全白转透明(`min(r,g,b)≥238` 软过渡 + 饱和度>14 保留彩色)→ 采用。成果:
|
||||
不透明率 19.4%、近白 0.0%、29KB;轮辐/车架/车筐全成描边,粉色轮圈 + 蓝枪托 + 阴影仍在。
|
||||
3. 手绘 SVG 替换:未做(不如现成素材真实)。
|
||||
|
||||
**代价要记住**:车身填色一起没了,成品是**线稿风**,在 1512 整页里偏淡(远看像铅笔稿)。
|
||||
若用户要「实心车」,唯一正解是重出一张**绿幕/透明背景 + 车身用浅灰蓝等中间调**的素材
|
||||
(白车在浅场景里本来就发飘),已写进 docs/theme-shiroko-assets-spec.md 的「白上白」小节,
|
||||
验收清单也加了「不透明像素近白占比应≈0」这条。
|
||||
|
||||
改动:`props/bike.webp`(397×259 画布不变,位置公式不用动)、css 里图片 `?v=20261020d→20261021a`、
|
||||
`StaticVersion 20261021c→20261021d`、两份 docs 同步。`go test ./internal/theme` 全绿。
|
||||
⚠️ 画布尺寸**不能裁**:裁完 `auto 259px` 的显示宽度变了,`left calc(50% - 612px)` 的锚点会跟着漂。
|
||||
|
||||
## 19:4x 两件事:/user/questions「卡片透明」根因 + 主题素材上 R2
|
||||
|
||||
**1) /user/questions 卡片透明 —— 不是主题,是模板没有卡片**
|
||||
`templates/user/question-list.html` 整个文件只有 1 处 `.uk-card`(投票 tab 的「我的投票」标题条);
|
||||
提问列表是 `<div class="uk-margin-medium-bottom">` + `<hr>` **裸铺在页面上**。
|
||||
默认主题底色 #fff 看不出来,换带整页插画的主题后场景整片透上来 = 「卡片透明」。
|
||||
修:`.qlist-panel` 加卡片底(`--tb-surface` + `--tb-radius` + `--tb-shadow` + padding,
|
||||
≤640px 收紧),内部那张 `.uk-card-default` 降成 `--tb-surface-2` 去阴影(防卡片套卡片)。
|
||||
验证:本地快照注入 shiroko 后,行内容落在卡上,场景只从卡外透出;默认外观只是多一层白卡。
|
||||
⚠️ 页面级样式仍写在模板 `<style>` 里(不搬 base.css),符合既有约定。
|
||||
|
||||
**2) 主题素材放 R2 —— 三种做法,生成器已支持 A**
|
||||
素材只有一处引用:主题 css 的 `url()`(分享卡是 Go 用 imaging 画的,不读素材)。
|
||||
- A 生成器「素材前缀」:导出面板新增输入(`S.assetBase`,存进工程 json,`?assetbase=` 可测),
|
||||
`urlOf()` 一处决定地址 → 绝对 URL。**踩到过坑:`layerCSS` 里复制了一份路径拼接**,
|
||||
只改 urlOf 会导致「背景走 CDN、装饰还指本地」,已统一到 urlOf。
|
||||
- B 应用内 301:`[theme] asset_base` + 中间件(必须注册在 `flamego.Static` **之前**)。
|
||||
- C 边缘/反代改写(CF Origin Rule / nginx)。
|
||||
细节与取舍写进新文档 `docs/theme-assets-cdn.md`(含缓存、CORS、深色无素材、迁移 sed 命令)。
|
||||
⚠️ UI 坑:面板 240px 里 4 个中文字的 label 会被 flex 压缩、末字被输入框切掉 →
|
||||
label 要 `flex:0 0 auto; white-space:nowrap`,宽度给 84px 才够(68px 仍会切)。
|
||||
|
||||
**3) 素材托管:本地 / 任意 S3 兼容,生成器内测通即可上传**
|
||||
用户要求「放本地还是放 S3 兼容(不只 R2)由部署者自己定,并且能配好即测、测通即传」。
|
||||
- `serve.py` 手写 SigV4(零依赖,PUT/GET/DELETE/HEAD),新增 `/api/s3/ping|test|upload`。
|
||||
测试连接四步:HEAD 桶 → PUT 探针 → GET 读回比对 → 匿名 GET(能否被浏览器直读)→ 删探针。
|
||||
4xx 把 `<Code>` 翻成人话(`S3_HINTS`),签名失败直接指向密钥/Region/时钟三件事。
|
||||
- `index.html` 新增「素材托管」面板,上传集合复用导出用的 `usedAssetIds()`;
|
||||
成功后自动回填「素材前缀」。密钥只进 sessionStorage,不进工程 json(有断言)。
|
||||
- **两个真 bug**:①`usedAssetIds()` 返回 Set,`!ids.length` 恒真 → 永远「还没有素材」;
|
||||
②字段填完 700ms 内刷新会丢(scheduleSave 防抖)→ 失焦/切模式/上传成功时立即 `saveDraft()`。
|
||||
- **验证手段**:`check_sigv4.py` 用 aws-sdk-go-v2 的 signer 逐字节对拍(6/6)——
|
||||
裸 SignHTTP 会二次转义 URI、而 S3 client 关了它,所以用例只用 ASCII,
|
||||
中文 key 的编码规则跟随 S3 client(单次 RFC3986)。
|
||||
`selftest_upload.py` 假 S3 跑 16 项;`mock_s3.py` 可手动点面板。
|
||||
- ⚠️ **8777 端口陷阱**:Windows `SO_REUSEADDR` 让新进程 bind 成功却抢不到 accept,
|
||||
症状是「代码改了但接口还是 404」。查 `Get-NetTCPConnection -LocalPort 8777`,或换端口验证。
|
||||
|
||||
**4) 远端素材检测与管理(用户「还没测,想先看到 S3 上有什么」)**
|
||||
- serve.py:`s3_request` 支持 query,新增 `list_objects`(ListObjectsV2 + 分页)与
|
||||
`delete_objects`;端点 `/api/s3/list`、`/api/s3/delete`。
|
||||
- index.html:远端素材区,按对象键名跟本地比 → 一致 / 旧图(同名大小不同)/ 多余 / 缺失;
|
||||
「补齐差异」只传缺的与旧图,「删除多余」批量删,单行 ↻ 覆盖、✕ 删除。
|
||||
- ⚠️ **键的两种形态**:list 返回**完整键**(含 `<路径前缀>/`),upload/delete 接口收
|
||||
**相对键**。不剥这一层,服务端会再拼一次前缀 → 删一个不存在的键,S3 还回 204,
|
||||
表现为「显示删除成功但东西还在」。加 `relKey()` 统一转换。
|
||||
- ⚠️ `check_sigv4.py` 补了带 query 的用例(ListObjectsV2 三参数、乱序传入的分页 token),
|
||||
8/8 一致 —— canonical query 的排序与百分号编码也必须对拍,漏测会静默失效。
|
||||
|
||||
**5) 生成器默认监听 0.0.0.0(局域网/手机可用)**
|
||||
- `serve.py --host` 默认由 127.0.0.1 改成 0.0.0.0;启动时用 `local_addresses()` 列出所有
|
||||
网卡 IP(getaddrinfo + UDP connect 双路兜底),0.0.0.0 本身不打印成可点地址。
|
||||
- 顺带修两个真问题:①`TCPServer` 是单线程 —— 传一张几 MB 的图会卡住 iframe 预览与其它请求,
|
||||
换 `ThreadingTCPServer`;②写接口加 `_same_origin()`(Origin 与 Host 比对,无 Origin 放行),
|
||||
挡住「同网段另一个网页借访客浏览器发写请求」。curl/脚本不受影响。
|
||||
- `index.html` 提示文案里写死的 `127.0.0.1:8777` 改成 `HERE`(`location.host`)——
|
||||
用局域网 IP 打开时不能反过来告诉用户「请用 127.0.0.1 打开」。
|
||||
- ⚠️ **浏览器按 origin 隔离存储**:`127.0.0.1` 与 `192.168.x.x` 是两个站点,
|
||||
localStorage 草稿与 sessionStorage 里的 S3 密钥不共享,换地址要重填一次(README 已写)。
|
||||
- `mock_s3.py` 加 `--host`(默认仍 127.0.0.1):生成器在别的设备上打开时,假 S3 也要 0.0.0.0。
|
||||
- 验证:局域网 IP 访问 200、`--host 127.0.0.1` 时局域网访问连不上、跨站 Origin 被拒(403)、
|
||||
局域网 origin 下检测/补齐全走通。⚠️ 本机 curl 访问 192.168.x.x 必须加 `--noproxy '*'`,
|
||||
否则被系统代理接走报 502。
|
||||
@@ -1,250 +0,0 @@
|
||||
# 2026-10-23
|
||||
|
||||
## 单题分享卡二维码提到 5px/模块(`310f851`)
|
||||
|
||||
用户决定「单题一起做」,同时否掉方案 5(结果落盘/R2)——理由:功能使用率不高,不值得大动干戈。
|
||||
|
||||
- 单题卡卡片 210×255 放不下 5px 模块(37 模块要 185px):`QQRCardW` 236、`QQRCardH` 300、
|
||||
`QQRSize` 190。X 由 `QContentX+QContentW-QQRCardW` 派生 → 679,Y 居中 → 120。
|
||||
- `QTextMaxW` / `qLeftClip` 都从 `QQRCardX` 派生,左栏自动收窄 26px,无需另改。
|
||||
- 抽了 `drawQuestionQRCard(canvas, user, question, logo, fontCtx)`(handler 只剩调用),
|
||||
这样测试能直接渲染整卡:`TestQuestionQRCardLayout` 断言正方形 / 模块≥5 / 四边留白≥20 /
|
||||
居中≤1px,`TEST_PREVIEW=1` 出 `outputs/sharecard-preview/question-card.png`。
|
||||
- 实测:二维码 185px、居中偏差 0.5px、上下留白 34px;gozxing 在 960/720/480/320 全通过。
|
||||
变异验证:`QQRSize` 改回 150 → "QR module is 4px, want >=5" FAIL。
|
||||
- 未做(用户明确不要):分享卡结果落盘/上 R2。进程内 LRU 仍是 16 条 + TTL 1h,
|
||||
冷渲染成本接受现状。
|
||||
|
||||
## 部署后复验(箱子卡 + 单题卡,`310f851`)
|
||||
|
||||
- 箱子卡:QR 185×185 @ y120-304 / x702-886,卡中心 795 → 着墨中心 794.0(−1.0px);
|
||||
提示语 794.0、分隔线 795.0、链接两行 795.0 / 794.5;上下留白各 18px。
|
||||
- 单题卡:QR 185×185 @ y154-338 / x704-888,卡中心 797 → 着墨中心 796.0(−1.0px);
|
||||
提示语 797.0;上下留白 34 / 33px。
|
||||
- 端到端解码(部署图,含真实背景与内嵌头像):两张卡在 960 / 720 / 480 / 320 宽全部 OK,
|
||||
内容正确(单题卡 `/114`)。
|
||||
- 顺带确认 **不是** bug:删除提问后分享卡立刻 302(`GetByID` 在缓存查询之前),
|
||||
不存在「删了还能靠缓存取到图」的窗口。
|
||||
|
||||
## 缓存 key 排查结论(`675cecb`)
|
||||
|
||||
用户要求「有影响就修」。查下来**当前没有实际影响**,只有一条理论缺口:
|
||||
|
||||
| 可能场景 | 是否真有影响 | 原因 |
|
||||
|---|---|---|
|
||||
| 用户改域名 → 二维码指向旧域名 | ❌ 当前无 | Domain 只在注册时写(`register.go` / `CreateUser`),无改域名入口 |
|
||||
| 改 ExternalURL / ShareBackground | ❌ 无 | 配置需重启,缓存是进程内的,重启即清 |
|
||||
| 换头像/背景后仍是旧图 | ❌ 无 | R2 key = `picture/日期/随机 hex`,内容不可变,换图必换 URL |
|
||||
| 域名将来可改 | ⚠️ 理论 | 单题卡 key 未含 `user.Domain` |
|
||||
|
||||
处理:给 `questionShareCardCacheKey` 补 `user.Domain`(箱子卡本来就有);
|
||||
两处「TTL 30 分钟」注释改成 1 小时(代码一直是 `time.Hour`)。
|
||||
新增 `TestShareCardCacheKeysCoverDrawnFields`——逐字段变异断言 key 必须变,
|
||||
去掉 Domain 立刻 FAIL。**以后改分享卡要加新字段时,先看这张测试。**
|
||||
|
||||
## 正式环境「蜜罐封了但还能访问」排查(IPv6)
|
||||
|
||||
本地用 IPv6 全程复现了一遍 autoban 链路(CF-Connecting-IP 注入 + trust_proxy=true):
|
||||
封禁前 200 → POST 凭据后 `IsBanned`=true → 同地址 403 → 同 /64 换地址仍 403 →
|
||||
不同 /64 放行 200。**判定逻辑与中间件都正确,不是代码 bug。**
|
||||
|
||||
封禁键 = `shared.LimitKey(ResolveClientIP(req))`:
|
||||
- IPv4 原样
|
||||
- IPv6 折成 `xxxx:xxxx:xxxx:xxxx::/64`
|
||||
写入(honeypot.Serve / route.NotFound)与检查(autobanMiddleware)用的是同一个函数,一致。
|
||||
|
||||
⇒ 「邮件说封了但能访问」只能是**键不一致**或**进程重启(封禁纯内存态)**。
|
||||
三种可能:① 部署后重启过;② trust_proxy 未配 → 键是反代地址(`127.0.0.1` / `::/64`);
|
||||
③ 前后请求地址族或 /64 不同(双栈、IPv6 前缀轮换、多实例)。
|
||||
|
||||
判定方法:`honeypot hits` / `data/honeypot-creds/creds-YYYY-MM-DD.jsonl` 里的 `ip` 字段
|
||||
就是**被封的键**,与本机 `curl -6 ifconfig.co` 比对即可。
|
||||
|
||||
**发现的真实取证缺陷**:`db.HoneypotHit.IP` 与 creds 文件存的是折叠后的 `/64` 键,
|
||||
IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会误以为「这不是我的 IP」。
|
||||
建议:取证记精确 IP,封禁仍用 /64;建封与 403 命中各打一条日志。
|
||||
|
||||
### 据此动手改的三处(精确 IP / 封禁键分离 + 可观测性)
|
||||
|
||||
1. `honeypotHandler.Serve` 拆两个变量:`clientIP = ResolveClientIP`(精确,取证/邮件用)、
|
||||
`banKey = LimitKey(clientIP)`(/64,封禁/采样/告警去重用)。DB 与凭据文件都写 clientIP。
|
||||
2. `autoban.Options.OnBan(key, duration, reason)` 回调(新增 `ReasonProbe` / `ReasonHoneypot`),
|
||||
保持 autoban 纯标准库、不再依赖 logrus。route.newBanner 注入 → 建封打 WARN
|
||||
(ban_key / reason / duration / expires_at)。中间件 403 分支打 Debug。
|
||||
3. 邮件同时给「来源 IP」(精确)与「封禁范围」(ban_key,仅 `ne .ip .ban_key` 时渲染);
|
||||
顺手把模板里过时的「已入库」改成「写入取证文件」(上一轮凭据外置后没同步)。
|
||||
|
||||
副作用修掉:`honeypot stats` 的来源排行改按 `LimitKey` 合并(否则 IPv6 轮换地址会把排行碎成
|
||||
几十条各 1 次)。SQL 取 `limit*4` 再合并,属近似。
|
||||
|
||||
测试:`TestHoneypotRecordsExactIPv6ButBansByPrefix`(精确 IP + /64 键 + store 断言)、
|
||||
`TestOnBanCallback`、`TestHoneypotAlertTemplateRenders`、`TestMergeSourcesByKey`。
|
||||
四处变异验证有效(改回存聚合键 / 封禁用精确地址 / 去掉 notifyBan / 不合并排行)。
|
||||
|
||||
**变异验证的坑**:把 `shared.LimitKey(r.IP)` 改成 `r.IP` 会因 `shared` 未使用而
|
||||
**编译失败**(不是断言失败);变异要保证仍能编译(改成 `LimitKey(r.IP) + r.IP` 才测到逻辑)。
|
||||
|
||||
### 测试环境验收(部署 29ddee0 后)
|
||||
|
||||
伪造 `CF-Connecting-IP: 2001:db8:…`(RFC 文档保留段,封了不影响任何人)直连源站端口:
|
||||
|
||||
- 基线 200 → POST 凭据 200(假页面)→ **同 /64 换地址 403**(聚合封禁生效);
|
||||
- 凭据文件 `ip` = `2001:db8:dead:beef::1234`(精确,无 /64 后缀),`banned:true`;
|
||||
- 换另一个 /64(`2001:db8:cafe::1`)→ 200,说明封的是 /64 不是全局;
|
||||
- `od -c` 复核 `"path":"/wp-login.php"` **无前导空格** → cleanPath 生效。
|
||||
|
||||
**教训**:上一次那条 `" /wp-login.php"` 是**那次请求的 URI 本身带空格**(本地实测
|
||||
`Match(" /wp-login.php")=true` 靠 TrimSpace 命中、`Match("/ wp-login.php")=false`),
|
||||
不是代码问题;判断这类"看不见的字符"一律用 `od -c`,别靠终端渲染。
|
||||
测试环境服务是**前台跑在 /dev/pts/1**,日志不在文件里 —— 拿日志要 `tmux capture-pane` 或
|
||||
下次启动加 `>> logs/tamabox.log 2>&1`。
|
||||
|
||||
### 蜜罐升级为 9 个伪装家族
|
||||
|
||||
新增 `family.go`(皮肤表)/ `asset.go`(静态资源占位)/ 重写 `page.go`:
|
||||
- 按路径分发:wordpress / phpmyadmin / tomcat(401+Basic) / jenkins / grafana / gitlab /
|
||||
luci / joomla / generic;每类独立字段名、配色、指纹响应头、Set-Cookie;
|
||||
- `credentialKeys` 扩 `pma_*` / `j_*` / `luci_*` / `user[login]`;
|
||||
`token` 只用于判 HadFields,**不进 isPasswordKey**(否则 PMA 的 hidden token 会随机顶掉真密码);
|
||||
- 静态资源(`/wp-admin/css/…` 等)回占位内容,**不落库不计数** —— 一次页面加载 5–10 个请求;
|
||||
- `honeypot paths` 加家族汇总(实测 110 条:generic 84 / wordpress 10 / tomcat 6 / gitlab 3 / pma 3 …);
|
||||
- 性能:RenderFamily 12µs→3.7µs、22→2 allocs、19.5KB→5.2KB(禁 `"a"+b+"c"`、Replacer 提包级)。
|
||||
|
||||
**新工具** `dev/preview-honeypot`:渲染 9 个家族到 `outputs/honeypot-preview/index.html`,
|
||||
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
|
||||
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
|
||||
|
||||
### 测试环境验收:9 家族伪装(部署 7225f68 后,只读 GET)
|
||||
|
||||
全部符合预期:8 个家族 title/generator 各不相同、Tomcat 401+Basic realm、
|
||||
静态资源 200 且 MIME 正确(128B css / 81B js / 70B png)、插件目录 .php 回 404、
|
||||
业务路由(/login /user /static /api)无 canary、界外路径仍 302 → /。
|
||||
|
||||
发现的漏洞(按优先级):
|
||||
1. **P1 `/xmlrpc.php` 回 HTML 登录页** —— 而我们自己发了 `X-Pingback: /xmlrpc.php`,
|
||||
真实 WP 回 text/xml,等于自己指给自己一个明显假的地址;同时丢掉 WP 爆破最有价值的
|
||||
捕获通道(xmlrpc system.multicall 一次可试上千组,绕开登录限速)。
|
||||
2. **P2 每次请求恒定值**:PMA `token=e1a4c0b7d2f58g93`(还带非 hex 的 g)、
|
||||
generic `csrf_token=a7f3c1e9b2d4`、`X-Jenkins-Session: 8b1f2c4d`。
|
||||
两次请求完全相同 → 跨请求指纹即可识别。应改每请求随机。
|
||||
3. **P2 `X-Pingback` 用相对路径**,真实 WP 给绝对 URL。
|
||||
4. **P3 `/phpinfo.php` 漏匹配**:路径清单里是 `/phpinfo`、`/info.php` 精确项,
|
||||
段前缀匹配不覆盖 `.php` 变体 → 302。可加「p 去掉 .php 后在集合内」的兜底。
|
||||
5. **P3 `/wp-json/wp/v2/users` 回 HTML**,真实 WP 回 JSON(用户枚举接口)。
|
||||
6. P3 `X-Frame-Options: DENY` / `Cache-Control: no-store` 与真实 WP 不同,
|
||||
但属全局中间件,改动影响全站。
|
||||
|
||||
### 修 P1/P2:XML-RPC 端点 + 动态 token + .php 兜底
|
||||
|
||||
- 新增 `internal/security/honeypot/xmlrpc.go`:`/xmlrpc.php` 不再回登录页。
|
||||
GET→405+XML fault「XML-RPC services are disabled」;POST methodCall→200+fault
|
||||
「Incorrect username or password」;POST system.listMethods→方法列表模板。
|
||||
凭据提取用正则(不上 encoding/xml,蜜罐高频命中):抓 `<string>`、剔除
|
||||
methodName 与 http(s) URL、两两配对;`blog_id=1` 这种「奇数长度+首项短数字」
|
||||
会丢首项纠正错位;第一组进标准字段,其余进 `cred2.*`,原始串全量进 `xmlrpc.N`
|
||||
(配置上限 16 对 / raw 8KB)。
|
||||
- 动态占位符:`LoginPageVars.Nonce`(32 hex,`math/rand/v2`,刻意不用 crypto/rand,
|
||||
这些是假 token 只需「每次不同」)。支持 `$hex8/16/32` 与 `$base`;
|
||||
页头、hidden、Set-Cookie 共用同一套 nonce。
|
||||
- `X-Pingback` 改绝对地址(`$base/xmlrpc.php`),没配 `external_url` 就不发。
|
||||
- `Cache-Control` 从 `no-store` 改 `no-cache, must-revalidate, max-age=0`(更像 WP/PMA)。
|
||||
- Grafana/GitLab 补真实会种的会话 cookie(`grafana_sess`、`_gitlab_session`,动态)。
|
||||
- `Match` 增加 `.php` 变体兜底:`/phpinfo.php` 之前漏匹配 → 302。
|
||||
- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
|
||||
|
||||
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
|
||||
|
||||
### 测试环境验收:XML-RPC 端点(部署 9d3f5a7 后)
|
||||
|
||||
- `GET /xmlrpc.php` → **405 + text/xml**(与真实 WP 一致,XML-RPC 只接受 POST),
|
||||
用户误以为这是报错;405 的 body 是 `<fault>XML-RPC services are disabled…`。
|
||||
- `POST system.listMethods` → 200 + 方法列表 ✓。
|
||||
- `POST wp.getUsersBlogs`(伪造 `CF-Connecting-IP: 2001:db8:beef::7`)→ 凭据落文件:
|
||||
`username=xruser / password=xrpass`、`fields.xmlrpc.0/1`、`xmlrpc.method`、`banned:true` ✓。
|
||||
- 紧接的下一次请求 `403 Forbidden` = 已封禁(第一次 POST 就封了该 /64),属预期。
|
||||
- 动态 token 两次不同(`d85a8c…` / `76a404…`)✓。
|
||||
|
||||
**再次出现的 `"path":" /xmlrpc.php"` 已判定为粘贴/终端产物**:`IsXMLRPCPath` 不做 trim,
|
||||
若 `path` 真带前导空格根本进不了 XML-RPC 分支,而记录里有 `xmlrpc.*` 字段 ⇒ 落盘值必是
|
||||
`/xmlrpc.php`。这类"看不见的字符"结论一律靠 `od -c`,别靠终端回显。
|
||||
|
||||
### 403 响应体换成图片
|
||||
|
||||
用户给了 R2 预签名链接(`X-Amz-Expires=604800`,7 天后失效)——**不能直接热链**。
|
||||
下载后(256×256 webp,8.6 KB)用 `//go:embed` 编进二进制:
|
||||
`internal/route/assets/403.webp` + `internal/route/banned.go: writeBanned()`。
|
||||
- 状态码仍是 403(语义不变),只换 body;`Content-Type: image/webp` + 显式 `Content-Length`
|
||||
(8 KB 超过 Go 的 2 KB 缓冲,不设会退回 chunked)+ `no-store`;HEAD 不写 body。
|
||||
- 放在 `internal/route/assets/` 而不是 `static/`:static 会多出一个可公开访问的 URL。
|
||||
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
|
||||
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
|
||||
已压到 256×256;别塞几百 KB 原图。
|
||||
|
||||
### 关于「403 图放 R2 是否更好」的结论
|
||||
|
||||
结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」——
|
||||
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
|
||||
那就要放弃「403 直接是图片」,改成 403 + 一个 `<img src=外链>` 的 HTML。
|
||||
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
|
||||
(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。
|
||||
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
|
||||
若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。
|
||||
|
||||
### 改为 ban_image_path(配置驱动,默认纯文字)
|
||||
|
||||
按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除,
|
||||
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
|
||||
- `[security] ban_image_path`(相对路径以进程工作目录为基准);
|
||||
- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`,
|
||||
热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);
|
||||
- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成
|
||||
app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条);
|
||||
- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给;
|
||||
HEAD 不写 body;`no-store`。
|
||||
- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。
|
||||
|
||||
**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
|
||||
否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。
|
||||
|
||||
## /user/questions:筛选项「已读 是/否」+ 手机端标签行不折行
|
||||
|
||||
- 筛选栏第 5 列标签「阅读状态」四字,在 ~53px 的窄列里必折两行。
|
||||
→ 词条 `qlist.readStatus` 删除,换 `qlist.readFilter`(已读/Read/既読/已讀)+
|
||||
`qlist.filterYes`/`qlist.filterNo`(是/否)。**只改标签,option 的 value 仍是
|
||||
read/unread**(后端 `readParam` 校验依赖它)。`qlist.all`(全部)保留作默认不筛选。
|
||||
- 手机端「时间 + 状态标签 + 标记/置顶」挤一行:实测(360/390/375 iframe + dump-dom 量 rect)
|
||||
一行只有 306–336px,完整时间占 127px,2 标签 + 2 按钮要 343px → 必折。
|
||||
→ 时间改 `tb-short`=m-d H:i / `tb-text`=Y-m-d H:i:s(复用 pill 的 640px 断点);
|
||||
→ `.qlist-pill` 基础值 min-width 40→34、padding 0 8→6(手机端省 20px),
|
||||
桌面 `@media(min-width:640px)` 仍覆盖回 62px/10px。
|
||||
结果:2 标签 + 2 按钮在 360 就单行;「私密+待答+未读」三标签仍会在 ≤375 折行(可接受)。
|
||||
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
|
||||
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
|
||||
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
|
||||
|
||||
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
|
||||
|
||||
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
|
||||
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
|
||||
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
|
||||
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
|
||||
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
|
||||
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
|
||||
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
|
||||
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
|
||||
|
||||
## 全站数据页 /stats(替换首页统计卡)
|
||||
|
||||
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
|
||||
明细见 `topics/stats.md`。
|
||||
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
|
||||
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
|
||||
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
|
||||
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
|
||||
热力图 CSS grid + 主色 opacity 分 4 档。
|
||||
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
|
||||
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
|
||||
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
|
||||
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
|
||||
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
|
||||
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。
|
||||
@@ -1,144 +0,0 @@
|
||||
# 2026-10-24
|
||||
|
||||
## /stats 入口收敛到页脚
|
||||
|
||||
- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
|
||||
- 改动:
|
||||
- `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `</details>` 后那一段)。
|
||||
- `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import;
|
||||
`/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。
|
||||
- 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。
|
||||
- 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。
|
||||
- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异;
|
||||
重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。
|
||||
- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删,
|
||||
否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。
|
||||
|
||||
## /stats 文案与脚注排版修正
|
||||
|
||||
- 页脚入口文案「数据」→「统计数据」(`site.footer.stats`,四语言 `Statistics` / `統計データ` / `統計數據`)。
|
||||
- **「提问最集中在三 19 点前后」的 bug**:洞察句子复用了热力图列头的 `stats.wd.*`
|
||||
("一"…"日" / "Mon"…),那是为窄列头设计的单字标签,进句子就成了"三"、英文是 "Wed"。
|
||||
新增一组完整名 `stats.weekday.1..7`(周一/週一/Monday/月曜日),洞察改用这组;
|
||||
`stats.wd.*` 只留给热力图列头。已在 `overview_test.go` 加断言锁住,防止回退。
|
||||
→ 教训:**短标签和完整名必须分两套词条**,别为了少写一组 key 复用。
|
||||
- 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由
|
||||
「A:x · B:y」挤成一行改为 `grid-template-columns: max-content 1fr` 各占一行,
|
||||
标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。
|
||||
|
||||
## 部署前自查:两个 P0 都已修
|
||||
|
||||
用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的:
|
||||
|
||||
1. **「今日」两项被每日缓存冻住**(P0,与页面「实时」说明矛盾)。
|
||||
→ `TodayCounts` 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。
|
||||
新增 `TestTodayNumbersNotFrozen` / `TestTodayCacheExpiresAcrossMidnight`。
|
||||
2. **日期分组跟着 PG 会话时区(默认 UTC)**(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。
|
||||
psql 实测:`2026-10-24 01:30+08` 在 UTC 会话下 to_char 得 `2026-10-23`、hour=17。
|
||||
→ `analyticsLocalize()` 用 `AT TIME ZONE INTERVAL '+08:00'`(偏移取 `time.At()` 的 Zone,
|
||||
不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。
|
||||
3. PeakDay 用 `>=` 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 `>`。
|
||||
4. `ovRefresh` 里的聚合改用 `context.WithoutCancel` + 30s 超时:第一个访客中途关页面时,
|
||||
排队的请求不会各自重跑全表 GROUP BY。
|
||||
|
||||
性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms,
|
||||
本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。
|
||||
|
||||
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
|
||||
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
|
||||
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
|
||||
|
||||
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
|
||||
|
||||
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
|
||||
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
|
||||
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
|
||||
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
|
||||
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
|
||||
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
|
||||
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
|
||||
|
||||
## P0:回答耗时口径是错的(本次修)
|
||||
|
||||
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
|
||||
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
|
||||
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
|
||||
|
||||
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
|
||||
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
|
||||
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
|
||||
|
||||
## 顺带修
|
||||
|
||||
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
|
||||
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
|
||||
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
|
||||
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
|
||||
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
|
||||
|
||||
## 30 天折线补纵轴刻度(`cbb8908`)
|
||||
|
||||
- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart`
|
||||
返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**,
|
||||
分两次各自归一化会让线和刻度错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。
|
||||
1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
|
||||
- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的,
|
||||
文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`,
|
||||
top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。
|
||||
- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。
|
||||
- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
|
||||
- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。
|
||||
- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。
|
||||
- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。
|
||||
|
||||
## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码)
|
||||
|
||||
- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的
|
||||
15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。
|
||||
- 连真库实测(临时测试,跑完已删):
|
||||
- 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`;
|
||||
- `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样;
|
||||
- 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`;
|
||||
- old 的 `answered_at` / `updated_at` 相差 216h。
|
||||
- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。
|
||||
所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。
|
||||
- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`,
|
||||
被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下,
|
||||
比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。
|
||||
- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。
|
||||
|
||||
## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt
|
||||
|
||||
- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。
|
||||
- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。
|
||||
先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
|
||||
- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。
|
||||
- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行);
|
||||
执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
|
||||
- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"),
|
||||
改它会直接改变 /stats 首页数字,留待用户决定。
|
||||
|
||||
## 已改(`219adef`):今日回答数也换 answered_at
|
||||
|
||||
- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。
|
||||
实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
|
||||
- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致;
|
||||
改写回答不重置 answered_at,所以改写不算今日回答(有意)。
|
||||
- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。
|
||||
20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了
|
||||
「真慢了再加 idx_questions_answered_at」。
|
||||
- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」,
|
||||
但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
|
||||
- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。
|
||||
|
||||
## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」
|
||||
|
||||
- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间,
|
||||
而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
|
||||
- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`),
|
||||
删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。
|
||||
- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面
|
||||
的 `stats.foot.cacheNote` 说明,不塞进标签里。
|
||||
- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。
|
||||
- i18n 有词条数量对等测试,删键必须四个语言一起删。
|
||||
@@ -1,126 +0,0 @@
|
||||
# 2026-10-25
|
||||
|
||||
## /stats 文案清理:把「写给运营者看」的话删掉
|
||||
|
||||
用户指出「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」怪 ——
|
||||
那是对他自己说的(作者视角的免责),不是对访客说的。顺着扫了页面全部 40 余条文案。
|
||||
|
||||
改动:
|
||||
|
||||
- **删 `stats.insight.note`**(四语言 + 模板那行 `<p class="tb-card-note">`)。
|
||||
卡片留「数据速览」+ 列表即可;`stats.insight.empty` 保留。
|
||||
删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`。
|
||||
- **`stats.foot.cacheNote` 去掉实现细节**:「当天重算一次的快照(当天第一次访问时算,
|
||||
服务重启也会重算)」→「累计数字与图表每天更新一次」。访客不需要知道缓存是懒重算、
|
||||
也不需要知道重启会触发重算;准确的新鲜度由「快照时间」那行给出。
|
||||
- 顺手修了 `templates/stats.html` 里一条过期注释(还写着「四条时间」「基准日」,
|
||||
上一轮已改成三行「快照时间」)。
|
||||
|
||||
保留未动(判断过,不属于这个问题):
|
||||
|
||||
- `stats.foot.privacy` 与顶部 `stats.lead` 的后半句近乎同义(隐私声明重复一次)——
|
||||
页脚隐私声明是惯例,重复不算噪音,故保留。
|
||||
- `stats.today.tag` 的「(实时)」在 60s TTL 下仍成立,不改。
|
||||
|
||||
## 约定(已写进 topics/stats.md)
|
||||
|
||||
页面文案一律以**访客**为第二人称对象:不写实现细节(缓存、重启、索引),
|
||||
不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。
|
||||
数据来源/隐私/新鲜度这些确实要对访客交代的事,放 `stats.lead` 与页脚注。
|
||||
|
||||
## 环比句改成「点明月份 + 条数」
|
||||
|
||||
用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。**不是**——实际比的是
|
||||
两个**完整自然月**(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」
|
||||
两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。
|
||||
|
||||
- 新句:`9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。`
|
||||
EN:`September had 5,620 questions; August had 4,890, up 14.9%.`
|
||||
- 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。
|
||||
- `page.go` 新增 `monthText`:CJK 一律「9 月」,英文用 `time.Month().String()`
|
||||
("month 9" 不是英语),不带年份——相比的两个月相邻。
|
||||
- 三个词条 `momUp/momDown/momFlat` 取代 `mom`:方向词没法塞进同一个模板,
|
||||
四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。
|
||||
- `|pct| < 0.05` 走持平,避免「多了 0.0%」。
|
||||
- 测试:`TestInsightMoMNamesBothMonths`(上升/下降/持平三分支,断言两个月份名 +
|
||||
两个条数都在句子里)、`TestMonthText`。
|
||||
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
|
||||
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
|
||||
改成从 `monthly[10]/monthly[9]` 算。
|
||||
|
||||
## 安全评估(AI 挖洞背景下的一次全量自检)
|
||||
|
||||
报告:`outputs/security-audit-2026-10-25.md`,脚本在 `outputs/sec-audit-2026-10-25/`。
|
||||
结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。
|
||||
|
||||
### P1-1 匿名提问可植入远程图片(去匿名化)
|
||||
- `markdown.go` 的 `safeURL` 只校验协议,`<img src=https://任意域>` 能原样输出。
|
||||
- 唯一挡住它的是审核「广告_引流类」维度,而该维度**只在 strict 档生效**
|
||||
(`censor/policy.go`:standard 只有 Abuse/Contraband,lax 为空)。
|
||||
- 实测:账号切 standard → `<img src="https://tb871870.example.org/standard.png">` 直接入库并渲染;
|
||||
切回 strict 才被拒。→ 修法必须在 `markdown.go` 层限制 img src 为站内/白名单,不能依赖档位。
|
||||
- 同时确认 8 种混淆(实体编码、`java\tscript:`、协议相对 `//`、反斜杠)都被审核识别。
|
||||
|
||||
### P1-2 访问密码可爆破 + 提问密码校验早于验证码
|
||||
- `UpdatePagePassword`/`UpdateAskPassword` 只有 `maxlen:30`,**无 minlen**,可设 1 位。
|
||||
- `page.go` 的 `New()` 第一件事就比对 AskPassword,验证码要到后面才校验 →
|
||||
实测「错误提问密码 + `deadbeef:999999`」返回「提问密码错误」而不是验证码错误 →
|
||||
**猜密码完全不消耗验证码**。限额 30/min(ask)、10/min(page)。
|
||||
|
||||
### 其余P2/P3
|
||||
- 登录开放重定向:`to=/\evil.example` → `Location: /\evil.example`,浏览器按 WHATWG
|
||||
把 `\` 当 `/` → `https://evil.example/`。`path.Clean` 不处理反斜杠。
|
||||
- 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
|
||||
- 无 CSP;`X-XSS-Protection: 1` 过时;`/user` 无 `Cache-Control: no-store`。
|
||||
- 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
|
||||
- 匿名投票身份 = `vote_fp` cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
|
||||
- 登录 `to` 兜底是死代码:`path.Clean("/"+to)` 恒非空 → 登录后永远落 `/` 而非箱主页。
|
||||
- JSON 响应无 `Content-Type`(实测 text/plain);429 无 `Retry-After`。
|
||||
|
||||
### 无法从外网确认的一项
|
||||
`trust_proxy` 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信
|
||||
(好的方向:无法伪造绕限流),但这既可能是 `trust_proxy=false`(→ 按 CF 边缘 IP 聚合,
|
||||
限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。
|
||||
|
||||
### 已验证没事(别重复怀疑)
|
||||
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302;
|
||||
页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF;
|
||||
`exec cwebp` 不接用户输入;SMTP 默认校验证书;`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;
|
||||
无 CORS 头;测试站蜜罐已开(`/.git/HEAD`、`/.env` 返回假后台)。
|
||||
|
||||
### 测试卫生
|
||||
- 测试站账号 `tbunread1022181904`,脚本已把严格度恢复 strict、清空 page/ask 密码、
|
||||
删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
|
||||
- 隔离 venv 里装了 `requests`(`~/.workbuddy/binaries/python/envs/default`),
|
||||
比 urllib 顺手;`/api/captcha/generate` 限流 20/min,批量提交脚本必须带 429 重试。
|
||||
|
||||
## 安全评估(续):trust_proxy=true 引出的 P1-3
|
||||
|
||||
用户确认两套环境 `trust_proxy = true`。据此重做了 IP 采信那一块,发现比原来的推断严重:
|
||||
|
||||
### P1-3 双 CDN 下权威头优先次序反了
|
||||
- `internal/shared/ip.go` 的 `ResolveClientIP` **无条件**先读 `CF-Connecting-IP`,再读 `EO-Client-IP`。
|
||||
- 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
|
||||
- CF:强制覆盖 `CF-Connecting-IP`,客户端伪造直接 **403**(生产/测试站实测一致)。
|
||||
- **EdgeOne:不剥离客户端传入的 `CF-Connecting-IP`**,`share.shiroko.one` 实测带该头返回 200
|
||||
(响应头 `Server: edgeone makers` / `EO-LOG-UUID`)。
|
||||
- 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效,
|
||||
且 `autoban.Record` 可被用来**栽赃任意 IP**(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
|
||||
- **今天打不通**:`box.shiroko.one` 境内递归(AliDNS DoH)只解析到 `cloudflare.182682.xyz` → 104.18.x/172.64.x,
|
||||
文档里写的境内 `eo.dnse4.com` 线路已不在;`box.tama.guru` 301 跳新域名也走 CF。差一条 DNS 记录。
|
||||
- 修法:权威头也套 `isTrustedProxy(remote)`(回环可用 cloudflared 本机场景),fail-closed;
|
||||
EO 侧删 `CF-Connecting-IP`;日志同时记 `remote_addr` 与解析后 IP。
|
||||
|
||||
### 源站定位(外网已尽力,未找到)
|
||||
- `box.shiroko.one` CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 优选,zone 不在我们手里,
|
||||
其 Transform/WAF 规则无法自查)。
|
||||
- certspotter 枚举 `*.shiroko.one` 11 个、`*.337845818.best` 19 个子域,逐个解析全部落
|
||||
Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),**无一条直连记录**。
|
||||
- `status.shiroko.one`、`share.shiroko.one` → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot
|
||||
→ 是 EO 边缘不是源站。
|
||||
- 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 `security-selfcheck.sh local` 确认
|
||||
`listen = 127.0.0.1:5877`。
|
||||
|
||||
### 顺带修正
|
||||
上一版报告曾把「三种伪造头都不生效」推断为 `trust_proxy` 可能为 false —— 那是错的解读:
|
||||
无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 2026-10-26
|
||||
|
||||
## 修复 P1-3:CDN 权威客户端头未校验来源
|
||||
|
||||
用户确认:生产/测试 `trust_proxy = true`,回源是 **cloudflared Tunnel 回本机**(对端 127.0.0.1);
|
||||
境内 **EdgeOne 已取消**(实测期国内速度不理想),将来可能重新启用。
|
||||
|
||||
### 改动
|
||||
- `internal/shared/ip.go`
|
||||
- 新增 `headerSourceTrusted(remote)`:对端是回环 或 命中 `trusted_proxies` 才允许采信权威头。
|
||||
- `ResolveClientIP` 把 `CF-Connecting-IP` / `EO-Client-IP` 读取整体收进该判断;公网对端忽略。
|
||||
- 公网对端带权威头 → `sync.Once` 一条 WARN(`remote_addr` + `header_ip`),覆盖「伪造」与
|
||||
「CDN 直连回源忘登记网段」两种情形。
|
||||
- XFF / X-Real-IP 信任范围未变(仍只认 `trusted_proxies`,回环不隐含信任)。
|
||||
- `internal/shared/ip_test.go`:原 4 个用例按新契约调整;新增
|
||||
`RejectsForgedHeaderFromPublicPeer`(4 种伪造组合)`TrustedProxyHonorsCDNHeader`
|
||||
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `setTrustedProxies` 助手(还原包级状态)。
|
||||
- `conf/app.sample.ini`(CRLF,用脚本按行块改,未破坏行尾)+ `internal/conf/static.go` 注释同步。
|
||||
|
||||
### 验证
|
||||
- 变异验证:把 `headerSourceTrusted(remote)` 临时改成 `true`(旧逻辑)→ 回归用例 3 个子项失败;
|
||||
还原后全绿。确认测试咬得住。
|
||||
- `go build -overlay dev/overlay.json ./...` 通过;排除 `internal/storage`(overlay 桩缺
|
||||
`fitsWithin`/`aspect` 等辅助函数,属既有问题)后全包测试通过。
|
||||
|
||||
### 环境坑(值得记)
|
||||
- 本机 `go` 是 1.25.5,`go.mod` 要 1.27.3;`GOSUMDB=off` 会让工具链校验失败(
|
||||
"verifying module: checksum database disabled by GOSUMDB=off"),`GOTOOLCHAIN=local` 又降级到 1.25.5。
|
||||
解法:直接调缓存里的工具链
|
||||
`~/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,配 `GOTOOLCHAIN=local`。
|
||||
- Python 读 CRLF 文件要显式 `newline=''`,否则 universal newline 把 `\r\n` 归一成 `\n`,
|
||||
`split('\r\n')` 只会得到 1 行(踩过一次,误判文件被写坏)。
|
||||
|
||||
### 待办
|
||||
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(`to` 开放重定向)均未动。
|
||||
|
||||
## 修复 P1-1 / P1-2
|
||||
|
||||
### P1-1 外链图片(`internal/template/markdown.go`)
|
||||
- 新增 `safeImageSrc()`:`<img src>` 只允许站内相对路径(无协议部分)或主机命中自有图片出口
|
||||
(`image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url` 主机)。
|
||||
- 含反斜杠一律拒(`\` 在浏览器等价 `/`);`//` 先于「以 / 开头=站内」判断;
|
||||
`url.Parse` + `Hostname()` 比对 → 后缀 / userinfo 把戏失效。
|
||||
- `img` 无合法 src 时**整段丢弃**(只删 src 会留 `<img alt>` 变成破图)。
|
||||
- `a[href]` 仍走 `safeURL`(外链需点击,产品上要允许)。
|
||||
- 测试:`TestSafeImageSrc`(12 条拒绝)+ `TestMarkdownToHTML_ImageSource`(端到端);
|
||||
变异验证:`safeImageSrc` 直接 return true → 7 条拒绝用例失败。
|
||||
|
||||
### P1-2 密码(5 个文件)
|
||||
- `minlen:6` + `form.PasswordMinLen`;handler 里 `rejectTooShortPassword()` 二次校验(rune 计数);
|
||||
前端 `minlength=6 maxlength=30` + 提示文案「6–30 位,留空则关闭」;i18n 新增
|
||||
`form.profile.passwordLen` / `question.errPasswordLocked`(4 语言)。
|
||||
- 校验顺序:抽 `askPasswordDenied()`,从 `New()` 首行挪到**两个分支验证码之后**(私聊 + 普通提问各一次)。
|
||||
没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。
|
||||
- 失败锁定:新增 `shared.AttemptLimiter`(无后台 goroutine,惰性清理 + 键数上限);
|
||||
`pagePasswordGuard(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
|
||||
|
||||
### 端到端验收脚本
|
||||
`outputs/sec-audit-2026-10-25/verify_p1_fixes.py`(`--only p1-1,p1-2a,p1-2b,p1-2c`)。
|
||||
**在未部署的旧版本上四项全 FAIL,已实测** → 证明检测有效;部署后应全 PASS。
|
||||
|
||||
踩到的两个判定坑(写脚本时记下来):
|
||||
- 提问成功页是 **200**(不是 302),不能用状态码判断提交成功。
|
||||
- 受保护的箱是**在 `/_/<domain>` 内联渲染密码表单(200)**,不是跳转 `/password`;
|
||||
判断「有没有密码闸门」只能看页面里有没有 `action="/_/<domain>/password"`。
|
||||
- 箱主页与 `/api/v1/user/<domain>/questions` 都**不暴露题目 id**(无后台),
|
||||
要定位刚建的提问只能扫 `/_/<domain>/<id>`(脚本里 `question_ids_with_marker`)。
|
||||
@@ -1,92 +0,0 @@
|
||||
# 2026-10-27
|
||||
|
||||
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
|
||||
|
||||
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
|
||||
|
||||
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
|
||||
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
|
||||
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
|
||||
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
|
||||
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
|
||||
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
|
||||
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
|
||||
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
|
||||
|
||||
**验收结果(全 PASS)**:
|
||||
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
|
||||
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
|
||||
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
|
||||
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
|
||||
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
|
||||
|
||||
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
|
||||
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
|
||||
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
|
||||
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
|
||||
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
|
||||
|
||||
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
|
||||
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
|
||||
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
|
||||
|
||||
**产物**:
|
||||
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
|
||||
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
|
||||
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
|
||||
|
||||
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
|
||||
|
||||
---
|
||||
|
||||
## 第二轮:修完 P2/P3 + 全局复查
|
||||
|
||||
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
|
||||
|
||||
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
|
||||
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
|
||||
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
|
||||
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
|
||||
|
||||
**全局复查新发现 3 个**(详见 `topics/sec.md`):
|
||||
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
|
||||
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
|
||||
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
|
||||
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
|
||||
|
||||
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
|
||||
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
|
||||
|
||||
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
|
||||
|
||||
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
|
||||
|
||||
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
|
||||
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
|
||||
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
|
||||
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮:用户「按最优解决定」→ CSP 切 enforce + 字体落地 + 提交
|
||||
|
||||
**CSP 默认改 enforce**(`cspMode()` 空值 = enforce;写错值仍退 report)。依据不是猜:
|
||||
写了 `outputs/sec-audit-2026-10-25/csp_sweep.py` + `csp_sweep.cjs`(Playwright + 本机 Chrome,
|
||||
监听 `securitypolicyviolation`),report 模式先测一遍、enforce 再测一遍,**18 页面 + 2 图片端点零违规**。
|
||||
巡检器自己做了变异自检(页面里主动 `fetch("http://csp-selftest.example/x")` → 确实报 connect-src),
|
||||
否则「0 违规」可能只是监听器没挂上。
|
||||
**补的漏项**:`browser.sentry-cdn.com` —— header.html 里 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
|
||||
本地没配 DSN 所以巡检碰不到,线上一开就会静默失效;现在按 `conf.App.SentryDSN` 条件放行 + 单测双向断言。
|
||||
|
||||
**分享卡字体**:补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallback / uming)并压到
|
||||
Noto(CFF) 之前;`static/fonts` 同时按 CWD 和可执行文件目录找;`CheckShareCardFont()` 在 runWeb 里
|
||||
启动即探(命中 Info 带路径,缺失 WARN 给 apt 命令)。实测 `apt install fonts-wqy-zenhei` 后
|
||||
零配置即 `font=…wqy-zenhei.ttc`,`/share-card` 与单题分享卡从 500 → `200 image/jpeg`,
|
||||
中文/二维码正常(图存 `outputs/sec-audit-2026-10-25/sharecard-*.jpg`)。
|
||||
|
||||
**提交**:三个 commit(7802bb2 安全修复 / e807dea 字体 / 430c6ef 记忆)。**push 失败**:
|
||||
`git.shiroko.one` 的 https 存取凭据被拒(`Failed to authenticate user`),SSH 路(`id_ed25519_wsl`)
|
||||
也未被该仓库授权 —— 需要用户更新 token 后自己 push。
|
||||
|
||||
**全量单测**:带 `DB_TYPE=postgres DB_USER=tamabox DB_PASSWORD=… DB_HOST=127.0.0.1` 全绿;
|
||||
不带 DB 变量时一批 DB 门控用例会报 `Unknown database type: ""`(不是回归)。
|
||||
@@ -1,381 +0,0 @@
|
||||
# 2026-10-29
|
||||
|
||||
## 第二轮综合复查(非安全向)
|
||||
|
||||
用户要求「再确认还存在什么问题与风险点」+「从用户角度有什么值得改进和增加的功能」。
|
||||
前一轮是安全专项(P1/P2/P3 全修完),这轮换到**业务正确性 / 运维 / 前端体验 / 产品能力**四个面。
|
||||
|
||||
产出:`outputs/review-2026-10-29.md`(分级清单 + 复现方式 + 产品建议)。
|
||||
|
||||
### 实测复现的两条 P0
|
||||
|
||||
1. **私聊 50 条截断**:`GetByConversationID` 是 `Order("created_at ASC") + Limit(50)`,
|
||||
唯一调用方永远传 nil cursor;会话上限 200 → 第 51 条起**写库成功但永久不可见**。
|
||||
本地造 55 条实测:页面只渲染 #1–#50。这不是性能问题,是「数据在库里但用户看不到」。
|
||||
2. **访问日志泄露 token**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
|
||||
Logger 记 `RequestURI`(含 query)。实测日志出现 `path=/_/tblocal/4?t=SECRETTOKEN123`。
|
||||
`?t=` 是私密题钥匙、`?code=` 是改密凭证。**tracing 中间件早就为同一风险避开了 RequestURI
|
||||
并写了注释,框架默认 Logger 漏了。**
|
||||
|
||||
### 代码确认的 P1
|
||||
|
||||
- **无优雅关闭**:全仓 `signal.Notify`/`Shutdown` 零命中,SIGTERM 直接杀进程 → 每次重启掐断在途 POST。
|
||||
- **提问入库后图片失败不回滚**(`page.go:660` → `:680`),叠加 DuplicateGuard 5 分钟 → 用户以为没发出、5 分钟内不能重提。
|
||||
- **Redis 两种故障**:session 在 Redis 上宕机即全站 500(flamego session load 直接 panic);
|
||||
`cache.Cacher` **没有** `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退到 localhost:6379 → 没配 Redis 时找回密码静默坏掉。
|
||||
- **处置不覆盖投票**:提问链路 7 处 `ActiveLevel`,投票链路 0 处。L4 处置后 `/_/domain` 已 302 到 /restricted,
|
||||
但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
|
||||
|
||||
### 产品缺口(重要,别忘)
|
||||
|
||||
- **完全没有访问量数据**:`SiteAnalytics` 是全站聚合,无 per-user 维度,也没有 page view 表。
|
||||
箱主无法知道自己被看了多少次。
|
||||
- **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联;全仓 `localStorage` 零命中。
|
||||
→ 建议前端用 localStorage 记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
|
||||
- `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
|
||||
- 模板零 `application/ld+json`(SEO 富摘要缺失),无 RSS。
|
||||
- 图片原图直传(无前端压缩),配合 ReadTimeout 30s → 手机端 8MB 图必超时。
|
||||
|
||||
### 顺带确认的「不用动」
|
||||
|
||||
四语言字典 917 条完全对齐(`TestDictionaryComplete` PASS)、深色模式正文对比度 4.8–6.8:1 达标、
|
||||
gzip 实现质量高、后台移动端短标签方案做得细。
|
||||
|
||||
### 已修(同日,六项全部端到端验证)
|
||||
|
||||
| # | 改动 | 验证 |
|
||||
|---|---|---|
|
||||
| 1 | `GetByConversationID` 改 `id DESC` 取最新 + 内存反转,limit 取 `conf.Limits.ChatMaxMessages`;游标配 DESC | 55 条会话实测渲染 55 条(改前 50);单测 3 项 + 变异(改回 ASC 变红) |
|
||||
| 2 | 弃 `flamego.Classic()`,`flamego.New()` + 自写 `accessLogMiddleware`(只记 `URL.Path`;静态成功不记、4xx/5xx 记) | 日志 `msg=access path=/_/tblocal/4`,token 计数 0 |
|
||||
| 3 | `signal.NotifyContext` + `srv.Shutdown(15s)` + 关连接池 | 8 个并发分享图请求中 SIGTERM → 8/8 成功 |
|
||||
| 4 | 图片失败分支 `Questions.DeleteByID` 回滚 | 6MB 图 → 提问增量 0 且 `deleted_at` 非空;无图对照增量 1 |
|
||||
| 5 | `cache.Cacher` 加 `if conf.Redis.Addr != ""`(否则 MemoryIniter);启动 `PingRedis` fail-fast | 停 Redis 后启动立即失败并指向 Redis |
|
||||
| 6 | 投票 Page/Submit 加 L4、VotePublish 加 L2、探索页用 `ActiveLevels` 批量过滤 | L4 → `/vote` 302 到 /restricted;探索页该投票 0→2(撤销后);L2 下发布停 draft,撤销后 published |
|
||||
|
||||
新增 `vote.err.restrictedPublish` 四语言 key。回归:4/4、19/19、6/6、3/3,全量单测 + `go build ./...` 通过。
|
||||
|
||||
**踩到的两个坑**(值得记):
|
||||
- 消息加载的兜底写成 `limit < 50 → 50`,会把箱主显式配置的小上限抬大,测试直接测出这个语义错误。
|
||||
兜底只该处理**零值**(未初始化),不该替用户改配置。
|
||||
- Python 的 **bytes 字面量不支持 `\uXXXX`**(只有 str 支持),写字面量 `b'...\uff08'` 会原样写入
|
||||
`\uff08` 六个字符。CRLF 文件的字节级替换要用 `\xef\xbc\x88` 或 `.encode()`。
|
||||
|
||||
### 第二批(P2 + 高价值 P3,Docker 按用户要求跳过)
|
||||
|
||||
| # | 改动 | 验证 |
|
||||
|---|---|---|
|
||||
| 7 | ReadTimeout 30s→120s(8MB 图需 ≥68KB/s;ReadHeaderTimeout 仍 10s 防 slowloris) | 参数修正 |
|
||||
| 8 | 投票结果改 stale-while-revalidate:过期先返旧值 + 后台按投票去重重算;Submit 从「删缓存」改「标记过期」(新增 `cache.Expire` / `GetWithExpiry`) | 2 万条:冷启 0.211s → 过期首读 0.023s(9.2x) |
|
||||
| 9 | 三处幂等:`MarkDeletedByOwner` 条件更新+RowsAffected、`SetPinnedIfCurrent`、追问「上一条已回复」移进事务 | DB 测试 3 项全过 |
|
||||
| 10 | 私聊首条三写入合并为一个事务(`CreateWithFirstMessage`) | message_count 与实际消息数一致 |
|
||||
| 11 | `/healthz` + `/readyz`(DB ping 失败 503) | 停 PG 后 healthz 200 / readyz 503 |
|
||||
| 12 | 验证码失败改同请求渲染,不再丢正文 | 200 + 正文回填 + 不建提问 |
|
||||
| 13 | `tbBeginSubmit()` 抽出,reCAPTCHA 回调补防重复 | 静态验证(7 个模板受益) |
|
||||
| 14 | waline 按页加载、`loading="lazy"`、safe-area、三处硬编码接字典(+4 key×4 语言) | /login 无 waline;en 页面零中文残留 |
|
||||
| 15 | 5 处裸类型断言改带 ok | 编译 + 单测 |
|
||||
|
||||
新增 `verify_round3.py`(6 项)。第二批回归:6/6、4/4、19/19、6/6、3/3,全量单测 + build 通过。
|
||||
|
||||
**判据随实现更新**:`verify_p1_fixes.py` 的 p1-2b 原来用「是否 302」判断验证码先于密码,
|
||||
第 12 项改成 200 后判据失效 → 改为「200 且页面报『验证码错误』」。语义不变,判据跟着渲染方式走。
|
||||
|
||||
### 这次踩的第三个坑(重要,我自己造成的)
|
||||
|
||||
**Windows 上 `Path.write_text()` 默认把 `\n` 转成 `\r\n`**,用它改 LF 文件会让整个文件行尾翻车。
|
||||
- 症状:`gofmt -l` 突然把整文件标为未格式化。
|
||||
- 后果:`git diff --stat` 会显示整文件重写。
|
||||
- 正确写法:`path.write_text(s, encoding="utf-8", newline="\n")`,或用 bytes。
|
||||
- **排查手段**:`git diff --stat` 是判断行尾污染最快的信号 —— 真实改动应该只有几十行,
|
||||
整文件重写一定是行尾变了。恢复方式:`git show HEAD:<file>` 取其行尾,再统一替换回来。
|
||||
|
||||
另:本项目 `core.autocrlf=true`,仓库里存 LF、工作区 checkout 成 CRLF,
|
||||
所以「工作区看到 CRLF」不等于「仓库里是 CRLF」,判断基准要看 `git show`。
|
||||
|
||||
---
|
||||
|
||||
## 第三批(数据安全 + 产品缺口)
|
||||
|
||||
1. **删除投票 / 删除提问改成一个事务 + 真删(`Unscoped`)**。
|
||||
原来四步独立写入、前几步失败只 Warn → 中途失败留下「选项没了票还在」这类
|
||||
界面修不好的状态,重试时投票已不存在、只能报 not exist。
|
||||
更关键的是:所有模型都带 `dbutil.Model` 的软删除,原来全是软删 ——
|
||||
提交记录里的参与者文本、`voter_ip`、`voter_token`、提问里的 `FromIP`
|
||||
只是「界面上看不见」,库里永久留着。用户点删除的预期是删掉。
|
||||
2. **配额与内容同事务**:抽出 `incrementQuotaTx(tx, userID, feature, delta)`,
|
||||
提问 `Questions.Create`、私聊 `CreateWithFirstMessage`、投票提交
|
||||
`VoteSubmissions.Create`(新增 `QuotaUserID` 字段)都在自己的事务里 +1。
|
||||
原来 handler 里独立写入且失败只 Warn,`quota_count` 会永久偏低。
|
||||
3. **追问改回答不再重复发邮件**:`UpdateAnswerByID` 返回 `wasFirst`(事务内读旧值判定),
|
||||
handler 只在首次回答时发。原来改三遍措辞就发三封。
|
||||
4. `isQuestioner` 的 token 比较改 `subtle.ConstantTimeCompare`(与 Pager 中间件同口径);
|
||||
CSV 导出的**表头与选项列**补 `sanitizeCSVCell`(原来只有数据列走它)。
|
||||
5. 写路径功能开关改 **fail-closed**:新增 `db.FeatureStateOfStrict`,
|
||||
读配置失败时拒绝而不是回退默认「开启」。
|
||||
6. **真 404 + HTML 500**:新增 `internal/route/errorpage.go`,`f.NotFound` 不再 302 到首页,
|
||||
改 `writeErrorPage(ctx, 404, ...)`;`flamego.Recovery()` 换成 `recoveryMiddleware()`。
|
||||
7. **提问者找回自己的提问**:成功页写 localStorage,箱主页显示提示条,
|
||||
新增 `POST /api/v1/ask-status`(批量问「哪些已被回答」)。
|
||||
|
||||
### 这批踩到 / 学到的
|
||||
|
||||
- **flamego 的 `f.NotFound` 拿不到组级中间件**:`template.Templater` 与 `Contexter`
|
||||
(含 session、CSRF)都挂在 `f.Group("", ...)` 上,NotFound 不经过它们。
|
||||
所以在 NotFound 里注入 `template.Data` 会直接 panic:
|
||||
`unable to invoke the 8th handler: value not found for type template.Data`。
|
||||
错误页最终用**内联 HTML + i18n.T**(`pickErrorPageLang` 从 Accept-Language 猜语言),
|
||||
不依赖模板系统 —— 错误页本来是最后一道防线,不该依赖可能出错的链路。
|
||||
- **`gofmt` 会把注释里的列表规范化**:`// 1) ...` 会被改写成 `// 1. ...`。
|
||||
自己写的中文注释列表如果用了 `1) 2) 3)`,`gofmt -l` 会标为未格式化 —— 不是行尾问题。
|
||||
- **`Select(...)` 漏列 = 字段静默为空**:`ListByIDsAndUser` 一开始只选了
|
||||
id/token/answer/created_at,摘要永远是空串,接口 12 项里就这一项红。
|
||||
- **配额口径要按功能分别确认**:投票的定量收集是「累计收到的票数」(在提交成功时 +1),
|
||||
不是「创建了几个投票」。所以我一度在删除投票里加了 -1,那是错的 —— 删投票不代表票没发生过。
|
||||
- **CDP 验证纯前端功能的套路**(`verify_ask_history_ui.mjs`):
|
||||
`chrome --headless=new --remote-debugging-port=9222`(必须作为独立后台进程跑,
|
||||
跟在别的命令后面 `&` 会随 shell 退出被回收)→ node 用内置 `WebSocket` 连
|
||||
`/json/list` 里任一 target → `Runtime.evaluate` 注入 localStorage → 重新 navigate →
|
||||
断言 DOM → `Page.captureScreenshot`。注意 headless=new 的空标签页 `type` 常是 `other`,
|
||||
不能只找 `type === "page"`。
|
||||
- **控制台错误要区分来源**:页面里 R2 图片的 CORS 报错是既有问题,
|
||||
把它算进「本次改动失败」只会得到假阳性。判据要按 URL/关键字过滤。
|
||||
|
||||
### 待办(仍未做)
|
||||
|
||||
- AutoMigrate 无版本化(改列名会静默丢数据)
|
||||
- 无备份脚本(图片全在对象存储,只备份 DB 恢复后全是裂图)
|
||||
- 32 个 `<label>` 无 `for=`、语义标签对比度 1.9:1
|
||||
- 箱主数据面板(全站无 page view 数据)、Telegram/Webhook 通知、JSON-LD + RSS、图片前端压缩
|
||||
|
||||
## 箱主数据面板 + 图片前端压缩(本轮完成)
|
||||
|
||||
把「待办」里的两项落地,其余(AutoMigrate 版本化、备份脚本、label/对比度、Telegram/Webhook、JSON-LD)仍未做。
|
||||
|
||||
### 箱主数据面板(/user/insights)
|
||||
|
||||
- 新表 `box_views`(`internal/db/box_views.go`):一行 = 一访客×一天×一箱,唯一索引
|
||||
`(user_id, viewed_on, visitor)`,`views` 累加。**刻意不内嵌 `dbutil.Model`**——
|
||||
软删除 + 唯一索引会让被删行永久占槽(老坑)。
|
||||
- 访客标识 = `HMAC(日盐, IP)` 前 32 hex(`internal/insights/recorder.go`):同日可去重(UV 准)、
|
||||
跨日不可关联(隐私)。代价:近 7/30 天 UV = 各日 UV 之和,文案里已写明。
|
||||
- 异步记录器:buffered channel 512 + 2s flush,构造即启动 goroutine(避免 Close/Run 竞态),
|
||||
队列满即丢(统计尽力而为)。过滤:箱主本人、空 UA、爬虫 UA 子串、预取头(Sec-Purpose 等)。
|
||||
- 聚合层 `internal/db/box_analytics.go`:BoxTrend(提问/回答按日)、BoxHours(24h 分布)、
|
||||
BoxFunnel(已回答/已读未答/未读 + 回答率 + 24h 回复占比)。复用 analyticsDialectFor 的时区处理。
|
||||
- 展示层 `internal/insights/page.go`:30 天 PV/UV 双柱趋势(CSS 高度百分比)、时段分布、
|
||||
漏斗 + 回答率进度条、自动结论(`FormatInsights`)。**百分比整数值不带 .0**(formatPct)。
|
||||
- 打点:`route/question/page.go` 的 Pager 末尾(所有错误分支/密码页已 return 之后),
|
||||
`!ctx.IsLogged || ctx.User.ID != pageUser.ID` 才记。
|
||||
- 挂载:`/user/insights` 在 reqUserSignIn 组内;`pageScope` 加 `/user/insights → admin`。
|
||||
- 密钥复用 `server.xsrf_key`;`StartMaintenance` 每日清 400 天前的明细。
|
||||
- 测试:`internal/db/box_views_test.go`(4 项,含「同批同键先内存合并」——PG 的 ON CONFLICT
|
||||
不允许同条 INSERT 二次命中同一行)、`internal/insights/page_test.go`(pct/窗口切分/visitorID/爬虫过滤)。
|
||||
|
||||
### 图片前端压缩(templates/base/footer.html)
|
||||
|
||||
- 选图即压:canvas → WebP(长边 1600、质量 0.8,与服务端 GeneralEncodeSpec 对齐)。
|
||||
- 三条边界:GIF/SVG 跳过(canvas 会把 GIF 变静止首帧)、压完不更小退回原图(<85% 才采用)、
|
||||
透明 PNG 走 WebP(不支持 WebP 编码时退回 PNG 而非 JPEG,否则透明变黑)。
|
||||
- `createImageBitmap(..., {imageOrientation:'from-image'})` 摆正 EXIF,老浏览器回退不带选项。
|
||||
- submit capture 兜底:选图立刻提交时 `preventDefault` + `Promise.all(压缩)` + `form.submit()`,
|
||||
避免二次触发防重复。
|
||||
- 实测(CDP + 自写 WebSocket 客户端 `dev/_cdp.py`):4.6MB PNG → 11.7KB WebP(0.3%),
|
||||
小图原样保留、GIF 跳过,三断言全过。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **同步脚本把 WSL 的 `conf/app.ini` 删了**:Windows 侧用 `tar | wsl rsync --delete` 同步源码,
|
||||
目标机那份含 DB 密码/对象存储密钥的 app.ini 不在源目录里,被当多余文件删掉 → 服务重启起不来。
|
||||
修:`dev/sync-wsl.sh` 里 `rsync --exclude "conf/app.ini"`,模板放 `dev/wsl-app.ini`(已 gitignore)。
|
||||
- **配置丢了怎么找回**:进程内存 dump(`/proc/PID/mem` 扫描 `key = "value"` 模式)能捞出启动时
|
||||
加载的全部 ini 键值(salt/xsrf_key/上传密钥/DB DSN 都在),配回 `conf/app.ini` 即可。
|
||||
- **headless 里 canvas 编码大图超时是假象**:Windows Chrome 的 `--dump-dom` + `--virtual-time-budget`
|
||||
会把 3000×2000 噪声图压得解码超时,`#out` 永远停在 running。换 WSL 内 `apt install chromium`
|
||||
走 CDP 轮询(`Runtime.evaluate` 非 await 反复读结果)才拿到真值。
|
||||
- **自写 CDP 的 WebSocket 客户端必须处理 continuation 分片**:CDP 大事件(console/DOM 属性)会
|
||||
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
|
||||
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
|
||||
|
||||
|
||||
## 全盘复查(数据面板/图片压缩之后的第二轮)
|
||||
|
||||
用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、
|
||||
删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
1. **P1 提问真删不彻底**(`questions.go` DeleteByID 对主表 Unscoped,但追问/图片仍软删):
|
||||
- `follow_ups.go DeleteByQuestionID`、`upload_images.go DeleteByQuestionID` 改 Unscoped 真删。
|
||||
- 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
|
||||
- 新增 `internal/db/question_delete_hard_test.go`:用 Unscoped 计数断言删除后追问/图片/关联行
|
||||
物理上=0。**变异验证**:把追问改回软删,测试失败(expected 0 actual 1)。
|
||||
|
||||
2. **P1 BoxTrend 每日回答数按 created_at 分组**(应为 answered_at):`box_analytics.go` 加 answerDay
|
||||
表达式(PG 用 `to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...)`,MySQL 用 CONVERT_TZ),
|
||||
WHERE/GROUP/SELECT 三者口径一致。新增 `internal/db/box_analytics_test.go`:构造「40 天前创建、
|
||||
昨天回答」的提问,断言计入昨天。**变异验证**:改回 d.day 后测试失败。
|
||||
|
||||
3. **P2 ask-status domain 存在性可枚举**:`ErrUserNotExists` 分支原返回空数组、正常分支返回带元素数组,
|
||||
凭空/非空可枚举 domain。改为箱主不存在时也回显 `ok:false`(与「存在但 token 错」完全同形)。
|
||||
|
||||
4. **P2 私聊 SendMessage 写路径用 FeatureStateOf**(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。
|
||||
(此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)
|
||||
|
||||
5. **P2 图片压缩 submit 兜底可重复提交**:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到
|
||||
preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。
|
||||
|
||||
### 判定不修(子代理误报,或有意设计)
|
||||
|
||||
- **投票删除不回退配额**:子代理当 bug,但这是**有意为之**——投票配额口径是「累计收到的票数」,
|
||||
删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
|
||||
- **recomputeHeights 里 `d.UV > maxPV` 死代码**:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」
|
||||
恰好是设计意图,不算错。
|
||||
- **TopDays 死代码**:`BoxViewsStore.TopDays` 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾,
|
||||
但不改数据安全,留待后续。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **gofmt -l 把「缺末尾换行」也标为未格式化**:Edit 工具写文件时可能丢末尾换行符,`gofmt -l`
|
||||
会报 `\ No newline at end of file`。与行尾问题不同,补一个 `\n` 即可。
|
||||
- **struct 字段对齐**:手写 struct 时字段没对齐(`Questions int64` 少一个空格),gofmt 会标。
|
||||
用 `dev/gofmt_keep_crlf.py` 统一修。
|
||||
- **CSRF token 登录后会轮换**:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login
|
||||
拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的
|
||||
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
|
||||
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
|
||||
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
|
||||
|
||||
## 三项待办落地:AutoMigrate 版本化 + 备份脚本 + Telegram/Webhook 通知
|
||||
|
||||
用户「先按1-2-3的顺序做」,期间两次纠正 R2 存储结构认知。
|
||||
|
||||
### 1. AutoMigrate 版本化迁移(internal/db/migrations.go)
|
||||
|
||||
- 新增 `schema_migrations` 表(version 主键 + name + applied_at),`runMigrations(db)` 按版本升序
|
||||
执行未应用的迁移,每个迁移在事务里「跑 up + 记版本」。
|
||||
- 把 db.Init 里散落的 5 个幂等补丁收编为版本迁移 v1~v5:
|
||||
backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled /
|
||||
normalizeChatQuota / normalizeHarassmentSetting。收编后**只跑一次**(原来每次启动都跑)。
|
||||
- AutoMigrate 仍负责「加表/加列/加索引」(声明式、幂等、无数据丢失),版本迁移负责
|
||||
AutoMigrate 做不了的破坏性变更(改列名/删列)与一次性回填。两者分工,互不替代。
|
||||
- 迁移失败阻断启动(半途而废比静默跳过更危险)。
|
||||
- 测试:migrations_test.go(幂等跑两遍不重复 / 失败阻断后续 / 清单严格升序)。
|
||||
- 实测:真实库首次启动建表 + v1~v5 落账本,二次重启账本仍 5 行(幂等)。
|
||||
|
||||
### 2. 备份脚本(internal/cmd/backup.go,TamaBox backup)
|
||||
|
||||
- `--output <dir>` 输出;`--db-only` / `--images-only` 分开关。
|
||||
- DB:调 pg_dump(PGPASSWORD 环境变量传密码避免 ps 泄漏),输出 db-<ts>.sql(标准恢复格式)。
|
||||
- 图片:从 R2 下载 tamabox 站全部图片 + 写 images-manifest.json(key→size→md5)。
|
||||
- 两个 CLI 入口(根 tamabox.go + cmd/tamabox.go)都注册了 Backup。
|
||||
- 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 30 秒下 15 个文件、
|
||||
路径保持 tamabox/ 结构、大小与 List 一致。
|
||||
|
||||
### 3. Telegram/Webhook 通知(internal/notify/notify.go)
|
||||
|
||||
- 站级 Webhook([notify] webhook_url):新提问/新回答/新追问/新私聊/新投票回复统一 POST JSON。
|
||||
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id 字段):新提问/新投票点对点推送。
|
||||
- 通知是「尽力而为」旁路:任一通道失败只记日志不抛错,不影响业务主流程;未配置则静默跳过。
|
||||
- 6 处业务触发点接入 notify:page.go(新提问+新私聊)、question.go(新回答)、
|
||||
follow_up.go(新追问+追问回答)、vote.go(新投票)。私聊消息保留仅邮件(已有节流,避免刷屏)。
|
||||
- UI:profile 加 telegram_chat_id 输入框 + 四语言字典(admin.notifyTelegram* 3 条)。
|
||||
- 测试:notify_test.go(webhook JSON 结构 / 未配置跳过)。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **R2 存储结构(用户两次纠正,重要!)**:图片实际存在 Cloudflare R2,CDN URL 形如
|
||||
`https://r2.337845818.best/tamabox/2025/12/1767082504316655685.jpg`(bucket=tamabox,
|
||||
key=年月/时间戳.扩展名,**无 picture/ 前缀**)。但 R2 API 侧(ListObjectsV2)返回的 key
|
||||
**带 bucket 名作前缀**(`tamabox/2025/12/...jpg`)——这是 path-style bucket 的 R2 行为。
|
||||
- 关键:`storage.GetS3Client` 的 endpoint 配置(image_endpoint 带 `/tamabox` 后缀 +
|
||||
Bucket 参数 tamabox)对 GetObject/PutObject 恰好可用(aws-sdk 拼出 /tamabox/tamabox/<key>,
|
||||
R2 容错),但 **ListObjectsV2 会拼成 /tamabox/tamabox?list-type=2 → 404 NoSuchKey**。
|
||||
- 备份脚本的正确姿势:剥离 endpoint 的 `/tamabox` 后缀 + UsePathStyle=true + Bucket=tamabox,
|
||||
List prefix="tamabox/",GetObject 直接用 List 返回的完整 key(含 tamabox/ 前缀)。
|
||||
- 同一 R2 account(0c99...)下还有 blog/ 前缀的另一个站点内容(共享 account)。
|
||||
- **代码的 PictureKeyPrefix="picture/" 与线上不一致**:mainline 代码上传 key 用 `picture/` 前缀,
|
||||
但线上历史图片 key 是 `tamabox/` 前缀(即实际 key 无前缀、靠 bucket 隔离)。这是待澄清的
|
||||
「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。
|
||||
- **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go):
|
||||
老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。
|
||||
|
||||
## R2 存储结构最终定论(用户贴出生产 app.ini [upload] 段后)
|
||||
|
||||
生产 app.ini 关键段:
|
||||
```
|
||||
[upload]
|
||||
default_avatar = "https://r2.337845818.best/tamabox/tamabox_low.png"
|
||||
default_qrbackgroundimage = "https://r2.337845818.best/tamabox/picture/2024/01/01.jpg"
|
||||
image_endpoint = "https://<acct>.r2.cloudflarestorage.com/tamabox" # 注意带 /tamabox 后缀
|
||||
image_bucket = "tamabox"
|
||||
image_bucket_cdn_host = "r2.337845818.best/tamabox"
|
||||
```
|
||||
|
||||
实测铁证(GetObject 精确验证):
|
||||
- 生产配置(endpoint 带 /tamabox + Bucket=tamabox + Key)下,`key=2025/12/1767082504316655685.jpg`
|
||||
返回 OK(187274),而 `key=picture/2024/01/01.jpg` 返回 404。
|
||||
- **结论:R2 bucket tamabox 的真实 key = `YYYY/MM/<17位雪花ID>.<原扩展名>`,无 picture/ 前缀、
|
||||
无 tamabox/ 前缀。**
|
||||
- 代码 `PictureKeyPrefix="picture/"` + `randstr.Hex(15)` 生成的 key 在 R2 里**根本不存在**。
|
||||
- `default_qrbackgroundimage` 里的 `picture/2024/01/01.jpg` 是**手动上传的站级静态资源**(孤立的),
|
||||
不是代码生成的,且该 key 实测 404(可能已失效)。
|
||||
|
||||
**重大结论:生产部署的存储层代码 ≠ mainline 仓库的存储层。**
|
||||
mainline 的 `picture/YYYY/MM/DD/<15hex>`(无扩展名)是一套**从未上线、与生产脱节**的实现;
|
||||
生产真实用的是 `YYYY/MM/<雪花ID>.<原扩展名>`(无前缀)。差异太大不可能是小改动,
|
||||
是存储层被整体替换过但改动没合回 mainline。
|
||||
|
||||
对备份脚本的影响:
|
||||
- 备份脚本用「无后缀 endpoint + pathStyle + Bucket=tamabox + prefix=tamabox/」List,
|
||||
返回的 key 带 tamabox/ 前缀(pathStyle 的 R2 行为),用同一 key GetObject 下载是自洽的、
|
||||
能下全所有图片(含 picture/ 之外的真实 key)。
|
||||
- 但落盘路径带 tamabox/ 前缀,与「真实 key 无前缀」在语义上不一致——恢复回传时需注意。
|
||||
- 待用户决策:是否把 mainline 的 PictureKeyPrefix 和 key 生成逻辑改回生产真实格式
|
||||
(`YYYY/MM/<雪花ID>.<ext>`),还是维持现状。这是存储层正确性问题,未动。
|
||||
|
||||
## 存储层 key 口径对齐生产(已落地)
|
||||
|
||||
用户确认「生产就是按 mainline 在 10-25 前最后一次修改部署的,有调整 git 查得到」,并拍板
|
||||
「先备份当前源码,再按生产口径统一代码」。
|
||||
|
||||
### 根因最终定论(纠正之前「雪花ID」的说法)
|
||||
|
||||
- 生产 URL 里 `1767082504316655685` 不是雪花 ID,而是 **UnixNano 纳秒时间戳**(19 位):
|
||||
`datetime.fromtimestamp(n/1e9)` = 2025-12-30 16:15:04,与路径 `2025/12` 完全吻合。
|
||||
- **生产口径 key = `YYYY/MM/<UnixNano>.<原扩展名>`**(无前缀、无 day 层)。
|
||||
- 铁证:`route/question/page.go:uploadImage`(提问配图入口)**早就是这个格式**:
|
||||
`uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)`。
|
||||
脱节的只有 `internal/storage` 包里三个函数(UploadAvatarToS3 / UploadBackgroundToS3 /
|
||||
UploadPictureToS3 及 UploadPictureToOSS),它们还在用老的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`。
|
||||
- git 历史是**浅快照**:`PictureKeyPrefix` 只在 `e3e59e7 "chore: baseline snapshot"`(2026-09-10,
|
||||
却是「最早提交」)出现一次,之前之后都没改过 —— 完整历史在快照之外,10-25 前的改动追溯不到。
|
||||
|
||||
### 改动(internal/storage/)
|
||||
|
||||
1. `picture.go`:`PictureKeyPrefix` 改 `""`;新增三个辅助函数:
|
||||
- `uploadKey(ext)` → `YYYY/MM/<UnixNano><ext>`(唯一 key 生成点)
|
||||
- `extFromContentType(contentType)` → MIME→扩展名映射(保证 key 扩展名与 ContentType 严格一致,
|
||||
避免「.jpg 实为 webp」错配)
|
||||
- `originalExt(filename)` → 取原扩展名并小写(OSS 原图直传通道用)
|
||||
2. `s3.go`:key 生成改 `uploadKey(extFromContentType(contentType))`;删除 day 变量与 randstr 依赖。
|
||||
注意转换成功用 `.webp`(contentType=image/webp),回退原图用原格式扩展名(contentType=image/jpeg→.jpg)。
|
||||
3. `oss.go`:`UploadPictureToOSS` 的 `_ *multipart.FileHeader` 改 `fileHeader`(取原扩展名),
|
||||
原样上传不转 webp,key 用 `uploadKey(originalExt(...))`。
|
||||
4. 新增 `picture_test.go`:锁定 key 格式正则 `^\d{4}/\d{2}/\d{16,19}\.jpg$` + ext 映射 + originalExt。
|
||||
|
||||
### 验证
|
||||
|
||||
- gofmt 通过;`go build ./...` 通过;storage 全量测试 + 新增测试 PASS。
|
||||
- 行尾:三个文件都是 LF(工作区 LF),Edit 未破坏(HEAD 对比行数变化正常,非整文件重写)。
|
||||
|
||||
### 踩坑(本轮新)
|
||||
|
||||
- **go 版本假象**:`go version` 报 1.27.3(GOROOT 指向 toolchain 目录),但 `GOTOOLCHAIN=local`
|
||||
时暴露真实工具链是 **1.25.5**;go.mod 要求 1.27.3。正确编译姿势:直接调已下载的 toolchain 二进制
|
||||
`$HOME/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,
|
||||
或去掉 GOSUMDB=off 让 toolchain 校验通过。用 `GOTOOLCHAIN=local` + 系统 go 会报
|
||||
`go.mod requires go >= 1.27.3 (running go 1.25.5)`。
|
||||
- **备份目录别放 .go 文件**:把 storage 源码备份到 `outputs/backup-*/` 后 `go build ./...`
|
||||
会把备份当 Go 包编译(undefined: announcementConfig 等)。备份的 .go 文件要改 `.go.bak` 后缀,
|
||||
或放到仓库外。
|
||||
- **fmt 的 `%s` 对 int64**:`fmt.Sprintf` 用 `%s` 拼 int64 能编译但 `go vet`(测试时自动跑)报
|
||||
`%s has arg of wrong type int64`,且语义模糊。纳秒时间戳必须用 `%d`。
|
||||
@@ -1,106 +0,0 @@
|
||||
# 2026-10-30
|
||||
|
||||
## 蜜罐周期安全汇总(周报/月报 + 地图 + 邮件)
|
||||
|
||||
用户需求:以周/月为单位汇总安全状况,把嗅探 IP 通过**公共服务**查地理位置,汇总到**地图**,
|
||||
发到邮箱。起因是他发现线上有人隔一段时间换不同 IP 探测蜜罐。
|
||||
|
||||
### 关键认知:ipinfo 与 geoip 是两个维度(用户亲自纠正)
|
||||
|
||||
用户反馈「ipinfo 只能查机房,肉鸡全显示『未识别(普通网络)』」。原因是
|
||||
`internal/security/ipinfo` 走 `projectdiscovery/cdncheck` 的离线 ASN 指纹,只认云厂商/CDN/WAF。
|
||||
它答的是「**是不是代理**」,不是「**在哪儿**」。肉鸡(家宽、企业宽带、移动网络)必然是后者,
|
||||
所以安全汇总必须另建一套真正的 IP 地理库 → 新包 `internal/security/geoip`。
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `internal/security/geoip/geoip.go` — ip-api 批量打底(100/批、1.4s 节流)+ 腾讯精修国内
|
||||
(只精修前 200 个,一 IP 一请求)。`Result{Geo, Unresolved, Requests, Failures}` 三态。
|
||||
- `internal/security/report/{period,aggregate,mapdraw,html,geo,countryname}.go`
|
||||
- `internal/db/{ipgeo,security_report}.go` — `ip_geo_cache`(主键 = `LimitKey`)、
|
||||
`security_report_logs`(`period_key` 唯一索引保证幂等)
|
||||
- `internal/cmd/report.go` — `honeypot report --period week|month` + 30 分钟一轮的调度器
|
||||
- `templates/mail/security-report.html`
|
||||
|
||||
### 踩过的坑(都要记住)
|
||||
|
||||
1. **ip-api 免费版很慢**:22 个 IP 实测 4–10 秒,且同一批重复请求更慢。最初设 15s 超时 →
|
||||
第一次真实数据就整批超时、定位率 0%。现在 `defaultTimeout = 45s` + 重试一次。
|
||||
2. **缓存毒化**:早先把所有 miss 都写缓存并 `Failed=true`(TTL 3 天)→ 一次网络抖动
|
||||
等于把整批 IP 钉成「查不到」三天,报告定位率莫名变 0 且无任何线索。
|
||||
现在只有服务**明确回答**没数据才允许写失败缓存;请求失败绝不写。
|
||||
3. **`addrs` 不能从 map 取顺序**:腾讯精修只取前 N 个,若按 map 迭代(随机)取,
|
||||
每次运行精修到的 IP 都不同,配额花在长尾上。必须按 `keys` 原始顺序(= 命中数降序)建。
|
||||
4. **地图标签会叠出假数字**:同一都市圈两个城市坐标只差 0.01°,标签精确重叠,
|
||||
「AU 55」+「AU 30」叠成了看起来像 **AU 305** 的字符串 —— 读图的人会当结论。
|
||||
已加 `labelPacker`(不重叠才画,右侧放不下换左侧)。
|
||||
5. **不能无脑把腾讯结果的国家码写成 CN**:`provider=tencent` 时海外 IP 也走这条路。
|
||||
现在从 `ad_info.nation` 精确判「中国」才给 CN。
|
||||
|
||||
### 合规(地图)
|
||||
|
||||
自绘底图**零国界**,只画经纬网 + 气泡 + 度数刻度;真实底图交给腾讯地图(配 key 才有,
|
||||
未配给申请指引)。代码里不预置任何 key,测试里有禁词断言(googleapis/mapbox/osm/bing)。
|
||||
报告里 HK/MO/TW 统一显示为「中国香港 / 中国澳门 / 中国台湾」(`countryname.go`),
|
||||
且常见国家改用中文名(报告是给中文读者看的)。
|
||||
|
||||
### 端到端验证(WSL Debian + PG 17.11)
|
||||
|
||||
854 条仿真数据 / 22 个来源:首跑 36s(真实 ip-api)、次跑 1.3s(缓存命中),
|
||||
定位覆盖 22/22、14 个国家/地区、未能定位 0 次。
|
||||
|
||||
### 顺手修的(别人改动留下的)
|
||||
|
||||
- `internal/route/honeypot_test.go` 的 `captureStore` 缺 3 个新方法 → 整个 route 包编译不过
|
||||
- `templates/render_test.go` 的 `mockLoggedUser` 缺 `TelegramChatID` → 3 个渲染测试失败
|
||||
|
||||
## 蜜罐邮件提醒功能审阅(用户要求,重点找 bug 与改进点)
|
||||
|
||||
审阅报告:`outputs/review-honeypot-mail-2026-10-30.md`。基线测试全绿(mail + route 蜜罐相关)。
|
||||
|
||||
### 结论分级
|
||||
|
||||
- **P1(应修)`autoban.alerted` 去重口径不一致**:`map[string]bool` 永不过期,且 `cleanup()`
|
||||
从不清理它 → 同一 IP 的告警**一生只发一次**;而蜜罐侧 `dedup` 用 24h TTL。两条链路口径不同,
|
||||
长期盯防同一来源会漏掉后续攻击活动。建议改成带时间戳的 map + cleanup 按窗口清理。
|
||||
- **P2(建议修)蜜罐告警空收件人不短路**:`route/honeypot.go:alert()` 没有
|
||||
`SecurityAlertEmail == ""` 前置判断(autoban 侧有 `alertEmail != ""`)。蜜罐开着但没配收件人时,
|
||||
每次凭据提交都走一遍无效 SMTP 拨号 + 留 Warn 日志,与「未配置静默跳过」预期不符。
|
||||
- **P2(边界)邮件明文密码**:`SendHoneypotAlertMail` 把明文 password 渲染进 HTML。代码已意识到
|
||||
是敏感物。建议维持明文(功能价值=运维立刻拿到字典)+ 强化「收件人必须是个人邮箱、禁群组」的
|
||||
配置警示,而非一刀切掩码。
|
||||
|
||||
### 审阅中确认「已做对、无需改」的点(值得记住的设计)
|
||||
|
||||
- 凭据**类型层面**不入库:`db.HoneypotHit` 结构体没有 Username/Password 字段,编译期保证;
|
||||
明文只落 0600 按天 JSONL。
|
||||
- 蜜罐独立于 `enable_auto_ban`(`RecordHoneypot` 只看 `HoneypotEnabled`)。
|
||||
- 纯 GET 探测不立即封(留「GET 表单→POST 提交」的咬钩机会)。
|
||||
- 精确 IP 与 /64 封禁键分离;body 只读一次先提取凭据再判封禁;`clipRunes` 按 rune 截断防非法 UTF-8。
|
||||
|
||||
### 三项已修(commit 036156b)
|
||||
|
||||
1. **P1 `autoban.alerted` 去重口径**:`map[string]bool` → `map[string]time.Time`,新增
|
||||
`alertCooldown = 24h` 常量 + `alertedRecently()` 辅助 + `cleanup()` 清理过期项。
|
||||
与蜜罐侧 `alertDedupTTL=24h` 对齐。新增 `TestAlertCooldownExpires`(回拨 alerted 时间戳
|
||||
模拟冷却期已过,断言能再次告警)。
|
||||
2. **P2 空收件人短路**:`route.go` 注入 `sendAlert` 前判 `SecurityAlertEmail` 空值,空则置 nil,
|
||||
`alert()` 的 `h.sendAlert == nil` 自然短路。(autoban 侧本就有 `alertEmail != ""` 判断,无需改。)
|
||||
3. **P2 明文密码警示**:`conf/static.go` 注释 + `app.sample.ini` 注释强化「蜜罐邮件含明文密码,
|
||||
收件人必须个人邮箱、禁群组/分发列表」。维持明文不改掩码(功能价值=运维立刻拿到字典)。
|
||||
|
||||
验证:autoban/route/mail/conf 测试全绿,`go build ./...` 通过,gofmt 干净,行尾未破坏(LF)。
|
||||
|
||||
## 更新日志文档 + 首页间距修复
|
||||
|
||||
1. **更新日志**:`outputs/changelog-2026-10-30.md`,站长口吻、口语化,只写用户可感知的功能
|
||||
(数据面板/Telegram 通知/找回提问/图片压缩/404 页/验证码不丢正文/私聊完整记录)。
|
||||
蜜罐、备份、迁移等纯后台内容刻意不写。注意:Telegram 条目依赖生产配好 [notify] 的 bot token。
|
||||
2. **生产配置答疑**:蜜罐子项全有 sanitizeHoneypot 兜底,无需补;真正要显式加的是
|
||||
honeypot_cred_dir、报告配置段(report_email 留空回退 security_alert_email)、[notify] 可选段。
|
||||
生产 ban_window_seconds=10 比默认 60 短,已提示用户确认是否有意。
|
||||
3. **首页间距修复(ea9ba3e)**:`templates/home.html` 里 tb-grid 之后的三张独立卡片
|
||||
(域名迁移/一点提醒/近期更新)彼此零间距挤成一团。修法:`.tb-grid ~ .uk-card { margin-top: 16px }`
|
||||
(模板内联 <style>,不碰 base.css,无需 bump StaticVersion)。
|
||||
截图验证:headless=old + file:// 预览页,亮暗两主题间距均正常。
|
||||
**坑:headless Chrome 的 --screenshot 相对路径写不进**(Failed to write file),必须用绝对路径。
|
||||
@@ -1,74 +0,0 @@
|
||||
# 主题生成器 `dev/theme-builder/`(长版明细)
|
||||
|
||||
## 主题生成器 `dev/theme-builder/`
|
||||
|
||||
- `serve.py` 起静态服务(8777),iframe 加载 `outputs/preview-*.html`。**file:// 下父页读不到 iframe DOM**,必须走 HTTP。
|
||||
8777 常由用户自己起着,改完直接刷新。
|
||||
- 单文件 `index.html`(约 2900 行)。图片处理**非破坏**:`orig` 存原始字节 + `fx` 存参数,每次从 orig 重算;
|
||||
参数没变则跳过重编码。画质(`S.export.quality/maxSide`)同理 —— 改完 `rebakeAll()` 从 orig 重来,不叠加压缩。
|
||||
- **尺寸单位** 4 档:`fit`(按视口高,人物)/ `fitw`(按视口宽,横幅)/ `px` / `vh`。
|
||||
`layerSize()` 出 CSS、`unitGeom()` 出预览框,**两处必须共用同一函数**,否则「框没超、图已裁」。
|
||||
⚠️ 下拉的 value 用 `l.unit`,不能用 `unitOf(l)`(后者把 fit 归一成 vh → 选了 fit 却显示 vh)。
|
||||
- 溢出检测 `layerMetrics(l, a, VW, VH)`:算 left/top/overL/R/T/B,编辑器里显示「显示 131×420,完整可见」或
|
||||
「⚠ 左右各 88px 越界」,并给「按当前视口修好」按钮(切 px + 算刚好放下的尺寸)。导出前 `overflowReport()` 再扫一遍。
|
||||
- 视口预设**带高度**(1512×900 / 1280×800 / 1024×768 / 820×1180 / 390×844):裁切几乎全由高度决定,
|
||||
只改宽度模拟不出来。`fitStage()` 按预设高渲染 iframe。
|
||||
- 窄屏:全局 `hideDeco`(急停)> 每层 `mob`(inherit/scale/hide)> 尺寸单位(fit/fitw 自己就缩)。
|
||||
⚠️ **`hideDeco` 会把 @media 里 10 槽全写 none** —— 桌面完全正常、手机上素材全没了,
|
||||
这是「素材在窄屏消失」的头号来源(test01 就是这么中招的)。UI 已在勾选时给橙色警告、
|
||||
图层行标「窄屏隐藏」、加层/导出再提醒;`?narrow=hide` 可一键复现。
|
||||
⚠️ `@media` 里**每个独立配置的 scope 都要写一条** `[data-page-scope="x"]`:页面覆盖块特异性 (0,2,1),
|
||||
只写 `html[data-theme-style]`(0,1,1) 的话独立配置页在手机上完全不生效。
|
||||
⚠️ 竖版图只替换**本来有背景图**的页面(`cfg.bg` 非空),否则等于给纯色页塞了张图。
|
||||
- ⚠️ **派生文字色必须走对比度下限,不能按固定比例混**:`paletteVars` 里
|
||||
`text-secondary = mixToContrast(text, backdrops, 5.5)`、`text-muted = 4.5`(WCAG AA)。
|
||||
旧的 `mix(text,bg,0.55)` 在浅底只有 2.9:1 —— 每导一次主题就把 base.css 的 AA 修复覆盖回去。
|
||||
- 半透明卡片(`--tb-surface: rgba(255,255,255,0.86)`)必须 `flatten()` 压到底色再算,否则按纯白算偏低;
|
||||
- 参考背景取「页面底色 + 压平后的卡片」两者更不利的那个;
|
||||
- 手填正文本身不达标时**原样返回**(宁可不够淡,不能产出看不见的字)。
|
||||
- 配色面板有读数(正文/次要/淡文字/主色),<4.5 变橙;默认 primary 已压到 `#3d6fac`(4.63)。
|
||||
- 还要导出 **`--tb-on-primary`**(白字 `#ffffff` / 深字 `#0b1016` 里挑对比度更高的):
|
||||
深色模式主色偏亮,白字只有 2.2~3.1:1。主色卡中间调时两种字都不够 → 面板橙色提示 +
|
||||
`?dump=palette` 的 `warnings`,**不判失败**(是主色要调,不是工具挑错)。
|
||||
- 自检钩子:`?dump=css|zip|validate|install|fx|fxpipe|fxkey|layers|fit|quality|palette`,`?demo=1&fresh=1&rows=N&scope=&loadurl=`
|
||||
(`?demo=1` 已含 fit 层 + 竖版背景,`@media` 才会生成)。用法:
|
||||
`chrome --headless=old --virtual-time-budget=12000 --dump-dom "http://127.0.0.1:8777/dev/theme-builder/index.html?demo=1&fresh=1&dump=fit"`
|
||||
抓 `<pre id="dump">`(正则要带属性:`<pre id="dump"[^>]*>`)。
|
||||
- ⚠️ **canvas `toDataURL('image/webp', 1)` 不能用**:Skia 在 q=1 切无损,输出反常(噪声图 q=1 只 52KB,
|
||||
q=0.95 是 440KB)。`canvasAsset` 夹到 0.95,滑块上限也是 0.95。
|
||||
- ⚠️ 平涂/线稿降 q 可能**变大**(有损引入噪点)。压体积先砍长边,再降 q。缩小走 `imageSmoothingQuality='high'`。
|
||||
- ⚠️ 面板只有 ~240px:`input[type=range]` 有固有宽度,必须 `flex:1 1 0;width:0;min-width:0`,
|
||||
且数值放进 label 里(第三个小 flex 项会被顶出可视区)。
|
||||
- `?dump=css` 的产物要过 Go 侧校验:临时存成 `static/css/themes/<非下划线名>.css` 再 `go test ./internal/theme`,
|
||||
跑完删掉。
|
||||
- 导出面板「素材前缀」`S.assetBase`:空 = `/static/images/themes/<slug>/`;填了 = CSS 写绝对 URL
|
||||
(R2/OSS)。**图片地址只能由 `urlOf()` 决定** —— `layerCSS` 曾复制过一份拼接,改一处会出现
|
||||
「背景走 CDN、装饰还指本地」。文档见 `docs/theme-assets-cdn.md`。
|
||||
- **素材托管(2026-10-21)**:面板两选一 `S.host.mode = local|s3`,字段
|
||||
endpoint/region/bucket/prefix/pathStyle/publicBase 进工程,**accessKey/secretKey 只进
|
||||
sessionStorage**(`SEC_SESSION_KEY`),绝不进草稿/工程 json/导出 zip(有断言)。
|
||||
`publicBaseOf()` 手填优先,空则按 endpoint 推导(path 风格 `<ep>/<桶>/<前缀>`)——
|
||||
对 R2 这个地址不可匿名读,测试的「匿名可读」只给 warn 不拦。
|
||||
- **上传走 `serve.py` 代理**,浏览器不直连对象存储:不用配 CORS,密钥不发给第三方。
|
||||
端点 `/api/s3/ping`(探测能力,`python -m http.server` 没有 → 按钮禁用)、
|
||||
`/api/s3/test`、`/api/s3/upload`。上传集合与导出共用 `usedAssetIds()`。
|
||||
- ⚠️ **`usedAssetIds()` 返回 Set**:`!ids.length` 恒真、`ids.map` 不存在 → 上传永远报
|
||||
「还没有素材」。用 `[...usedAssetIds()]` 展开。
|
||||
- ⚠️ **SigV4 手写,必须对拍**:`dev/theme-builder/check_sigv4.py` 调
|
||||
`dev/sigv4ref`(aws-sdk-go-v2 的 `signer.SignHTTP`)逐字节比。6/6 一致才可信。
|
||||
- 只用 ASCII 用例:裸 `SignHTTP` 默认**二次转义** URI,而 S3 client 内部把
|
||||
`DisableURIPathEscaping` 设成 true(单次编码)。我们跟随 S3 client / AWS 文档 RFC3986。
|
||||
- canonical headers 三件套 `host;x-amz-content-sha256;x-amz-date`,与 SDK 相同;
|
||||
CanonicalHeaders 自身以 `\n` 结尾,故其后与 SignedHeaders 之间有一个空行(规范如此)。
|
||||
- ⚠️ **HEAD 桶失败拿不到 `<Code>`**(HEAD 响应无 body)→ 只能按状态码归因
|
||||
(403→AccessDenied / 404→NoSuchBucket / 301|307→PermanentRedirect)。想拿真错误码要靠 PUT。
|
||||
- ⚠️ **Windows 上 8777 可能被上一个进程继续占着**(SO_REUSEADDR 让新进程 bind 成功但抢不到
|
||||
accept)。症状:`/api/s3/ping` 返回旧版 404,而代码明明改了。用
|
||||
`Get-NetTCPConnection -LocalPort 8777` 查 PID,或换端口验证。
|
||||
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
|
||||
`python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
|
||||
`python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。
|
||||
- 顶栏面板 `S.navbar={mode,alpha,blur}`:solid/glass/clear 三档,基色仍取「配色 → 顶栏」,
|
||||
`navbarValue()` 统一算;glass 额外写 `.uk-navbar-container:not(.uk-navbar-transparent){backdrop-filter}`。
|
||||
**clear 只给预览**,`validate()` 与 `TestNavbarNotFullyTransparent` 都拦。
|
||||
自检 `?dump=navbar`。
|
||||
@@ -1,7 +0,0 @@
|
||||
# 协作(长版明细)
|
||||
|
||||
## 协作
|
||||
|
||||
- 结论分级 P0/P1/P2;给建议前先根因取证 + 实测复现,别只静态推断。
|
||||
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
|
||||
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
|
||||
@@ -1,15 +0,0 @@
|
||||
# 行尾 / 模板 / embed(长版明细)
|
||||
|
||||
## 行尾 / 模板 / embed
|
||||
|
||||
- 行尾混合不可推断(`internal/db/*`、`mail.go`、`route/user/profile.go` 是 LF;`internal/route/route.go`、
|
||||
`users.go`、`i18n/dict_*.go`、`route/question/*.go`、`base.css`、`dev/theme-builder/*` 是 CRLF)。
|
||||
改前用 Python 数 `b'\r\n'`;`gofmt -l` 会把 CRLF 误报为未格式化 → 走 `dev/gofmt_keep_crlf.py`。
|
||||
Edit 工具会保留原文件行尾,可放心用;新增整份文件用 Write 时要自己补 CRLF。
|
||||
- 往 gofmt 对齐组中间插键会污染 diff,新键插组末;注释别用 `''`(gofmt 改成 `”`)。
|
||||
- ⚠️ **`templates/embed.go` 是显式清单**:新增**顶层**模板必须加进 `//go:embed`,否则运行时
|
||||
`html/template: "xxx" is undefined` → 500,编译期无提示。`static/fs.go` 同理。
|
||||
- ⚠️ **`//go:embed` 的 `*` 不匹配 `_` / `.` 开头的文件**:`static/css/themes/_scaffold.css` 与
|
||||
临时建的 `_gentest.css` **都不进 embed**,`TestDecoSlotCount` 扫不到它们(表现是"校验了但数字没变")。
|
||||
验证生成的主题 CSS 要临时改成非下划线文件名。
|
||||
- `templates/user/moderation-strictness.html` 只用 shell/python 改(Edit/Write 会注入 `data-page-node-id`)。
|
||||
@@ -1,9 +0,0 @@
|
||||
# GORM(长版明细)
|
||||
|
||||
## GORM
|
||||
|
||||
- 默认值必须带引号;`default:` 会把零值替换,空串有语义的字段别加。
|
||||
- ⚠️ 事务里复用同一 `tx` 会串条件,每句 `tx.WithContext(ctx).<链式>`。
|
||||
- ⚠️ 软删除 + 唯一索引 = 槽位永久占用:停用(可恢复)= 物理删内容 + 软删用户行,注册侧 `Unscoped` 给
|
||||
`ErrDeactivatedEmail/Domain`;封禁 = 物理删用户行,邮箱释放。级联删除一律 `Unscoped()`。
|
||||
- ⚠️ GORM 链式对象 Count/Find 后带状态,统计/预览/更新必须各自重建 query。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 蜜罐(多家族伪装 / 取证 / 封禁)
|
||||
|
||||
## 结构
|
||||
|
||||
- `internal/security/honeypot/family.go` 是**唯一皮肤表**:9 个 Family
|
||||
(wordpress / phpmyadmin / tomcat / jenkins / grafana / gitlab / luci / joomla / generic)。
|
||||
每个 skin 自带 routes、title、brand、tagline、css、fields、hidden、failure、headers、cookies。
|
||||
- `FamilyOf(path)` 按 segments 前缀判定(`.` 也算边界,兼容 `/adminer.php`)。
|
||||
新增路径必须**同时**进 `honeypotPrefixes`,否则 `Match` 根本不接管。
|
||||
- `asset.go`:`/wp-admin/css/…`、`/wp-includes/js/…` 这类资源回占位内容,
|
||||
**不落库、不计数** —— 一次页面加载会打 5–10 个资源请求。
|
||||
- `page.go`:一次 `strings.Builder` 拼接,禁用 `"a" + b + "c"`(每个 + 一次分配);
|
||||
`fieldIDReplacer` 必须包级,别在循环里 `NewReplacer`。
|
||||
- `route/honeypot.go` 的 Serve 顺序:cleanPath → **资源短路** → 家族 → 诊断日志 → 回环告警 → 提凭据 → 封禁 → 响应 → 落库。
|
||||
|
||||
## 性能基线(谁改慢了谁负责)
|
||||
|
||||
- `RenderFamily` ≈ 3.7 µs / 2 allocs / 5.2 KB;`AssetFor` ≈ 55 ns / 0 alloc。
|
||||
- 单页体积上限 4 KB,由 `TestRenderIsCheap` 守。
|
||||
- 预览:`go run ./dev/preview-honeypot` → `outputs/honeypot-preview/index.html`(9 个 iframe)。
|
||||
改皮肤后务必跑一次并截图,上一版就是靠它发现 Tomcat 按钮没背景色。
|
||||
|
||||
## 坑
|
||||
|
||||
- **hidden CSRF token 不能算密码键**:PMA / GitLab 的表单带 `token`,而 map 遍历无序,
|
||||
一旦 token 进 `isPasswordKey`,真密码会被随机覆盖成 token 值。
|
||||
所以 `credentialKeys` 收 token(只用于判 `HadFields`),`isPasswordKey` 不收。
|
||||
- **Tomcat 家族 = 401 + `WWW-Authenticate`**,凭据从 `Authorization` 头取;
|
||||
给它一个 HTML 表单反而不像。
|
||||
- 页面里不能出现 `TamaBox` / `honeypot` 字样;canary 只放 HTML 注释,
|
||||
`<meta name="generator">` 必须伪装成真实产品(扫描器必查)。
|
||||
- WP 登录页底部不能挂 404 文案(真实页没有,挂上一眼就假)。
|
||||
- 新增诱饵前缀前先核对业务路由(`/user`、`/login`、`/api`、`/_/`…);
|
||||
`forbiddenPrefixes` 按 `/xxx` 段边界判断,所以 `/users/sign_in` 不会被 `/user` 连坐。
|
||||
@@ -1,72 +0,0 @@
|
||||
# 业务正确性 / 运维 / 前端体验(2026-10-29 第二轮复查)
|
||||
|
||||
安全向的结论在 `sec.md`。这里是非安全向的四类,完整清单见 `outputs/review-2026-10-29.md`。
|
||||
|
||||
> **状态**:下列「已实测确认的缺陷」「处置覆盖面」「Redis」三块已于同日修复并端到端验证。
|
||||
> 紧接着做了第二批(P2 + 高价值 P3,共 9 项):投票结果 stale-while-revalidate、
|
||||
> 三处幂等、私聊首条事务、`/healthz` `/readyz`、验证码失败保正文、`tbBeginSubmit`、
|
||||
> waline 按页加载 / lazy / safe-area / i18n、5 处裸断言。
|
||||
> 第三批(同日):删除投票/提问改事务 + 真删、配额与内容同事务、追问改回答不重复发信、
|
||||
> token 常数时间比较 + CSV 注入补齐、写路径 fail-closed、真 404/HTML 500、
|
||||
> 提问者找回自己的提问(localStorage + `/api/v1/ask-status`)。
|
||||
> 条目保留是为了记住**为什么**会踩、以及验证该怎么做。
|
||||
|
||||
## 已修:两个 P0(都是实测出来的)
|
||||
|
||||
- **私聊 50 条截断(P0,已修)**:`internal/db/message.go` 的 `GetByConversationID` 是
|
||||
`Order("created_at ASC") + Limit(50)`,而唯一调用方 `chat_handler.go:97` 永远传 `nil` cursor。
|
||||
会话上限是 200 → 第 51 条起写库成功、`message_count` 在涨、页面显示 51/200,但列表永远只有最早 50 条。
|
||||
实测造 55 条,页面只渲染 #1–#50。**取最新 N 条要 `Order("id DESC")` 再内存反转**;
|
||||
游标 `id <` 也必须配 DESC(配升序会反复返回同一页)。
|
||||
- **访问日志泄露 token(P0,已修)**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
|
||||
`Logger()` 记的是 `ctx.Request().RequestURI`(含 query)。实测出现
|
||||
`path=/_/tblocal/4?t=SECRETTOKEN123`。`?t=` 是私密题钥匙,`?code=` 是改密凭证。
|
||||
**tracing 中间件早就为同一风险避开 RequestURI 并写了注释,框架默认 Logger 漏了。**
|
||||
改用 `flamego.New()` + 自写访问日志,只记 `URL.Path` 与白名单 query key。
|
||||
|
||||
## 处置(restrict)的覆盖面(投票已补齐)
|
||||
|
||||
- 提问链路 7 处 `ActiveLevel`(page L4 / question L3 / setPublic L2 / followUpAnswer L2 / shareCard 全级 / ListAPI L4 / moderation 锁定)。
|
||||
- **投票链路 0 处**:`vote.Page`、`vote.Submit`、`VotePublish` 都不查。L4 处置后 `/_/domain` 已 302 到
|
||||
`/restricted`,但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
|
||||
- **教训:新增任何「用户发起」的写入口,都要问一句「这里要不要查 ActiveLevel」。**
|
||||
|
||||
## 运维面
|
||||
|
||||
- **无优雅关闭**(已修):全仓 `signal.Notify` / `server.Shutdown` 零命中,SIGTERM 直接杀进程。
|
||||
`web.go` 里 `ListenAndServe` 一把梭。每次部署都掐断在途 POST。
|
||||
- **Redis 两种故障**(已修):session 走 Redis 时 load 失败会 `panic`(flamego/session),被 Recovery 兜成 **全站 500**;
|
||||
`cache.Cacher` 没写 `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退 `localhost:6379` →
|
||||
「不配 Redis」的部署里找回密码静默坏掉、私信邮件节流失效。启动时该 `PING` 一次并 fail fast。
|
||||
- `ReadTimeout: 30s` 与 8MB 单图上限冲突(手机 4G 上行 1–2 Mbps → 8MB 必超时),正文也一起丢。
|
||||
- 无 `/healthz`:`route.Home` 不碰 DB,DB 挂了 `/` 仍 200。
|
||||
- `Dockerfile` 用 `golang:1.20` 而 `go.mod` 是 1.27.3 → **构建必失败**;`-X` 路径还是旧仓库名
|
||||
`github.com/NekoWheel/NekoBox`,`BuildCommit` 永远是 `dev`。
|
||||
- AutoMigrate 无版本化:GORM 只加列不删列,**改列名会静默留下旧列 + 新列全 NULL**,比迁移失败更难发现。
|
||||
- 备份只有 `docs/cli.md` 一句 `pg_dump`;用户图片全在对象存储,**只恢复 DB = 全站裂图**。
|
||||
|
||||
## 前端
|
||||
|
||||
- `templates/question/history-template.html` 有硬编码中文(查看回答 / 加载更多 / 无更多留言),en/ja 用户直接看到。
|
||||
`vote/page.html:140` 中英混排。**字典本身 917 条 × 4 语言完全对齐(有测试守着),问题只在模板层。**
|
||||
- 提问页验证码失败走 `SetErrorFlash + Redirect` → **整段提问内容丢弃**;其他分支都是
|
||||
`SetError + Success` 保留内容。最常见的失败反而最不该丢内容。
|
||||
- `form.submit()` 不触发 submit 事件 → 验证码流(默认 captcha_type)完全没有防重复点击。
|
||||
- `waline.js` 全站加载但只有 `home.html` / `item.html` 用;UIkit CSS 走第三方 CDN 且阻塞首屏。
|
||||
- 32 个 `<label>` 无 `for=`;语义标签白字对比度约 1.9:1;全仓零 `loading="lazy"`。
|
||||
- 移动端 `body{padding-bottom:56px}` 与 `env(safe-area-inset-bottom)` 冲突,iPhone 上遮住约 32px。
|
||||
|
||||
## 产品缺口(按 ROI)
|
||||
|
||||
1. **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联。
|
||||
全仓 `localStorage` 零命中。最小解:前端记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
|
||||
2. **箱主没有自己的数据**:`SiteAnalytics` 是全站聚合(只有 `COUNT(DISTINCT user_id)`),
|
||||
**全站没有 page view 表**。箱主不知道自己被看了多少次、回答率多少。
|
||||
3. `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
|
||||
4. 模板零 `application/ld+json`(SEO 富摘要),无 RSS。
|
||||
5. 图片原图直传,无前端压缩。
|
||||
|
||||
## 这一轮确认「不用动」的
|
||||
|
||||
四语言字典对齐、深色模式正文对比度 4.8–6.8:1、gzip 实现(204/304/Content-Length/Vary/Flusher 都处理对了)、
|
||||
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
|
||||
@@ -1,61 +0,0 @@
|
||||
# 审核 / 处置 / 举报(长版明细)
|
||||
|
||||
## 审核 / 处置 / 举报
|
||||
|
||||
- 线上通道 = 七牛(`ai.qiniuapi.com/v3/text/censor`,antispam);`[security] text_censor_source = qiniu|aliyun|auto`。
|
||||
七牛只硬拦涉政/暴恐/色情,`review` 放行 → 裁决在 `censor/policy.go` + `moderation.Check`。
|
||||
- **新增审核调用点必须走 `moderation.Check`**(直调 `censor.Text` 绕过档位)。不可用时 **fail-closed**。
|
||||
- ⚠️ 圈内黑话(代称+群体标签+避雷叙述)通用审核拦不住,别指望词库;靠处置 + 举报 + 人工判断。
|
||||
- 严格度 lax/standard/strict 存 `users.moderation_strictness`;**受处置用户强制 strict**:
|
||||
`moderation.EffectiveStrictness(ctx, userID, raw)`(7 个入口已改)。**查询失败回退用户设置**
|
||||
(否则 DB 抖动会让全站突然加严)。处置期内拒绝保存 + 设置页 disabled。
|
||||
- ⚠️ **同请求渲染只能用 `ctx.SetError` / `SetInternalError`,不能用 `*Flash`**:flash 写 session,下次请求才读 →
|
||||
提示静默丢失。`Set*Flash + Redirect` 才是正常模式。扫描法:找 flash 调用 → 向下第一个 `Success(`/`Redirect(`。
|
||||
- 处置四级:L1 停止分发/L2 收窄公开(新回答强制私密、禁 set-public)/L3 冻结发布(仍可收信)/
|
||||
L4 整页停用(`/restricted`)。到期靠 `expires_at`,**无 cron**;`restrict` 是**替换语义**;比较用 `RestrictionAtLeast`。
|
||||
- ⚠️ L2 强制私密必须在**审核通过之后**,且与写回答**同一条 UPDATE**(`AnswerByIDPrivate`):
|
||||
拆开会留下「公开+已作答」窗口,第二条失败还会变成「私密+无回答」。
|
||||
- ⚠️ **`ListAPI`(`/api/v1/user/{domain}/questions`)不经过 Pager 中间件**,L4 与页面密码必须自己校验。
|
||||
- ⚠️ **整箱分享卡 `ShareCard` 与单题分享卡都要拦**,且拦截必须在缓存查询之前(否则 30 分钟内仍给旧图)。
|
||||
- 举报 `POST /_/{domain}/{qid}/report`:免登录 + 验证码 + 限流;同 IP 同题唯一索引 →
|
||||
**幂等且提示与首次一致**(否则成了内容存在性探测器)。箱主只看「被举报 N 次」,**不给 IP**。
|
||||
- 闭环:删除/设私密/`hide-public` → `MarkAcceptedByQuestion`;待处理超 30 天视为过期。
|
||||
⚠️ `hide-public` 里必须**先快照目标 ID 再更新**(更新后带 `is_private=false` 的子查询恒为空集)。
|
||||
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却);单条恰好 3 人举报 → 提醒箱主。
|
||||
⚠️ 该邮箱为空时静默跳过、无日志;改配置必须重启。
|
||||
- `/rules` 是处置依据页(四语言);注册页必须勾 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
|
||||
- `restrict`/`unrestrict` 默认给箱主发通知(按 `users.language`,`mail/restriction-notice.html`),
|
||||
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 跳过。`--days > 90` 要 `--force`。
|
||||
⚠️ **到期自动解除不发信**(读表比较时间)。
|
||||
|
||||
## 蜜罐(2026-10-21)
|
||||
|
||||
- 落点是**改写 `f.NotFound`**(不是新中间件):NotFound 只在路由表落空时触发 → 与业务路由零交集;
|
||||
位于全局 Use 链之后(body 已限 12 MiB);组级 CSRF 不作用于它。
|
||||
- flamego 短路机制:`context.run()` 在 `ResponseWriter().Written()` 为真时 return → 写完响应不调 `ctx.Next()` 即止。
|
||||
- 蜜罐命中的路径由蜜罐自己 `autoban.Record`,autoban 中间件须跳过(否则一次请求计两遍,阈值减半)。
|
||||
- `HoneypotHit` 不嵌 `dbutil.Model`(审计证据只追加,不软删)。
|
||||
- 假页只含 canary + 假信息;错误提示不区分「用户不存在」与「密码错误」(防用户名枚举)。
|
||||
- 密码 CLI 默认脱敏;告警邮件含明文凭据 → 收件人只能是运维本人。
|
||||
|
||||
### 蜜罐封禁与 enable_auto_ban 解耦(2026-10-21 线上实测修复)
|
||||
- ⚠️ **`Record()` 第一行 `if !b.enabled { return }` 会让 `enable_auto_ban=false` 连坐蜜罐** →
|
||||
蜜罐只落库不封禁(实测命中 70+ 次零封禁)。已修:新增 `RecordHoneypot()` 独立通道 + 独立阈值/时长。
|
||||
- `IsBanned()` 不得受 `enabled` 约束:`enabled` 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
|
||||
- `RecordHoneypot` 的键必须与 `Record` 一致(同样 `shared.LimitKey` 归一化),否则 `IsBanned` 判不出来。
|
||||
- 备份/配置/密钥泄露扫描(`/backup.zip`、`/db.sql`、`/id_rsa.pem`)文件名主体随机,**只有后缀稳定**
|
||||
→ 用 `path.Base()` 后缀匹配表,不能只靠子串(`"/.zip"` 匹配不到 `/backup.zip`)。
|
||||
|
||||
### 蜜罐表体积与自动清理(2026-10-21)
|
||||
- `honeypot_hits` 是**只追加**审计表,写入完全由外部扫描流量驱动(不可预期、不可节流)
|
||||
→ 必须有自动保留期清理,否则表体积只增不减。
|
||||
- 两道闸:时间(`honeypot_retain_days`)+ 数量(`honeypot_max_rows`)。数量闸不可省 ——
|
||||
攻击者可用海量源 IP 在保留期内把表撑爆。
|
||||
- ⚠️ PG 的 `DELETE` 只标记死元组,**表文件不会变小**;无 VACUUM 时表体积单调膨胀
|
||||
(实测 22→52 MB)。清理后必须 `VACUUM (ANALYZE)`,但注意 **VACUUM 不缩小文件、
|
||||
只让空间可复用**(止住增长即可,不必 `VACUUM FULL`)。
|
||||
- ⚠️ `VACUUM` 不能在事务块/函数体内执行 → GORM 走 `Exec()`(Raw 处理器,不包事务)可行。
|
||||
- ⚠️ 清理器的 `runOnce` 必须自己再挡一次 `retainDays<=0`,否则「保留 0 天」= 全删光。
|
||||
- 索引:GORM 去掉 struct tag 的 `index` **不会**删既有库上的旧索引 → 需显式 DropIndex。
|
||||
删索引前先用 EXPLAIN 确认零查询使用(`provider` 索引实测 10 万行 648 kB 且零使用)。
|
||||
- CLI `honeypot size` 报告行数/磁盘占用/时间跨度 —— 把「有没有臃肿」交给运维自查。
|
||||
@@ -1,58 +0,0 @@
|
||||
# 分享卡(share card)
|
||||
|
||||
两处入口都在 `route/question/share.go`:`ShareCard`(箱子卡,960×540)、`QuestionShareCard`(单题卡)。
|
||||
单题卡出图条件:**已回答 + 非私密 + 无站方处置**(`db.Restrictions.ActiveLevel` 非空即拒绝,fail-closed)。
|
||||
|
||||
## 文字排版
|
||||
|
||||
- 居中/限宽**必须**用 `measureTextWidth`(逐字 `HMetric().AdvanceWidth` + `Kern`,`scale = fontSize*64`),
|
||||
它与 `DrawString` 返回笔位逐位相等。绝不要用「中文 1em / 其余 em 系数」估算:URL 里的 `: / . _ -`
|
||||
只有 0.25~0.35em,整句会虚高 55%,居中直接偏 40px。
|
||||
- 链接展示走 `displayURL`(去 `https://`)→ `layoutLink`(单行 15→13px,放不下按 host/path 两行 13→11px,
|
||||
host 超宽才中间省略,字号下限 `LinkMinSize`)→ `ellipsizeMiddle`。限宽恒定 `QRCardW-40`。
|
||||
|
||||
## 二维码(`generateQR`)
|
||||
|
||||
- **图像只输出符号**:`qr.DisableBorder = true`,静默区由承载它的白色卡片提供。
|
||||
把 4 模块静默区画进图里 + 再居中留白,会让 165px 目标只剩 111px(3px/模块)——这是曾经的回归。
|
||||
- `perModule = size / modules` 向下取整,符号 = `modules*perModule`;**调用方必须用 `qr.Bounds().Dx()`
|
||||
排版与居中**,不能假设等于传入的 size。
|
||||
- 单题卡 `QQRSize = 190`(37 模块 → 185px = 5px,卡片 236×300 居中于 679,120);
|
||||
箱子卡 `QRCodeSize = 190`(37 模块 → 185px = 5px,卡片 270×335)。
|
||||
- 单题卡的二维码卡片绘制在 `drawQuestionQRCard`(handler 只是调用),测试可直接出图。
|
||||
- 卡内垂直间距在 4px 栅格上:`24 / 17 / 20 / 1 / 20 / 16`,总高按链接行数算,整块垂直居中。
|
||||
|
||||
## 模块像素:唯一的硬指标
|
||||
|
||||
| 模块 | 960 | 720 | 480 | 320 | 240 |
|
||||
|---|---|---|---|---|---|
|
||||
| 4px(148px,37 模块) | o | o | **x** | x | x |
|
||||
| 5px(185px,37 模块) | o | o | o | o | x |
|
||||
|
||||
(go-qrcode 纠错 Q、真实 55 字符链接、含内嵌头像;用 gozxing 解码,它比手机严格,可当保守下限。)
|
||||
→ **目标 5px/模块**;`TestGenerateQRModuleSizeAtLeast5` 锁住三种常见链接长度,改小 QRCodeSize 立刻 FAIL。
|
||||
|
||||
改大模块的路线排序(成本从低到高):
|
||||
1. 同画布放大二维码(已做,0 CPU)——`generateQR` 只 0.8ms,与尺寸几乎无关。
|
||||
2. 降纠错 Q→M:模块 37→33 只省 11%,容错 25%→15%,不划算,别单独用。
|
||||
3. 换 PNG:只保住原图,缩到 480 一样解不出,体积还是 JPEG 的 5 倍。
|
||||
4. 2x 渲染:+60~100ms(本地基准:JPEG 编码 12→48ms、背景 Lanczos 25→41ms、画布 3→13ms)。
|
||||
只在「原图本身也要更精细(背景/文字抗锯齿)」时值得。
|
||||
|
||||
## 缓存命中率才是 2x 之外更值得看的地方
|
||||
|
||||
进程内 LRU 只有 16 条 + TTL 1h,站点箱子多时冷渲染频繁;把生成结果落盘 / 上 R2
|
||||
能让「贵」只付一次。这是比 2x 更实在的优化方向。
|
||||
|
||||
## 验证手法
|
||||
|
||||
- 守门测试 `route/question/share_layout_test.go`:测量=笔位、链接限宽、按着墨边界判居中 ±3px、
|
||||
二维码 `width == modules×perModule` 且吃掉目标尺寸 ≥80%、头像不在边缘裁切。改完都做变异验证。
|
||||
- `TEST_PREVIEW=1 go test ./route/question/ -run TestShareCardLinkPreview` 出 270×315 的卡区域 PNG。
|
||||
- 图片实测:PIL 数着墨 bbox(居中偏差 / 二维码宽度 / 各段间距);解码用
|
||||
`github.com/makiuchi-d/gozxing` 起个临时 module 跑(`_ "image/jpeg"` 和 `_ "image/png"` 都要 import)。
|
||||
- 单题卡要真实出图:`.workbuddy/local/qcard_probe.py`(发提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
|
||||
|
||||
## 缓存
|
||||
|
||||
进程内 TTL 1 小时 + 响应头 `Cache-Control: public, max-age=300`。改完要复验:重启(清进程内缓存)+ URL 加随机参数。
|
||||
@@ -1,134 +0,0 @@
|
||||
# 全站数据页(/stats)
|
||||
|
||||
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
|
||||
|
||||
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
|
||||
(`templates/base/footer.html`,词条 `site.footer.stats`)。
|
||||
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
|
||||
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
|
||||
新增入口不要再往首页加——那会让两处数字需要同步维护。
|
||||
|
||||
## 分层
|
||||
|
||||
```
|
||||
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
|
||||
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
|
||||
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
|
||||
→ route.Stats → templates/stats.html
|
||||
```
|
||||
|
||||
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
|
||||
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
|
||||
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
|
||||
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
|
||||
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
|
||||
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
|
||||
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
|
||||
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
|
||||
|
||||
## 口径(改之前先看)
|
||||
|
||||
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
|
||||
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
|
||||
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
|
||||
会自动刷新 updated_at,已读/置顶/公开切换都走 Update —— 实测一条 10 天前的提问
|
||||
被 `SetRead` 后 updated_at 直接推到当下。故新增 `questions.answered_at`(可空),
|
||||
只在 answer 由空变非空时写一次,改写回答不重置;历史行为 NULL 时
|
||||
`COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次
|
||||
(`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。
|
||||
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
|
||||
**句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」
|
||||
不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成
|
||||
「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。
|
||||
三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通);
|
||||
参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。
|
||||
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
|
||||
「今日提问/回答」两项实时(60 秒 TTL,见下)。
|
||||
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
|
||||
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
|
||||
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
|
||||
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
|
||||
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
|
||||
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
|
||||
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
|
||||
不能依赖数据库会话时区 —— 见「时区」一节。
|
||||
|
||||
## 两个已踩过的坑(2026-10-24 部署前自查发现)
|
||||
|
||||
**1. 「今日」两项曾被每日缓存冻住。** 原先 `SiteAnalytics` 里查了今日数,塞进
|
||||
`Overview`,而 `Overview` 整天复用 → 页面顶着「实时」的说明显示一整天不变的常量。
|
||||
现在拆出 `SiteAnalyticsStore.TodayCounts`,`stats` 层单独 60 秒缓存(`todayTTL`),
|
||||
跨日按日期字符串失效(`todayDate`)。缓存命中时返回的是**副本**(`withToday`),
|
||||
不能原地改 `ovCached` —— 那是数据竞争。
|
||||
|
||||
**2. 日期分组默认跟着 PG 会话时区走,通常是 UTC。** PG 官方镜像 `timezone=UTC`,
|
||||
而 Go 按容器本地时区(Dockerfile 设 Asia/Shanghai)切「今天」。实测同一条
|
||||
`2026-10-24 01:30+08` 的提问:会话 UTC 下 `to_char` 给 `2026-10-23`、`EXTRACT(HOUR)` 给 17。
|
||||
→ 日报表整体偏 8 小时(中国 0–8 点被算进前一天),热力图时段全错位,且与「今日」对不上。
|
||||
现在 `analyticsLocalize()` 统一包一层 `AT TIME ZONE INTERVAL '+08:00'`,偏移取自
|
||||
`time.Time.Zone()`(不用 IANA 名:Go 在未设 TZ 时给 "Local",PG 不认)。
|
||||
过滤条件仍是裸列,索引照用(EXPLAIN 确认走 Bitmap Index Scan)。
|
||||
|
||||
## 性能实测(20 万行 questions,本地 PG,冷缓存)
|
||||
|
||||
| 查询 | 耗时 | 扫描方式 |
|
||||
|---|---|---|
|
||||
| totals(全表条件聚合) | 337 ms | Seq Scan |
|
||||
| 字数分布(全表) | 313 ms | Parallel Seq Scan |
|
||||
| 热力图 180 天 | 100 ms | Bitmap Index Scan |
|
||||
| 日趋势 30 天 | 47 ms | Bitmap Index Scan |
|
||||
|
||||
合计约 0.8 秒;1C1G 云机按 2–3 倍算约 2 秒。靠启动后 3 秒预热挪出请求路径,
|
||||
可接受。**若 questions 到百万行**,三次全表扫描会到 6–10 秒,那时应把
|
||||
totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的无条件 CASE 聚合,
|
||||
合并后只扫一次)。现在不做:不知道真实表规模,每天只算一次,不值得为省 0.5 秒
|
||||
把 SQL 拆成大块。
|
||||
|
||||
## 图表实现(零前端库)
|
||||
|
||||
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
|
||||
|
||||
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
|
||||
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
|
||||
(否则窄屏描边被压细)。
|
||||
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
|
||||
线和刻度就错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
|
||||
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
|
||||
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
|
||||
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
|
||||
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
|
||||
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
|
||||
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
|
||||
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
|
||||
不用算周长。
|
||||
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
|
||||
(连续透明度在小方块上肉眼分不出来)。
|
||||
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
|
||||
展示顺序(周一起)在 `weekdayOrder` 里转。
|
||||
|
||||
## 预览与验证
|
||||
|
||||
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
|
||||
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
|
||||
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
|
||||
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
|
||||
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
|
||||
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
|
||||
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
|
||||
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
|
||||
|
||||
## 隐私红线
|
||||
|
||||
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
|
||||
「收到过提问的箱子」这类计数可以,具体是谁不行。
|
||||
|
||||
## 文案(读者是访客,不是站长)
|
||||
|
||||
- 不写实现细节:缓存策略、懒重算、"服务重启也会重算"、索引、TTL —— 访客要的只是
|
||||
「多久更新一次」。准确的新鲜度交给页脚「快照时间」那行。
|
||||
- 不写作者视角的免责:「下面几句…没有推测」「本文」「这里只有」这类,读起来像在跟审稿人
|
||||
解释,而不是在给访客看。已删 `stats.insight.note`。
|
||||
- 确实要对访客交代的只有三件:数据来源/隐私(`stats.lead` + `stats.foot.privacy`)、
|
||||
新鲜度(`stats.foot.cacheNote`)、口径例外(`stats.trend.monthlyNote` 的"当月还没过完")。
|
||||
- 删说明行会改变间距:`.tb-insights` 自己补 `margin-top`,别指望 `tb-card-note` 的 16px。
|
||||
@@ -1,41 +0,0 @@
|
||||
# 前端样式(长版明细)
|
||||
|
||||
## 前端样式
|
||||
|
||||
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,**改完必须 +1**)。令牌在 `:root`(亮)+
|
||||
`html[data-theme="dark"]`(暗)。新样式一律 `var(--tb-*)`,别写死色值,也别为深色另写分支。
|
||||
- 页面级样式内联在各自模板 `<style>`;尽量用类不用 `style=`(深色下内联写死色值会露白)。
|
||||
- 主题属性在 head 内联脚本里打(cookie `theme`),**不能挪回 body**,否则深色用户白闪。
|
||||
- **移动端(≤640px)语言切换与主题按钮在固定底栏** `#tb-mobile-utils`(`.tb-nav-utils` 被隐藏,
|
||||
body 留 `padding-bottom:56px`)。用户曾误以为"按钮不见了",先解释位置。
|
||||
⚠️ `/_/{domain}` 箱主页 `IsUserPage = true`(`route/question/page.go:133`),语言切换包在
|
||||
`{{ if not .IsUserPage }}` 里 → **箱主页没有语言切换入口,有意设计**(用户 2026-10-19 确认),别当 bug 修。
|
||||
- ⚠️ **UIkit 3.3.3 的 `.uk-flex::before/::after{display:none}`**:给带 `uk-flex` 的元素加伪元素必须写
|
||||
复合选择器(`.tb-cover.uk-flex::after`),否则静默失效。
|
||||
- 分类/题型标签一律 `.tb-chip`;彩色 `uk-label` 只留给语义状态(置顶/私密/被举报/已读、投票四态)。
|
||||
- **字号阶梯 5 档**:`--tb-fs-xs/md/lg/xl/hero`(12/14/16/24/28)。散落的 `.9/.95/.98rem` 一律映射到档位,
|
||||
别新增第 6 个值。⚠️ 已令牌化的是模板层;`base.css` 自身仍留 11 处硬编码(0.78/0.82/0.88/0.95/1.1),
|
||||
`templates/user/profile.html` 还留 16 处 —— 想彻底统一得先扫这两处。
|
||||
- 圆角只用 `--tb-radius(-sm/-xs)`;`50%` 与 `999px` 是语义(头像、胶囊),**不要**换成令牌。
|
||||
- ⚠️ `.uk-text-muted/.uk-text-meta` 必须带 `!important`:UIkit 3.3.3 自带 `color:#999!important`,
|
||||
普通声明压不过(现象:规则写了但全站还是 #999)。
|
||||
- **文字色下限 WCAG AA 4.5:1**(正文级)。`--tb-text-muted` = `#6b7280`(白底 4.83)。
|
||||
旧值 `#8a919c` 3.18、`#999` 2.85 —— 肉眼"淡一点"其实已经不合格。
|
||||
主色还没达标(`#1e87f0` 3.26 / `#4a7fc1` 3.69 / sakura `#e5678f` 2.80),要动得连主题一起改。
|
||||
- ⚠️ **改 base.css 的配色令牌必须同步检查 `static/css/themes/*.css`**:主题覆盖同名令牌,
|
||||
基座修了但主题页纹丝不动(sakura/shiroko 的 muted 就曾是 2.8:1,是这次才补到 4.6)。
|
||||
- 顶栏链接:亮色用 `--tb-text-secondary`、hover/当前页 `--tb-primary`;深色块原本 `!important` 定成
|
||||
`--tb-text`,会让同一顶栏在深浅两模式权重跳一档 → 已加 dark secondary 覆盖。
|
||||
- **主色要达到 AA 4.5:1**(它同时是 14px 链接/按钮文字色)。现值:base `#0e6fd0`、shiroko `#3b6dac`、
|
||||
sakura `#ba3e66`(都是同色相压亮度)。**改主色必须三处一起**:`base.css :root` + `themes/default.css` + 该主题。
|
||||
- ⚠️ **`--tb-on-primary`**(亮 `#ffffff` / 暗 `#0b1016`)是「主色作背景时的文字色」。
|
||||
深色模式的强调色偏亮(`#5c8dff`),白字只有 2.2~3.1:1 —— 用一个令牌同时伺候「链接色要深」和
|
||||
「按钮底要深」是矛盾的,所以拆成两个。`.uk-button-primary`、`.uk-label:not(语义色)` 都吃它。
|
||||
- 字号阶梯已全部落地(模板 + base.css);`0.9em`(相对父级)、验证码等宽字形、`mail/*` 是**有意例外**。
|
||||
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` → `outputs/preview-*.html`;
|
||||
截图必须 `chrome --headless=old --force-device-scale-factor=1`(新版 headless 本机挂住)。
|
||||
⚠️ `--window-size` ≠ CSS 视口;要精确视口就用 iframe 指定 width/height。验证 CSS 靠**像素比对**
|
||||
(PIL 在 venv)而非目测;扫描循环上界不能与物体边界重合。
|
||||
- ⚠️ **后台页不是都有卡片**:`/user/questions` 的列表是 `<hr>` 裸行,模板里唯一一处 `.uk-card`
|
||||
是投票 tab 的标题条。默认主题 #fff 看不出来,换带整页插画的主题就"整页透上来"。
|
||||
已在 `.qlist-panel` 上补卡片底;新增后台页时别假设外层已有卡片。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 主题(箱主个性化)(长版明细)
|
||||
|
||||
## 主题(箱主个性化)
|
||||
|
||||
- 三个注入点:`/_/{domain}/*` → `question.Pager` 按 `pageUser.Theme`;`/user/*` → `internal/context`
|
||||
Contexter 按 `c.User.Theme`(`isThemeScope()` 只认 `/user` 与 `/user/`);`/vote/{token}` → `route/vote.Page`
|
||||
按发布者 `owner.Theme`。都看箱主/发布者,不看访客。全站页(`/`、`/explore`、`/login`)不主题化。
|
||||
- 投票页**语言**也跟发布者:`applyOwnerLocale(data, owner)`(与主题同一函数),必须在任何 i18n 取词之前调用。
|
||||
- 一套主题 = `static/css/themes/<slug>.css`,**只覆盖令牌**,不碰布局。`static/fs.go` 已嵌整个 `themes/` 目录。
|
||||
- 主题显示名:模板用 `{{ themeName .LangCode .Slug }}` → `theme.DisplayName`,i18n 没登记
|
||||
`theme.name.<slug>` 时**回退成 slug**。⚠️ 别在模板里直接 `{{t .LangCode (printf "theme.name.%s" .Slug)}}`:
|
||||
`i18n.T` 缺词条返回 key 本身,设置页就会显示 `theme.name.test01`(用户报过)。
|
||||
- ⚠️ **`[theme] available` 是硬白名单,启动时读一次**:css 发布了但没登记,设置页看不到,现象同「部署没生效」。
|
||||
排查:`curl /static/css/themes/<slug>.css` 200 = 文件在(没登记);404 = 旧二进制。
|
||||
- ⚠️ 主题 css **必须成对写**:`html[data-theme-style="x"]`(0,1,1) + `html[data-theme="dark"][data-theme-style="x"]`(0,2,1)。
|
||||
`TestThemeCSSComplete` 查两个选择器 + 10 个颜色令牌。
|
||||
- ⚠️ **按页面作用域**:`internal/context.pageScope()` → `<html data-page-scope>`,取值
|
||||
`box`/`question`/`admin`(/user/questions,/user/votes*)/`profile`/`vote`。深色作用域覆盖必须**三重选择器**,
|
||||
否则被浅色作用域块(同特异性、写在后面)盖掉。
|
||||
- ⚠️ **装饰层固定 10 槽**(base.css 12 层 = pattern1 + deco10 + image1),三组 `--tb-deco/-size/-position`
|
||||
必须等长。`TestDecoSlotCount` 逐文件逐声明检查(只扫 embed 到的文件)。
|
||||
- 位置一律两值 `calc(0%|50%|100% ± N)`(百分比相对「容器 − 图片」)。**别用 left/bottom 关键字混搭**:
|
||||
三值语法非法时整条声明作废 → 图层静默回左上。
|
||||
- 尺寸想随视口缩用 `clamp`,**不要写死 px**:`auto clamp(min(calc(100vh - 24px), 231px), 46.67vh, 420px)`。
|
||||
内层 `min()` 是兜底 —— 屏幕小到连下限都放不下时让位给视口。宽幅素材只能按宽度缩(`vw`),
|
||||
按高度缩的话宽度按比例涨,窄屏照样裁。
|
||||
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
|
||||
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
|
||||
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
|
||||
⚠️ **顶栏只能半透明,不能全透明**(2026-10-22 用户明确要求):装饰层从角落长出来时实色顶栏会把
|
||||
花枝切一段;但改成 `transparent` 顶栏就"消失"了,导航失去依托、文字压在花纹上。正解是
|
||||
`rgba(bg, 0.72)` + `.uk-navbar-container{backdrop-filter: blur(12px)}`(模糊只能写在规则里,
|
||||
令牌只是颜色)。`TestNavbarNotFullyTransparent` 拦 `transparent` / `rgba(...,0)` / `#rrggbb00`。
|
||||
同理 `#tb-mobile-utils` 在 base.css 里写死 `rgba(255,255,255,.92)`,彩色主题要自己调成同色系。
|
||||
- ⚠️ 装饰层会穿过顶栏区域时,`top` 别停在 0~nav 高度内:改半透明后枝头会压到「登录」上
|
||||
(700~760px 宽度实测),按顶栏高度(60 + margin 20)把 `top` 下移。
|
||||
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
|
||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
||||
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
||||
图案 ≥24px、透明度 ≤0.06。
|
||||
- ⚠️ **多背景层不能塞变量列表**:变量自身是逗号列表会让层数与 size/position 错位。多张装饰用固定槽位。
|
||||
- 装饰摆位:**贴边、成组、避开页脚**;四角各摆一个会读成贴纸墙纸;页脚文案右对齐 → 右下角不能放人物。
|
||||
`--tb-deco` 是 fixed 视口层,贴边插画别放进 `cover` 背景图(必裁边)。
|
||||
- 主题视觉的**结构**比摆位重要:通行做法是「整屏场景插画 + UI 悬浮其上」(蔚蓝档案 JP)。
|
||||
白底留白里摆人物,无论多大都读成贴纸;卡片/顶栏配半透明白 + `backdrop-filter` 才有"浮在画面上"的关系。
|
||||
- 素材放 `static/images/themes/<slug>/`(embed 已含 `images/themes`,子目录自动覆盖)。整页图 16:9 + 竖版两张。
|
||||
- 人物素材:**图生图 + 抠白底**(`image1` 给立绘、`input_fidelity: high`、`flat vector illustration, no outlines`)。
|
||||
⚠️ 正面立绘直接贴进空场景像贴纸:改**侧后方视角**(不直视访客),RGB **压白 12~16%** 与压白 38% 的场景对齐雾度。
|
||||
- ⚠️ **抠白底容差 16**:背景 253、白袜子 237 只差 16,容差 22 以上会把袜子吃穿。手内侧/两腿之间是
|
||||
**闭合**白区,四边 floodfill 进不去,得按连通域单独掏(面积 ≥24),再 1px 收边。
|
||||
- ⚠️ `ruleBody` 取令牌前要剥 `/* */`:注释会和后面的令牌粘成一个键,色块预览静默失效。
|
||||
- ⚠️ **主题会覆盖 base.css 的同名令牌 —— 基座改配色必须同步改主题**。改 `--tb-text-muted` 时
|
||||
sakura `#a08c94`(2.8:1)、shiroko `#8794a7`(2.76:1) 都还没跟上,"修了但主题页没变"就是这么来的。
|
||||
现已调到 `#78696e`(4.61) / `#656e7a`(4.63),深色块 `#a18790` / `#7f8ea1`(对压平后的卡片 4.62)。
|
||||
主色也已修(`#0e6fd0` / `#3b6dac` / `#ba3e66`)+ 新增 `--tb-on-primary`,见 topics/style.md。
|
||||
量对比度时半透明 `--tb-surface` 要**压平到 --tb-bg** 再算,别当纯白。
|
||||
- 主题选择页的色块由浅色块的 `--tb-bg` + `--tb-primary` 生成(`swatchOf`),注释里别写令牌名。
|
||||
- `test01` 是用户 2026-10-21 自建又删掉的验证主题(css + 2 张 webp 已移除,未提交)。
|
||||
删主题只需删文件:`build()` 读不到 css 会打 warning 并忽略,已选该主题的用户静默回退站点默认。
|
||||
`static/images/shiroko-cutout.png` 也一并删除(2026-10-21):它从不进 embed,只是 docs 里的图生图参考图,
|
||||
仓库不存这类大图。
|
||||
- ⚠️ **白上白素材无法用颜色分开**:白底 + 白主体(如自行车)时,车身与背景是同一个白
|
||||
(实测都 `#f9f9f9`),floodfill 只吃外圈,轮圈内/车架三角/车筐整块留下 = 看着"没抠干净"。
|
||||
自检:`不透明像素里近白(>240) 占比`,正常应≈0(修复前 50%)。要么全部转透明(成品变线稿风),
|
||||
要么重出绿幕/透明背景素材。细节见 `docs/theme-shiroko-assets-spec.md` 的「白上白」。
|
||||
- ⚠️ 换 `props/*.webp` 时**不要裁画布**:`--tb-deco-size` 是 `auto 259px`,宽度按原图比例算,
|
||||
裁完锚点 `left calc(50% - 612px)` 会漂;四周透明边留着不影响观感。
|
||||
@@ -1,90 +0,0 @@
|
||||
# TamaBox 功能与修复变更记录
|
||||
|
||||
> 变更日期:2026-08-12 ~ 2026-08-13
|
||||
> 说明:本文档汇总本阶段对 TamaBox(NekoBox fork)新增的功能、修复的问题与性能/安全优化,供部署、维护与后续开发参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、新增功能
|
||||
|
||||
| # | 功能 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | **问题置顶 / 精选回答** | 提问箱主人在管理页可置顶/取消置顶提问,公开主页置顶优先展示(`is_pinned` 字段 + `PinFirst` 排序) |
|
||||
| 2 | **单题分享图** | 单个问题(提问+回答)生成分享卡片图,仅公开且已回答可生成;复用主页分享图的绘制/缓存/字体模块 |
|
||||
| 3 | **回答后追问** | 提问者凭 token/登录身份对已回答的问题最多追问 3 次,主人可回复;事务+行锁保证并发上限;追问框仅在主人回复上一轮后显示,等待期间提示提问者 |
|
||||
| 4 | **回答 Markdown 渲染** | 回答与追问回答支持 Markdown 排版,服务端渲染 + HTML 白名单过滤(防存储型 XSS),带 XSS 单测 |
|
||||
| 5 | **深色模式** | 全站一键切换深/浅色(cookie 持久化 365 天);桌面端顶栏图标、移动端底部固定操作栏,双入口 |
|
||||
| 6 | **同 IP 重复内容拦截** | 同一 IP 在 5 分钟内提交相同内容(提问/私聊首条)会被拒绝,防刷屏 |
|
||||
| 7 | **匿名 / 登录分级限流** | 登录用户享有 3 倍请求额度,匿名与登录独立限流器统计(滑动窗口) |
|
||||
| 8 | **封禁用户连带清理** | 新增 CLI 命令 `TamaBox ban --email/--domain`:事务级联删除该用户提问、追问、私聊、消息、图片关联并物理删除账号(含二次确认) |
|
||||
| 9 | **提问箱统计看板** | 管理页顶部 6 张统计卡:总提问 / 已回答 / 未回答 / 私密 / 置顶 / 私聊会话 |
|
||||
| 10 | **热门标签榜** | 探索页展示高频用户标签(点击即搜索),热门问题区块按需移除 |
|
||||
| 11 | **全文搜索提问箱** | 管理页可按关键词搜索自己收到的提问与回答(LIKE 模糊匹配,通配符转义,含私密提问) |
|
||||
| 12 | **JSON 全量导出** | `/user/profile/export-json` 导出用户信息 + 提问/回答/追问/图片 + 私聊全部消息,满足数据可携带权;与既有 Excel 导出并存 |
|
||||
| 13 | **多语言(i18n)** | 简体中文 / 繁體中文 / English / 日本語;按 语言 cookie > 登录用户配置 > 提问箱主人配置 > 站点默认 解析;**注册页可直接选择界面语言**(登录后即刻生效),注册成功提示跟随所选语言 |
|
||||
| 14 | **私聊**(已有功能完善) | 登录用户可向开启私聊的提问箱发起会话,支持图片、拉黑、每日条数上限、会话关闭;管理页批量获取首条消息避免 N+1 |
|
||||
| 15 | **页面密码 / 提问密码** | 提问箱页面可设置访问密码、提问可设置密码(bcrypt 存储) |
|
||||
|
||||
## 二、问题修复
|
||||
|
||||
### 编译阻断(在 Linux 上也会编译失败,已全部修复)
|
||||
- `QuestionsStore` 接口缺失 `SetPinned` 方法 → **置顶功能无法编译**,已补接口声明
|
||||
- `internal/route` 限流、IP 黑名单中间件 `ctx.Request()` 类型错误(应为 `ctx.Request().Request`)→ 修复
|
||||
|
||||
### 运行时 500 / 功能异常
|
||||
- `templates/embed.go` 遗漏 `about-me.html` → `/about-me` 页面 500,已补嵌入
|
||||
- **`/user/questions` 500**:`question-list.html` 在 `{{range}}` 内 6 处误用 `.LangCode`(应为 `$.LangCode`),渲染到私密/未回答/私聊条目即报错;已全部修正并新增渲染回归测试
|
||||
- "等待主人回复"提示误显示给提问箱主人 → 增加提问者身份判断
|
||||
- 邮箱 / 域名无唯一索引(并发注册可重复)→ 改为唯一索引
|
||||
- 验证码 remoteIP 未统一解析(反代部署失效)→ 统一走 `shared.ResolveClientIP`
|
||||
|
||||
### 安全 / 稳定性
|
||||
- 图片代理无响应体大小限制(DoS)→ 10MB 上限
|
||||
- 图片代理缺少 302 重定向 SSRF 防护 → 域名白名单 + 内网 IP 拦截 + 逐跳重定向校验
|
||||
- 限流器每个实例泄漏常驻 goroutine → 包级注册表 + 单一清理循环
|
||||
- `censor.RemoveTrustedURL` 白名单删除机制保留(建议定期审查 TrustedHosts)
|
||||
|
||||
### UI / 交互
|
||||
- 提问成功页复制按钮与链接栏大小不协调、两个操作按钮样式不统一 → 统一 flex 布局与按钮样式
|
||||
- 顶栏多语言文字(英文/日文)溢出:最终方案为**恢复 UIkit 居中三列布局**,桌面端原样;手机端(≤640px)改流式单行 + 横向滚动兜底,用户名限宽省略号截断
|
||||
- 管理页置顶按钮与"未回答"等标签不对齐 → 标签组改 flex 容器统一对齐;置顶按钮改用 `uk-label` 标签样式,完全继承 UIkit 尺寸(定制版 `.uk-label` 字号为 0.875rem,不再硬编码)
|
||||
- 语言切换 / 深色模式入口在移动端移入底部固定操作栏(毛玻璃样式)
|
||||
|
||||
## 三、性能与体积优化(分享图)
|
||||
|
||||
- **画布尺寸 1280×720 → 960×540**:绘制与编码工作量降至约 40%
|
||||
- **输出格式 PNG → JPEG(质量 88)**:单张体积约减小 90%(从数百 KB~1MB 降至约 100KB 量级),编码更快
|
||||
- **文字宽度测量改估算**(逐字符宽度累加,替代每次创建 freetype.Context + DrawString 测量)
|
||||
- 分享图结果缓存 TTL 由 30 分钟延长至 1 小时,远程图片缓存 1 小时不变
|
||||
- 名称文字增加柔和投影,增强复杂背景上的可读性
|
||||
|
||||
## 四、新增命令与配置
|
||||
|
||||
### CLI 命令
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `TamaBox web` | 启动 Web 服务 |
|
||||
| `TamaBox censor` | 批量补审未审查的提问/回答(需配置文本审查服务) |
|
||||
| `TamaBox uid` | 为历史用户补生成 UID |
|
||||
| `TamaBox ban --email [email protected]`(或 `--domain`,可加 `--yes` 跳过确认) | 封禁用户并连带清理其全部数据(不可恢复) |
|
||||
|
||||
### 配置项(conf/app.ini)
|
||||
| 配置 | 说明 |
|
||||
|------|------|
|
||||
| `[app] default_lang` | 站点默认语言(zh-CN / zh-TW / en / ja),留空默认简体中文 |
|
||||
| `[security] ip_blacklist` / `ip_blacklist_redirect_url` | 逗号分隔 IP/CIDR 黑名单,命中跳转指定 URL |
|
||||
| `[security] trust_proxy` | 反代部署且代理已覆盖 X-Real-IP/X-Forwarded-For 时设为 true,否则 false(防伪造 IP 绕过限流/黑名单) |
|
||||
| `[recaptcha] captcha_type` | recaptcha / turnstile / math(本地算数验证码) |
|
||||
| `[upload] image_*` / `aliyun_*` | 图片存储(R2/S3 或 OSS) |
|
||||
| `[mail] skip_tls_verify` | 自签名 SMTP 证书时跳过校验 |
|
||||
| `[service] tls_insecure_skip_verify` | 内网后端自签名证书时跳过校验 |
|
||||
|
||||
### 编译依赖提示
|
||||
- `chai2010/webp` 需要 CGO 与系统 `libwebp`(Linux:`apt install libwebp-dev`),转换失败自动回退原始格式
|
||||
- 动画 GIF 转 WebP 需要系统 `cwebp` 命令,未安装时回退原始 GIF
|
||||
|
||||
## 五、测试
|
||||
|
||||
- 非 DB 依赖测试全部通过:`templates`(模板解析 + 交叉引用 + 渲染回归)、`internal/i18n`、`internal/shared`、`internal/security/censor`、`internal/template`(含 Markdown XSS 用例)
|
||||
- DB 层测试需要外部 MySQL/Postgres(`DB_TYPE` 环境变量),建议 CI 配置
|
||||
- 注意:Windows 上因 webp 依赖无法完整编译,请在 Linux 环境 `go build ./...` 复核
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
FROM golang:1.20-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
ARG GITHUB_SHA=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go mod tidy
|
||||
RUN go build -v -ldflags "-w -s -extldflags '-static' -X 'github.com/NekoWheel/NekoBox/internal/conf.BuildCommit=$GITHUB_SHA'" -o NekoBox ./cmd/
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk update && apk add tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --from=builder /app/NekoBox .
|
||||
|
||||
RUN chmod 777 /home/app/NekoBox
|
||||
|
||||
ENTRYPOINT ["./NekoBox", "web"]
|
||||
EXPOSE 8080
|
||||
@@ -1,21 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 E99p1ant
|
||||
Copyright (c) 2026 tamakyi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<h1 align="center">
|
||||
<img src="https://nekobox-public.oss-cn-hangzhou.aliyuncs.com/images/Neko.png" width=100px/>
|
||||
|
||||
狼的提问箱
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
匿名提问箱 / Anonymous Question Box
|
||||
|
||||
基于原版NekoBox做了一些微小的修改。主要体现在:
|
||||
- 率先引入二维码访问,同时增加更多自定义可行性,方便自行创作。
|
||||
- 添加探索页面,允许用户自行选择添加标签并展示,为陌生人提问创造可能。
|
||||
- 稍微调整了屏蔽词策略。
|
||||
|
||||
## 截图
|
||||
|
||||
### QRcode
|
||||

|
||||
<br>
|
||||
### Commit with picture
|
||||

|
||||
<br>
|
||||
### Explorer
|
||||

|
||||
<br>
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
### 需求
|
||||
|
||||
* [Go](https://golang.org/dl/) (v1.19 或更高版本)
|
||||
* [MySQL](https://www.mysql.com/downloads/) (v5.7 或更高版本)
|
||||
* [Redis](https://redis.io/download/) (v6.0 或更高版本)
|
||||
|
||||
### 从源码编译
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tamakyi/TamaBox.git
|
||||
|
||||
cd TamaBox
|
||||
|
||||
go build -o TamaBox
|
||||
```
|
||||
|
||||
### 编辑配置文件
|
||||
|
||||
```bash
|
||||
cp conf/app.sample.ini conf/app.ini
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
./TamaBox web
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||

|
||||

|
||||
|
||||
NekoBox 使用 GitHub Actions 进行持续集成和部署。
|
||||
|
||||
当用户访问 NekoBox 时,请求将会被发送至阿里云 CDN,CDN 的访问日志将会被实时推送到阿里云日志服务
|
||||
(SLS)。日志数据将在 SLS 中存储 180 天,用于审计。
|
||||
|
||||
用户的信息、提问和回答将被存储在 MySQL 数据库中。
|
||||
|
||||
用户的会话、CSRF 令牌和电子邮件验证令牌将被暂时存储在 Redis 中。
|
||||
|
||||
用户的整个请求和响应链路将被上传到 Uptrace 用于调试。这些数据将被储存 30 天。管理员可以使用用户提供的 `TraceID`
|
||||
来追踪查询指定的请求。
|
||||
|
||||
当用户提交提问时,问题的内容将被发送到七牛文本审查服务进行审查。如果提问内容存在问题,该内容将被发送到阿里云文本审查服务进行二次审查。
|
||||
如果内容审查仍未通过,该提问将被拒绝发送。这是由于七牛文本审查服务不是很准确,一些非冒犯性的内容可能被七牛文本审查误报。
|
||||
|
||||
当用户收到新的提问时,阿里云邮件服务(DM)会向用户的邮箱发送一封邮件。
|
||||
|
||||
你可以在主页查看 NekoBox 的更新日志,也欢迎访问赞助页面来打钱支持 NekoBox。 更新日志和赞助商名单存储在独立部署的
|
||||
Pocketbase 服务中。
|
||||
|
||||
## 开源协议
|
||||
|
||||
MIT License
|
||||
|
||||
## 备注
|
||||
|
||||
* 基于原版[Nekobox](https://github.com/NekoWheel/NekoBox)修改,基本与原版无异,主要是添加了个人主页二维码和首页统计数,并修改了部分适合自己的参数。
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
<h1 align="center">
|
||||
<img src="https://box-user-assets.n3ko.cc/public/Neko.png" width=100px/>
|
||||
|
||||
NekoBox
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
匿名提问箱 / Anonymous Question Box
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://goreportcard.com/badge/github.com/NekoWheel/NekoBox">
|
||||
<img src="https://github.com/NekoWheel/NekoBox/workflows/Go/badge.svg" alt="Go Report Card">
|
||||
</a>
|
||||
<a href="https://sourcegraph.com/github.com/NekoWheel/NekoBox">
|
||||
<img src="https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?logo=sourcegraph" alt="Sourcegraph">
|
||||
</a>
|
||||
<a href="https://deepsource.io/gh/NekoWheel/NekoBox/?ref=repository-badge">
|
||||
<img src="https://deepsource.io/gh/NekoWheel/NekoBox.svg/?label=active+issues&token=7nuU5C-4QG3CP_5g9qFf3Bl9" alt="DeepSource">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/NekoWheel/NekoBox">
|
||||
<img src="https://goreportcard.com/badge/github.com/NekoWheel/NekoBox" alt="Go Report Card">
|
||||
<a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="/README.zh-CN.md">简体中文</a> | <a href="/README.md">English</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
### 需求
|
||||
|
||||
* [Go](https://golang.org/dl/) (v1.19 或更高版本,`go.mod` 声明 `go 1.25.0`)
|
||||
* [MySQL](https://www.mysql.com/downloads/) (v5.7 或更高版本)
|
||||
* [Redis](https://redis.io/download/) (v6.0 或更高版本)
|
||||
|
||||
> 编译依赖说明:`chai2010/webp` 需要 CGO 与系统 `libwebp`(Debian/Ubuntu:`apt install libwebp-dev`),
|
||||
> 静态图片上传时会自动转换为 WebP。若安装失败,转换将自动回退为原始格式(不影响编译之外的功能)。
|
||||
> 动画 GIF 转 WebP 需要系统安装 `cwebp` 命令行工具,未安装时回退为原始 GIF。
|
||||
|
||||
### 从源码编译
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NekoWheel/NekoBox.git
|
||||
|
||||
cd NekoBox
|
||||
|
||||
go build -o NekoBox
|
||||
```
|
||||
|
||||
### 编辑配置文件
|
||||
|
||||
```bash
|
||||
cp conf/app.sample.ini conf/app.ini
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
./NekoBox web
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
| ---- | ---- |
|
||||
| `./NekoBox web` | 启动 Web 服务 |
|
||||
| `./NekoBox censor` | 批量补审未审查的提问/回答(需配置文本审查服务) |
|
||||
| `./NekoBox uid` | 为历史用户补生成 UID |
|
||||
| `./NekoBox ban --email [email protected]` | 封禁用户并连带清理其提问、追问、私聊与消息(不可恢复,执行前二次确认) |
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **匿名提问 / 私密提问**:支持匿名或登录后提问,可标记私密(仅通过 token 链接可见)
|
||||
- **问题置顶 / 精选回答**:提问箱主人在管理页置顶提问,公开主页置顶优先展示
|
||||
- **回答后追问**:提问者凭 token 可对已回答的问题最多追问 3 次,主人可回复
|
||||
- **Markdown 回答**:回答支持 Markdown 排版(服务端渲染 + 白名单过滤防 XSS)
|
||||
- **分享图**:提问箱主页分享图与单题分享图(含二维码,复用同一绘制模块)
|
||||
- **页面密码 / 提问密码**:可选为提问箱页面设置访问密码、为提问设置密码
|
||||
- **同 IP 重复内容拦截**:同一 IP 在 5 分钟内提交相同内容会被拒绝
|
||||
- **匿名 / 登录分级限流**:登录用户享有更高请求额度(端点级滑动窗口限流)
|
||||
- **IP 黑名单**:支持精确 IP 与 CIDR,命中后跳转到指定 URL
|
||||
- **私聊**:登录用户可向开启私聊的提问箱发起会话,支持图片、拉黑、每日上限
|
||||
- **提问箱统计看板**:管理页展示总提问 / 已回答 / 未回答 / 私密 / 置顶 / 私聊统计
|
||||
- **全文搜索**:管理页可按关键词搜索自己提问箱的提问与回答
|
||||
- **热门问题 / 标签榜**:探索页展示热门标签与最新活跃的公开提问
|
||||
- **数据导出**:支持 Excel 导出与 JSON 全量导出(含追问与私聊,可迁移备份)
|
||||
- **多语言**:简体中文 / 繁體中文 / English / 日本語,支持按 cookie、用户配置与提问箱配置切换
|
||||
- **深色模式**:全站一键切换深/浅色(cookie 持久化)
|
||||
- **内容审查**:七牛 + 阿里云文本审查(可配置),结果带 30 天缓存
|
||||
|
||||
|
||||
## 架构
|
||||
|
||||

|
||||

|
||||
|
||||
NekoBox 使用 GitHub Actions 进行持续集成和部署。
|
||||
|
||||
当用户访问 NekoBox 时,请求将会被发送至阿里云 CDN,CDN 的访问日志将会被实时推送到阿里云日志服务
|
||||
(SLS)。日志数据将在 SLS 中存储 180 天,用于审计。
|
||||
|
||||
用户的信息、提问和回答将被存储在 MySQL 数据库中。
|
||||
|
||||
用户的会话、CSRF 令牌和电子邮件验证令牌将被暂时存储在 Redis 中。
|
||||
|
||||
用户的整个请求和响应链路将被上传到 Uptrace 用于调试。这些数据将被储存 30 天。管理员可以使用用户提供的 `TraceID`
|
||||
来追踪查询指定的请求。
|
||||
|
||||
当用户提交提问时,问题的内容将被发送到七牛文本审查服务进行审查。如果提问内容存在问题,该内容将被发送到阿里云文本审查服务进行二次审查。
|
||||
如果内容审查仍未通过,该提问将被拒绝发送。这是由于七牛文本审查服务不是很准确,一些非冒犯性的内容可能被七牛文本审查误报。
|
||||
|
||||
当用户收到新的提问时,阿里云邮件服务(DM)会向用户的邮箱发送一封邮件。
|
||||
|
||||
你可以在主页查看 NekoBox 的更新日志,也欢迎访问赞助页面来打钱支持 NekoBox。 更新日志和赞助商名单存储在独立部署的
|
||||
Pocketbase 服务中。
|
||||
|
||||
## 开源协议
|
||||
|
||||
MIT License
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := cli.NewApp()
|
||||
app.Name = "TamaBox"
|
||||
app.Description = "Anonymous question box"
|
||||
|
||||
app.Commands = []*cli.Command{
|
||||
cmd.Web,
|
||||
cmd.Censor,
|
||||
cmd.Uid,
|
||||
cmd.Ban,
|
||||
cmd.Restore,
|
||||
cmd.Reports,
|
||||
cmd.Restrict,
|
||||
cmd.Unrestrict,
|
||||
cmd.HidePublic,
|
||||
cmd.Honeypot,
|
||||
cmd.Backup,
|
||||
cmd.RestoreImages,
|
||||
cmd.R2Outbox,
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to start application")
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
; ── 关于配置键名 ──────────────────────────────────────────────────────────
|
||||
; ⚠️ 键名写错时,程序启动会 WARN 提示「存在无法识别的键」并给出最接近的合法键名。
|
||||
; 对于少量已知的常见误拼(如 honeypot_retention_days → honeypot_retain_days),
|
||||
; 程序会自动纠正并在日志里说明纠正了什么。
|
||||
; 但请优先写对键名 —— 自动纠正只覆盖已知的误拼,不认识的键一律不猜。
|
||||
|
||||
[app]
|
||||
production = true
|
||||
; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接
|
||||
external_url = "https://box.shiroko.one"
|
||||
; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠
|
||||
; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。
|
||||
icp = ""
|
||||
; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||
default_lang = ""
|
||||
uptrace_dsn = ""
|
||||
qiniu_access_key = ""
|
||||
qiniu_access_secret = ""
|
||||
aliyun_access_key = ""
|
||||
aliyun_access_key_secret = ""
|
||||
; 默认分享图背景图链接(1280x720)
|
||||
share_background = "https://your-default-background.jpg"
|
||||
; 分享卡绘图用的中文字体(相对项目根目录)。字体文件不在仓库里,必须自备一份,
|
||||
; 否则分享卡接口会返回 500「字体文件未配置或无法加载」。可用 Noto Sans SC / 思源黑体,
|
||||
; 支持 .otf/.ttf/.ttc;放好后无需重启生效。
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf"
|
||||
|
||||
[security]
|
||||
enable_text_censor = true
|
||||
; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
||||
text_censor_source = auto
|
||||
; 留空等同于 auto,即引入该配置前的历史行为。
|
||||
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
||||
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
||||
; 会让发布/私信整站不可用,且现象隐蔽、只在日志可见)。
|
||||
; 凭据位置:[app] 的 qiniu_access_key / qiniu_access_secret、
|
||||
; aliyun_access_key / aliyun_access_key_secret。
|
||||
; ⚠️ 配好凭据 ≠ 可用:阿里云通道走的是「内容安全 2.0 / 文本审核增强版」
|
||||
; (接口 TextModeration,接入点 green-cip.cn-shanghai.aliyuncs.com),
|
||||
; 开通前提只要求【实名认证,个人账号即可】,不需要企业认证。
|
||||
; 开通入口:内容安全 2.0 控制台 → 开通「内容审核增强版」按量付费
|
||||
; (开通本身不收费,按实际调用量出账;文本审核标准 7.5 元/万次)。
|
||||
; 用 RAM 子账号调用需授予 AliyunYundunGreenWebFullAccess;欠费停服同理。
|
||||
; 审核场景固定为 comment_detection(公聊评论检测,覆盖涉政/涉暴/涉黄等底线维度)。
|
||||
; 单次 content 上限 600 字符,超长文本由代码分段审核(段间带重叠,避免跨段漏判)。
|
||||
; ⚠️ 不要再去开通「内容安全 1.0」:它要求企业实名认证,个人账号无法开通,
|
||||
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
||||
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
||||
; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist =
|
||||
; 黑名单IP访问时强制跳转到该URL
|
||||
ip_blacklist_redirect_url =
|
||||
; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
trust_proxy = false
|
||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||
; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址
|
||||
; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。
|
||||
; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。
|
||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||
;
|
||||
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
||||
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
||||
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
|
||||
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
|
||||
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
|
||||
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
|
||||
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
|
||||
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
|
||||
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
|
||||
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
|
||||
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
|
||||
trusted_proxies =
|
||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
|
||||
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
|
||||
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
|
||||
; 回源的连接必然由本机进程发起,外部无法伪造。
|
||||
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
|
||||
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
|
||||
; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。
|
||||
; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段),
|
||||
; 不是 CDN 的全量 CIDR 清单。
|
||||
; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。
|
||||
csp = enforce
|
||||
; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与
|
||||
; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com
|
||||
; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域;
|
||||
; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。
|
||||
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
|
||||
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
|
||||
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
|
||||
; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装,
|
||||
; 所以只在「怀疑误伤、想先看证据」时临时用。
|
||||
; off:完全不下发(排障逃生口)。
|
||||
; 默认值的前提是白名单已用真浏览器逐页巡检过:
|
||||
; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。
|
||||
; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。
|
||||
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
|
||||
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
|
||||
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
|
||||
; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。
|
||||
; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名
|
||||
; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole)
|
||||
; 与 challenges.cloudflare.com。
|
||||
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
|
||||
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
|
||||
; off:完全不下发(排障逃生口)。
|
||||
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
|
||||
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
|
||||
; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。
|
||||
csp_report_uri =
|
||||
; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、
|
||||
; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器)
|
||||
; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数),
|
||||
; 进程重启即清零。多副本前必须先把计数迁到 Redis。
|
||||
; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间),
|
||||
; 重启丢的只是生效状态,账本还在。
|
||||
enable_auto_ban = true
|
||||
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||
ban_track_notfound = false
|
||||
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||
ban_threshold_sensitive = 10
|
||||
; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||
ban_threshold_404 = 20
|
||||
; 统计窗口时长(秒)
|
||||
ban_window_seconds = 60
|
||||
; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
ban_duration_minutes = 15
|
||||
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
|
||||
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
|
||||
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
|
||||
security_alert_email =
|
||||
|
||||
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
|
||||
; ⚠️ 本段一律「注释独占一行、值单独一行」。
|
||||
; 配置加载器开启了 IgnoreInlineComment,行尾 `; 注释` 会**并入值**:
|
||||
; honeypot_max_rows = 200000 ; 说明 → 值变成 "200000 ; 说明" → int 解析失败
|
||||
; honeypot_paths = ; 说明 → 值变成 " ; 说明" → 被切成一堆假路径
|
||||
; 后果是该项静默回退零值,而日志里只会说「配置值不对」,很难联想到是注释造成的。
|
||||
; 本文件其余段落已全部改成这个写法,新增配置项时请保持一致。
|
||||
|
||||
; 开启后,扫描器探测后台路径(/wp-admin、/.ssh、/.env 等)时不再返回 302,
|
||||
; 而是返回一个仿 WordPress 的假登录页,并把其提交的用户名/密码写入取证文件。
|
||||
; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集。
|
||||
; 假页面只含 canary 标记与假表单,绝不含任何真实信息。
|
||||
enable_honeypot = false
|
||||
|
||||
; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。留空 = 只用内置清单。
|
||||
; 用 `TamaBox honeypot paths` 可查看最终生效的完整清单。
|
||||
honeypot_paths =
|
||||
|
||||
; 蜜罐命中记录的保留天数(取证用途),到期自动清理。
|
||||
; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略,
|
||||
; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。
|
||||
; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。
|
||||
; 要关闭自动清理请用 honeypot_auto_clean = false。
|
||||
honeypot_retain_days = 30
|
||||
|
||||
; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。
|
||||
; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
|
||||
; 不清理的话表体积只增不减。关闭后仍可手动 `TamaBox honeypot prune`。
|
||||
honeypot_auto_clean = true
|
||||
|
||||
; 蜜罐命中记录的行数上限(第二道闸)。显式写 0 = 不限(会告警提醒表只增不减)。
|
||||
; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内把表撑爆。
|
||||
; 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||||
honeypot_max_rows = 200000
|
||||
|
||||
; 「提交凭据」多少次即封禁。默认 1(一次即封):往假后台里填账号密码是证据最强
|
||||
; 的行为,抓到一次就足够。⚠️ 不作用于纯 GET 探测 —— 那类请求走 ban_threshold_sensitive,
|
||||
; 否则扫描器看一眼假登录页就被封,永远走不到提交凭据那一步。
|
||||
honeypot_threshold = 1
|
||||
|
||||
; 蜜罐封禁时长(分钟)。缺省 1440(24 小时),刻意远长于 ban_duration_minutes:
|
||||
; 命中蜜罐意味着对方已在提交账号密码,证据强度远高于单纯路径探测。
|
||||
; 显式写 0 = 回退 ban_duration_minutes。
|
||||
; ⚠️ 蜜罐封禁与 enable_auto_ban 相互独立:即使关掉自动封禁,
|
||||
; 只要 enable_honeypot = true,蜜罐命中仍会封禁并记录。
|
||||
honeypot_ban_minutes = 1440
|
||||
|
||||
; 凭据取证文件的存放目录。
|
||||
; ⚠️ 捕获到的用户名/密码**不入库**,只追加到这个目录下的按天文件
|
||||
; (creds-YYYY-MM-DD.jsonl,一行一条 JSON)。原因:明文账号密码一旦入库,
|
||||
; 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
|
||||
; 目录会被强制为 0700、文件 0600;相对路径以进程工作目录为基准。
|
||||
; 目录不可写时只降级为「不保存凭据」,不影响站点启动。
|
||||
; 建议单独挂一个卷,并让它不参与备份。
|
||||
honeypot_cred_dir = data/honeypot-creds
|
||||
|
||||
; ── 安全状况汇总报告(周报 / 月报)──────────────────────────────
|
||||
;
|
||||
; 把周期内的蜜罐命中按来源 IP 聚合,再通过公共 IP 库定位到城市,
|
||||
; 生成「世界地图 + 国家/城市排行 + 来源明细」的 HTML 报告并邮件送达。
|
||||
;
|
||||
; ⚠️ 与 cdncheck 离线指纹的区别:那个只认云厂商 / CDN / WAF 的 ASN,
|
||||
; 肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
|
||||
; 下面的 IP 库回答的是「这个 IP 在哪儿」,是另一个维度。
|
||||
;
|
||||
; 数据源(ip_geo_provider):
|
||||
; auto(缺省) = ip-api.com 批量打底(全球、无需 key)+ 腾讯精修国内
|
||||
; ipapi = 只用 ip-api.com
|
||||
; tencent = 只用腾讯位置服务(必须配 key,否则降级 ipapi)
|
||||
; none = 不查询任何外部服务,攻击者 IP 一个字节都不外发
|
||||
; 隐私边界:auto / ipapi 会把攻击者 IP 发给 ip-api.com,tencent 发给腾讯。
|
||||
ip_geo_provider = auto
|
||||
|
||||
; 腾讯位置服务 key(lbs.qq.com 申请,个人免费额度足够)。
|
||||
; 两个用途:① 精修国内 IP 的省市与坐标;② 报告里可交互地图的底图。
|
||||
; 留空 → 跳过国内精修,报告里只保留离线地图(经纬网 + 气泡)。
|
||||
ip_geo_tencent_key =
|
||||
|
||||
; 周期性安全汇总邮件。缺省全开。
|
||||
; 发送时机:周报在每周一、月报在每月 1 日之后的第一轮检查发出,覆盖
|
||||
; 「上一个完整周期」;服务停机错过时间窗会在重新上线后补发;
|
||||
; 幂等由 security_report_logs 的 period_key 唯一索引保证,不会重复发。
|
||||
; 失败最多重试 5 次后停下(SMTP 配错时无限重试只会刷满日志),
|
||||
; 之后可用 TamaBox honeypot report 手动补发。
|
||||
honeypot_report_enabled = true
|
||||
honeypot_report_weekly = true
|
||||
honeypot_report_monthly = true
|
||||
|
||||
; 报告收件人。留空回退 security_alert_email。
|
||||
; 单独一个键是为了能把「随时发生的封禁告警」与「定期汇总」分投到不同邮箱
|
||||
; (例如告警进手机推送邮箱、汇总进归档邮箱)。
|
||||
honeypot_report_email =
|
||||
|
||||
; 报告文件(HTML + PNG)的存放目录,按 0700 创建、文件 0600。
|
||||
; 里面有攻击者 IP 明细,不要放进 Web 可访问的目录。
|
||||
; 想先看内容不发送:TamaBox honeypot report --period week --no-email
|
||||
honeypot_report_dir = data/reports
|
||||
|
||||
; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。
|
||||
; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。
|
||||
; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝,
|
||||
; 否则等于把配置文件当响应体吐给攻击者。
|
||||
; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试,
|
||||
; 每张图都会照常占用出网带宽。
|
||||
ban_image_path =
|
||||
|
||||
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
||||
default_moderation_strictness =
|
||||
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
||||
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
||||
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
||||
; 若希望默认更少误伤,改为 standard 即可,无需改代码。
|
||||
; 三档的拦截范围见 internal/security/censor/policy.go,
|
||||
; 适用于提问、投票、私聊三类内容。
|
||||
|
||||
[server]
|
||||
port = 80
|
||||
; 绑定监听地址(含端口)。Cloudflare Tunnel 部署时强烈建议设为 127.0.0.1:<port>,
|
||||
; 使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连伪造 CF-Connecting-IP。
|
||||
; 留空(默认)则监听所有网卡 ":port"。
|
||||
listen =
|
||||
salt = ""
|
||||
# ⚠️ 必填:密码哈希盐,留空会导致启动失败(已在 conf.Init 强制校验)
|
||||
xsrf_key = ""
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
user = "nekobox"
|
||||
password = ""
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "nekobox"
|
||||
schema = "public"
|
||||
# 仅在已确认需要自动清理重复 email/domain 时开启;默认关闭,避免启动时改写账号数据。
|
||||
dedupe_unique_fields = false
|
||||
|
||||
[redis]
|
||||
addr = "127.0.0.1:6379"
|
||||
password = ""
|
||||
|
||||
[recaptcha]
|
||||
; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||
captcha_type = recaptcha
|
||||
domain = "https://www.recaptcha.net"
|
||||
site_key = ""
|
||||
server_key = ""
|
||||
turnstile_style = false
|
||||
|
||||
[upload]
|
||||
default_avatar = ""
|
||||
default_background = ""
|
||||
|
||||
# User avatar and background.
|
||||
aliyun_endpoint = ""
|
||||
aliyun_access_id = ""
|
||||
aliyun_access_secret = ""
|
||||
aliyun_bucket = ""
|
||||
aliyun_bucket_cdn_host = ""
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret =""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
# User uploaded images.
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret = ""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
[mail]
|
||||
account = ""
|
||||
password = ""
|
||||
port = 465
|
||||
smtp = ""
|
||||
|
||||
[notify]
|
||||
; ── 站级广播式通知通道(可选,均留空则只走 [mail] 点对点邮件)──────────────
|
||||
; webhook_url:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
|
||||
; (新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复)统一 POST JSON 推送。
|
||||
; 留空 = 不推送站级 Webhook。
|
||||
; 注意:不同平台的 webhook 格式不同,这里发送统一 JSON(含 content/event/title/
|
||||
; body/link/time 字段),Discord 可直接用 content 字段展示;其他平台建议在
|
||||
; webhook 前端加一层转发做格式适配。
|
||||
; webhook_url = ""
|
||||
; telegram_bot_token:Telegram Bot token,配合每个箱主自填的 Chat ID 实现
|
||||
; 「新提问」点对点通知。留空 = 禁用 Telegram 通知。
|
||||
; telegram_bot_token = ""
|
||||
|
||||
[limits]
|
||||
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
||||
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
||||
; 键留空或填 0/负数时自动回退内置默认值,不会导致启动失败,但也不会按你的意图生效,
|
||||
; 请按需填写正整数。本文件启用 IgnoreInlineComment,行内分号注释会被并入配置值,
|
||||
; 因此下面注释一律独占整行、勿写行内注释。
|
||||
|
||||
; ── 标签与屏蔽词(账号设置页)──────────────────────────────────────────────
|
||||
; 最多标签个数
|
||||
max_tags = 5
|
||||
; 单个标签最大字符数
|
||||
max_tag_len = 5
|
||||
; 最多屏蔽词个数
|
||||
max_block_words = 10
|
||||
; 单个屏蔽词最大字符数
|
||||
max_block_word_len = 10
|
||||
|
||||
; ── 投票 ────────────────────────────────────────────────────────────────────
|
||||
; 参与者填写的「自定义补充选项」(其他/自定义)最大字符数
|
||||
custom_answer_len = 200
|
||||
; 站长创建投票时单个「选项」文本最大字符数(预设选项)
|
||||
vote_option_len = 300
|
||||
|
||||
; ── 提问 / 回答 / 追问 ─────────────────────────────────────────────────────
|
||||
; 提问正文最大字符数(访客提交的问题内容)
|
||||
question_content_len = 1000
|
||||
; 回答正文最大字符数(箱主对提问的回答,及对追问的回复,共用)
|
||||
answer_content_len = 2000
|
||||
; 追问正文最大字符数(提问者被回答后继续追问的内容)
|
||||
followup_content_len = 2000
|
||||
|
||||
; ── 私聊 ────────────────────────────────────────────────────────────────────
|
||||
; 单条消息(发送/回复)最大字符数
|
||||
chat_message_len = 2000
|
||||
; 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
|
||||
; 会话条数上限;新注册用户的默认值即该硬顶。
|
||||
chat_max_messages = 200
|
||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||
daily_message_limit_max = 999
|
||||
|
||||
[announcement]
|
||||
; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ──────────────────────
|
||||
; content :默认文案,留空 = 关闭公告(任一语言文案非空也视为开启)。
|
||||
; 用 \n 表示换行;http(s) 地址会自动变成链接;
|
||||
; 内容按纯文本渲染,不支持 HTML 标签(写 <b> 会原样显示)。
|
||||
; content_zh_cn / content_zh_tw / content_en / content_ja:
|
||||
; 分语言文案,留空则回退到 content;content 也留空时该语言不显示公告。
|
||||
; timezone:start / end 里不带偏移的「裸时间」按哪个时区解释,默认 Asia/Shanghai。
|
||||
; 可填任意 IANA 时区名(UTC / America/New_York …);二进制已内嵌 tzdata,
|
||||
; 容器不需要另装时区包。带偏移的 RFC3339 以自带偏移为准。
|
||||
; start / end:展示时间窗,左闭右开 —— 到 end 那一刻下线。留空表示该侧不限:
|
||||
; 只写 start = 定时上线;只写 end = 现在起展示到某时刻;都留空 = 一直展示。
|
||||
; 支持 2026-10-25 02:00 / 2026-10-25 02:00:59 / 2026-10-25 / RFC3339
|
||||
; (2026-10-25T02:00:00+08:00)。
|
||||
; 时间或时区写错不会导致启动失败:日志 WARN 后关闭公告。
|
||||
; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one"
|
||||
; content_en = "Maintenance 02:00-04:00 on Oct 25 (UTC+8). Details: https://status.shiroko.one"
|
||||
; start = "2026-10-24 00:00"
|
||||
; end = "2026-10-25 04:00"
|
||||
content = ""
|
||||
content_zh_cn = ""
|
||||
content_zh_tw = ""
|
||||
content_en = ""
|
||||
content_ja = ""
|
||||
start = ""
|
||||
end = ""
|
||||
timezone = Asia/Shanghai
|
||||
|
||||
[theme]
|
||||
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
|
||||
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
|
||||
; 首页、探索等站方页面保持站点默认外观。
|
||||
; default :站点默认主题(箱主未选择时使用)。填 default 或留空 = 现有外观。
|
||||
; available :启用哪些主题,逗号分隔,同时是箱主设置页的白名单;
|
||||
; 列了但 static/css/themes/<slug>.css 不存在的会被忽略并告警。
|
||||
; 新增主题:把 static/css/themes/_scaffold.css 复制成 <slug>.css,
|
||||
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
|
||||
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
|
||||
default = default
|
||||
available = default,sakura,shiroko,new-sakura
|
||||
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
|
||||
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
|
||||
Binary file not shown.
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Insert form-validation i18n keys (form.*) into all four dictionaries.
|
||||
import io, os
|
||||
|
||||
base = os.path.join(os.path.dirname(__file__), "..", "internal", "i18n")
|
||||
|
||||
# key -> {lang: text}
|
||||
keys = {
|
||||
"form.register.email": {
|
||||
"zh_cn": "请填写有效的电子邮箱地址(不超过 100 个字符)",
|
||||
"zh_tw": "請填寫有效的電子郵件地址(不超過 100 個字元)",
|
||||
"en": "Please enter a valid email address (max 100 characters)",
|
||||
"ja": "有効なメールアドレスを入力してください(100文字以内)",
|
||||
},
|
||||
"form.register.domain": {
|
||||
"zh_cn": "个性域名只能包含字母、数字和下划线,长度为 3 至 20 位",
|
||||
"zh_tw": "個性網域只能包含字母、數字與底線,長度為 3 至 20 位",
|
||||
"en": "The custom domain may only contain letters, digits and underscores, and must be 3–20 characters long",
|
||||
"ja": "カスタムドメインは半角英数字とアンダースコアのみ使用でき、3〜20文字である必要があります",
|
||||
},
|
||||
"form.register.name": {
|
||||
"zh_cn": "昵称不能为空,且不超过 20 个字符",
|
||||
"zh_tw": "暱稱不能為空,且不超過 20 個字元",
|
||||
"en": "Nickname is required and must be at most 20 characters",
|
||||
"ja": "ニックネームは必須で、20文字以内である必要があります",
|
||||
},
|
||||
"form.register.password": {
|
||||
"zh_cn": "请设置 8 至 30 位的密码",
|
||||
"zh_tw": "請設定 8 至 30 位的密碼",
|
||||
"en": "Please set a password of 8–30 characters",
|
||||
"ja": "8〜30文字のパスワードを設定してください",
|
||||
},
|
||||
"form.register.repeatPassword": {
|
||||
"zh_cn": "两次输入的密码不一致",
|
||||
"zh_tw": "兩次輸入的密碼不一致",
|
||||
"en": "The two passwords do not match",
|
||||
"ja": "入力した2つのパスワードが一致しません",
|
||||
},
|
||||
"form.register.recaptcha": {
|
||||
"zh_cn": "无法加载人机验证,请刷新页面或切换网络后重试",
|
||||
"zh_tw": "無法人機驗證,請重新整理頁面或更換網路後重試",
|
||||
"en": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
|
||||
"ja": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
|
||||
},
|
||||
"form.email.required": {
|
||||
"zh_cn": "请填写有效的电子邮箱地址",
|
||||
"zh_tw": "請填寫有效的電子郵件地址",
|
||||
"en": "Please enter a valid email address",
|
||||
"ja": "有効なメールアドレスを入力してください",
|
||||
},
|
||||
"form.login.password": {
|
||||
"zh_cn": "请输入密码",
|
||||
"zh_tw": "請輸入密碼",
|
||||
"en": "Please enter your password",
|
||||
"ja": "パスワードを入力してください",
|
||||
},
|
||||
"form.recaptcha": {
|
||||
"zh_cn": "请完成人机验证",
|
||||
"zh_tw": "請完成人機驗證",
|
||||
"en": "Please complete the CAPTCHA verification",
|
||||
"ja": "CAPTCHA認証を完了してください",
|
||||
},
|
||||
"form.recover.newPassword": {
|
||||
"zh_cn": "请设置 8 至 30 位的新密码",
|
||||
"zh_tw": "請設定 8 至 30 位的新密碼",
|
||||
"en": "Please set a new password of 8–30 characters",
|
||||
"ja": "8〜30文字の新しいパスワードを設定してください",
|
||||
},
|
||||
"form.recover.repeatPassword": {
|
||||
"zh_cn": "两次输入的新密码不一致",
|
||||
"zh_tw": "兩次輸入的新密碼不一致",
|
||||
"en": "The two new passwords do not match",
|
||||
"ja": "入力した2つの新しいパスワードが一致しません",
|
||||
},
|
||||
"form.question.content": {
|
||||
"zh_cn": "问题内容不能为空,且不超过 2000 个字符",
|
||||
"zh_tw": "問題內容不能為空,且不超過 2000 個字元",
|
||||
"en": "Question content is required and must be at most 2000 characters",
|
||||
"ja": "質問内容は必須で、2000文字以内である必要があります",
|
||||
},
|
||||
"form.question.receiveReplyEmail": {
|
||||
"zh_cn": "请填写有效的电子邮箱地址以接收回复",
|
||||
"zh_tw": "請填寫有效的電子郵件地址以接收回覆",
|
||||
"en": "Please enter a valid email address to receive replies",
|
||||
"ja": "返信を受け取るための有効なメールアドレスを入力してください",
|
||||
},
|
||||
"form.answer.content": {
|
||||
"zh_cn": "回答内容不能为空,且不超过 2000 个字符",
|
||||
"zh_tw": "回答內容不能為空,且不超過 2000 個字元",
|
||||
"en": "Answer content is required and must be at most 2000 characters",
|
||||
"ja": "回答内容は必須で、2000文字以内である必要があります",
|
||||
},
|
||||
"form.followup.content": {
|
||||
"zh_cn": "追问内容不能为空,且不超过 2000 个字符",
|
||||
"zh_tw": "追問內容不能為空,且不超過 2000 個字元",
|
||||
"en": "Follow-up content is required and must be at most 2000 characters",
|
||||
"ja": "フォローアップ内容は必須で、2000文字以内である必要があります",
|
||||
},
|
||||
"form.followup.answer": {
|
||||
"zh_cn": "追问回答不能为空,且不超过 2000 个字符",
|
||||
"zh_tw": "追問回答不能為空,且不超過 2000 個字元",
|
||||
"en": "Follow-up answer is required and must be at most 2000 characters",
|
||||
"ja": "フォローアップの回答は必須で、2000文字以内である必要があります",
|
||||
},
|
||||
"form.profile.name": {
|
||||
"zh_cn": "昵称不能为空,且不超过 20 个字符",
|
||||
"zh_tw": "暱稱不能為空,且不超過 20 個字元",
|
||||
"en": "Nickname is required and must be at most 20 characters",
|
||||
"ja": "ニックネームは必須で、20文字以内である必要があります",
|
||||
},
|
||||
"form.profile.newPassword": {
|
||||
"zh_cn": "新密码长度不能超过 30 个字符",
|
||||
"zh_tw": "新密碼長度不能超過 30 個字元",
|
||||
"en": "New password must be at most 30 characters",
|
||||
"ja": "新しいパスワードは30文字以内である必要があります",
|
||||
},
|
||||
"form.profile.intro": {
|
||||
"zh_cn": "个人介绍不能为空,且不超过 100 个字符",
|
||||
"zh_tw": "個人介紹不能為空,且不超過 100 個字元",
|
||||
"en": "Bio is required and must be at most 100 characters",
|
||||
"ja": "自己紹介は必須で、100文字以内である必要があります",
|
||||
},
|
||||
"form.profile.colorHex": {
|
||||
"zh_cn": "请填写 7 位十六进制颜色代码(含 #)",
|
||||
"zh_tw": "請填寫 7 位十六進制顏色代碼(含 #)",
|
||||
"en": "Please enter a 7-character hex color code (including #)",
|
||||
"ja": "7文字の16進数カラーコード(#を含む)を入力してください",
|
||||
},
|
||||
"form.profile.dotscale": {
|
||||
"zh_cn": "请填写二维码点大小",
|
||||
"zh_tw": "請填寫 QR Code 點大小",
|
||||
"en": "Please enter the QR code dot size",
|
||||
"ja": "QRコードの点の大きさを入力してください",
|
||||
},
|
||||
"form.profile.backgroundimagealpha": {
|
||||
"zh_cn": "请填写二维码背景透明度",
|
||||
"zh_tw": "請填寫 QR Code 背景透明度",
|
||||
"en": "Please enter the QR code background opacity",
|
||||
"ja": "QRコードの背景の不透明度を入力してください",
|
||||
},
|
||||
"form.profile.linkMax": {
|
||||
"zh_cn": "主页链接长度不能超过 30 个字符",
|
||||
"zh_tw": "主頁連結長度不能超過 30 個字元",
|
||||
"en": "Profile link must be at most 30 characters",
|
||||
"ja": "プロフィールリンクは30文字以内である必要があります",
|
||||
},
|
||||
"form.profile.passwordMax": {
|
||||
"zh_cn": "密码长度不能超过 30 个字符",
|
||||
"zh_tw": "密碼長度不能超過 30 個字元",
|
||||
"en": "Password must be at most 30 characters",
|
||||
"ja": "パスワードは30文字以内である必要があります",
|
||||
},
|
||||
"form.profile.chatMaxMessages": {
|
||||
"zh_cn": "私聊消息上限需为 1 至 50 之间的整数",
|
||||
"zh_tw": "私聊訊息上限需為 1 至 50 之間的整數",
|
||||
"en": "The chat message limit must be an integer between 1 and 50",
|
||||
"ja": "チャットメッセージ上限は1〜50の整数である必要があります",
|
||||
},
|
||||
}
|
||||
|
||||
files = {
|
||||
"zh_cn": "dict_zh_cn.go",
|
||||
"zh_tw": "dict_zh_tw.go",
|
||||
"en": "dict_en.go",
|
||||
"ja": "dict_ja.go",
|
||||
}
|
||||
|
||||
# Build inserted block (sorted by key for determinism).
|
||||
ordered = sorted(keys.keys())
|
||||
block_lines = ["\n\t// 表单校验", " // 表单校验 (form validation)"]
|
||||
# dedupe comment for non-en langs not needed; use single comment line per lang appropriately.
|
||||
for lang, fname in files.items():
|
||||
path = os.path.join(base, fname)
|
||||
with io.open(path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
# Insert before the final standalone "}" that closes the map.
|
||||
idx = text.rfind("\n}")
|
||||
if idx == -1:
|
||||
raise SystemExit("cannot find closing brace in " + fname)
|
||||
# Build entries.
|
||||
lines = ["\n\t// 表单校验"]
|
||||
for k in ordered:
|
||||
v = keys[k][lang]
|
||||
# Escape double quotes (none expected, but be safe).
|
||||
v = v.replace('"', '\\"')
|
||||
lines.append('\t"%s":\t"%s",' % (k, v))
|
||||
insertion = "\n".join(lines) + "\n"
|
||||
new_text = text[:idx] + insertion + text[idx:]
|
||||
with io.open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_text)
|
||||
print("updated", fname, "with", len(ordered), "keys")
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# bench-cdn.sh — UIkit CDN 测速脚本(国内 / 海外双视角)
|
||||
#
|
||||
# 用途:对比"自建 R2 自托管 uikit"与"公共 CDN (cdnjs.snrat.com)"的加载速度,
|
||||
# 用于验证/复测 2026-09-05 的 CDN 迁移是否持续生效。
|
||||
#
|
||||
# 用法:
|
||||
# bash dev/bench-cdn.sh cn # 国内本机:每资源 5 次全量下载取中位
|
||||
# bash dev/bench-cdn.sh global # 海外:check-host.net 多区域节点 (p50/p90)
|
||||
#
|
||||
# 数据结论参考(2026-09-05 基线,国内中位):
|
||||
# css R2 0.864s -> snrat 0.200s (4.3x)
|
||||
# js R2 0.796s -> snrat 0.163s (4.9x)
|
||||
# icons R2 0.824s -> snrat 0.177s (4.7x)
|
||||
# 海外(check-host p50):css 打平;js R2 372ms vs snrat 647ms (R2 略快)
|
||||
# 结论:站点访客以国内为主 -> snrat 净收益大;海外属轻微下降可接受。
|
||||
# ============================================================================
|
||||
|
||||
OLD_CSS="https://r2.337845818.best/tamabox/cdn/expire-1-y/uikit/3.3.3/css/modify-black-font/uikit.min.css"
|
||||
NEW_CSS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"
|
||||
OLD_JS="https://r2.337845818.best/tamabox/cdn/lib/uikit/3.3.3/js/uikit.min.js"
|
||||
NEW_JS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"
|
||||
OLD_ICONS="https://r2.337845818.best/tamabox/cdn/lib/uikit/3.3.3/js/uikit-icons.min.js"
|
||||
NEW_ICONS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"
|
||||
|
||||
# ---------- 国内:全量下载计时 ----------
|
||||
bench_cn() {
|
||||
echo "== 国内本机:每资源 5 次全量下载(秒),取中位 =="
|
||||
local pairs=(
|
||||
"OLD-css (R2 modify):$OLD_CSS"
|
||||
"NEW-css (snrat) :$NEW_CSS"
|
||||
"OLD-js (R2) :$OLD_JS"
|
||||
"NEW-js (snrat) :$NEW_JS"
|
||||
"OLD-icons (R2) :$OLD_ICONS"
|
||||
"NEW-icons (snrat) :$NEW_ICONS"
|
||||
)
|
||||
for entry in "${pairs[@]}"; do
|
||||
local name="${entry%%:*}"; local url="${entry#*:}"
|
||||
local vals=()
|
||||
for i in 1 2 3 4 5; do
|
||||
vals+=("$(curl -sS -o /dev/null -w '%{time_total}' --max-time 20 "$url" 2>/dev/null)")
|
||||
done
|
||||
local sorted=($(printf '%s\n' "${vals[@]}" | sort -n))
|
||||
local med="${sorted[2]}"
|
||||
printf '%-20s : [%s] 中位=%ss\n' "$name" "${vals[*]}" "$med"
|
||||
done
|
||||
}
|
||||
|
||||
# ---------- 海外:check-host.net 多区域节点 ----------
|
||||
probe_global() {
|
||||
local name="$1" url="$2"
|
||||
local enc
|
||||
enc=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$url")
|
||||
local rid
|
||||
rid=$(curl -sS -H "Accept: application/json" \
|
||||
"https://check-host.net/check-http?host=$enc&max_nodes=12" 2>/dev/null \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin).get('request_id',''))")
|
||||
[ -z "$rid" ] && { echo "$name: 请求失败"; return; }
|
||||
sleep 8
|
||||
curl -sS -H "Accept: application/json" "https://check-host.net/check-result/$rid" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import sys,json,statistics
|
||||
d=json.load(sys.stdin)
|
||||
ms=[]
|
||||
for node,arr in (d or {}).items():
|
||||
if not arr or not arr[0]: continue
|
||||
r=arr[0]
|
||||
if len(r)>3 and str(r[3]).startswith('2') and isinstance(r[1],(int,float)):
|
||||
ms.append(r[1]*1000)
|
||||
if ms:
|
||||
ms.sort()
|
||||
print(f'$name | n={len(ms)} | min={ms[0]:.0f} | p50={statistics.median(ms):.0f} | p90={ms[int(len(ms)*0.9)-1]:.0f} | max={ms[-1]:.0f} ms')
|
||||
else:
|
||||
print(f'$name | 无有效节点')
|
||||
"
|
||||
}
|
||||
|
||||
bench_global() {
|
||||
echo "== 海外:check-host.net 多区域节点探测(HTTP 200 的耗时统计)=="
|
||||
probe_global "OLD css (R2 modify)" "$OLD_CSS"
|
||||
probe_global "NEW css (snrat)" "$NEW_CSS"
|
||||
probe_global "OLD js (R2)" "$OLD_JS"
|
||||
probe_global "NEW js (snrat)" "$NEW_JS"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
cn) bench_cn ;;
|
||||
global) bench_global ;;
|
||||
*)
|
||||
echo "用法: bash dev/bench-cdn.sh {cn|global}"
|
||||
echo " cn = 国内本机全量下载测速"
|
||||
echo " global = 海外 check-host.net 多区域探测"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 297 KiB |
@@ -1,11 +0,0 @@
|
||||
## 2024-03-04 21:45:55
|
||||
新增:用户私信功能上线,可在「我的」页查看匿名来信。
|
||||
修复:移动端问题列表间距过窄的问题。
|
||||
优化:首页加载速度提升约 30%。
|
||||
|
||||
## 2024-02-29 00:00:55
|
||||
修复:部分旧浏览器下时间显示异常。
|
||||
新增:修改记录页(本页),将完整更新历史从首页挪出,保持首页简洁。
|
||||
|
||||
## 2023-06-03 13:11:55
|
||||
项目初始化:匿名提问 / 回答的基础流程跑通。
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/template"
|
||||
"github.com/tamakyi/TamaBox/templates"
|
||||
)
|
||||
|
||||
// mergedFuncMap 将项目实际使用的模板函数(含 t 等 i18n 函数)合并为 text/template.FuncMap。
|
||||
// 不使用 FuncMap 直接解析会因 "t" 等函数未定义而误报模板错误。
|
||||
func mergedFuncMap() texttmpl.FuncMap {
|
||||
fm := texttmpl.FuncMap{}
|
||||
for _, m := range template.FuncMap() {
|
||||
for k, v := range m {
|
||||
fm[k] = v
|
||||
}
|
||||
}
|
||||
return fm
|
||||
}
|
||||
|
||||
func main() {
|
||||
hasErr := false
|
||||
_ = fs.WalkDir(templates.FS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".html") {
|
||||
return nil
|
||||
}
|
||||
if _, err := texttmpl.New(path).Funcs(mergedFuncMap()).ParseFS(templates.FS, path); err != nil {
|
||||
fmt.Println("TEMPLATE ERROR:", path, "->", err)
|
||||
hasErr = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if hasErr {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("all templates syntax OK")
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 处置链路部署后自检(L1 停止分发 / L4 整页停用 / CLI 可用性)
|
||||
#
|
||||
# 为什么需要它:本机沙箱起不了 web(无 redis),L1/L4 的 HTTP 行为只能在服务器上验证。
|
||||
# 这个脚本把「施加处置 → 观察 HTTP → 自动撤销」串起来,避免手工操作忘了解除。
|
||||
#
|
||||
# 用法:
|
||||
# BASE_URL=https://box-dev.example.com DOMAIN=<测试箱主域名> TAMABOX=/path/to/NekoBox \
|
||||
# bash dev/devcheck-restrict.sh # 只打印将要执行的命令(dry-run)
|
||||
#
|
||||
# BASE_URL=... DOMAIN=... TAMABOX=... RUN=1 \
|
||||
# bash dev/devcheck-restrict.sh # 真正执行,结束时自动撤销
|
||||
#
|
||||
# 注意:
|
||||
# - DOMAIN 必须是**测试箱主**,不要用真实用户;脚本会临时施加 L4/L1 再撤销。
|
||||
# - 站点默认语言可能是 ja,页面文案断言按日文/英文各查一次,避免误判「没生效」。
|
||||
# - 只依赖 CLI 与 curl,不需要 psql 凭据。
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE_URL=${BASE_URL:?need BASE_URL, e.g. https://box-dev.example.com}
|
||||
DOMAIN=${DOMAIN:?need DOMAIN, a TEST box domain only}
|
||||
TAMABOX=${TAMABOX:-./NekoBox}
|
||||
RUN=${RUN:-0}
|
||||
DAYS=${DAYS:-1}
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
restricted=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
|
||||
check() {
|
||||
local name=$1 want=$2 got=$3
|
||||
if [ "$want" = "$got" ]; then
|
||||
say " PASS $name (=$got)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL $name (want $want, got $got)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; }
|
||||
|
||||
do_restrict() {
|
||||
restricted=1
|
||||
if [ "$RUN" = 1 ]; then
|
||||
"$TAMABOX" restrict --domain "$DOMAIN" --level "$1" --days "$DAYS" --reason "deploy self-check" --yes
|
||||
else
|
||||
say " [dry-run] $TAMABOX restrict --domain $DOMAIN --level $1 --days $DAYS --reason 'deploy self-check' --yes"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ "$RUN" = 1 ] && [ "$restricted" = 1 ]; then
|
||||
say ""
|
||||
say "== 兜底撤销(无论中途是否失败)=="
|
||||
"$TAMABOX" unrestrict --domain "$DOMAIN" || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say "== 0. 二进制是否包含新命令 =="
|
||||
if "$TAMABOX" --help 2>&1 | grep -q 'restrict'; then
|
||||
say " PASS restrict 命令存在"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL 二进制里没有 restrict —— 构建的是旧代码(注意入口是根目录 tamabox.go,或用 ./cmd/)"
|
||||
fail=$((fail + 1))
|
||||
say ""
|
||||
say "结果: 无法继续,先修构建入口。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 1. 基线:处置前箱主页必须可访问 =="
|
||||
base=$(code "$BASE_URL/_/$DOMAIN")
|
||||
if [ "$base" != 200 ]; then
|
||||
say " FAIL 基线不是 200(got $base),后续断言全部无意义,已中止。"
|
||||
say ""
|
||||
say " 302 的常见原因(按概率排序):"
|
||||
say " 1) 域名不存在或账号已停用 —— 先确认 DOMAIN 拼写。查一个真实域名:"
|
||||
say " curl -s $BASE_URL/explore | grep -o 'href=\"/_/[^\"]*\"' | head -5"
|
||||
say " 或在库上执行:select domain from users where deleted_at is null order by id desc limit 5;"
|
||||
say " 2) 该箱主设置了页面密码 —— 此时首页是 200(渲染密码页),不是 302。"
|
||||
say ""
|
||||
say " 注意:302 不能证明任何处置生效。上次自检就出现过「箱主不存在 → 302」被误判成 L4 生效。"
|
||||
exit 1
|
||||
fi
|
||||
say " PASS box page = 200"
|
||||
|
||||
say ""
|
||||
say "== 1b. 停用页是否部署(与处置无关,dry-run 也验)=="
|
||||
check "restricted page reachable" 200 "$(code "$BASE_URL/restricted")"
|
||||
body=$(curl -sL --max-time 10 "$BASE_URL/restricted")
|
||||
if printf '%s' "$body" | grep -qE '一時的に利用できません|temporarily unavailable|暫時無法存取|暂时不可访问'; then
|
||||
say " PASS restricted page copy rendered"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
say " FAIL restricted page copy not found(i18n 或模板没部署上)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
|
||||
if [ "$RUN" != 1 ]; then
|
||||
say ""
|
||||
say "== dry-run:不做状态断言 =="
|
||||
say " 状态没有被改动,断言 L4/L1 只会得到误导性结果。"
|
||||
say " 基线已确认可访问,用 RUN=1 重跑以执行真实验证:"
|
||||
say " RUN=1 BASE_URL=$BASE_URL DOMAIN=$DOMAIN TAMABOX=$TAMABOX bash $0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 2. L4 整页停用 =="
|
||||
do_restrict L4
|
||||
sleep 1
|
||||
check "box page under L4" 302 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
|
||||
say ""
|
||||
say "== 3. L1 停止分发:页面恢复、分享卡被拦 =="
|
||||
do_restrict L1
|
||||
sleep 1
|
||||
check "box page under L1" 200 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
|
||||
qid=$(curl -s --max-time 10 "$BASE_URL/_/$DOMAIN" | grep -o "/_/$DOMAIN/[0-9]\+" | head -1 | grep -o '[0-9]\+$')
|
||||
if [ -n "${qid:-}" ]; then
|
||||
check "share card under L1 (qid=$qid)" 302 "$(code "$BASE_URL/_/$DOMAIN/$qid/share-card")"
|
||||
else
|
||||
say " SKIP 该箱主没有已公开回答,无法验证分享卡(换一个有已答问题的测试箱主)"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 4. 撤销 =="
|
||||
if [ "$RUN" = 1 ]; then
|
||||
"$TAMABOX" unrestrict --domain "$DOMAIN"
|
||||
restricted=0
|
||||
check "box page after unrestrict" 200 "$(code "$BASE_URL/_/$DOMAIN")"
|
||||
else
|
||||
say " [dry-run] $TAMABOX unrestrict --domain $DOMAIN"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "== 结果: PASS=$pass FAIL=$fail =="
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
say "有失败项,把上面输出 + 服务日志(journalctl / docker logs 里搜 'Failed to query user restriction')一起发我。"
|
||||
exit 1
|
||||
fi
|
||||
say "全部通过。仍需人工手测两项:L2(回答应保存为私密)、L3(回答应被拒绝)。"
|
||||
Vendored
-21
File diff suppressed because one or more lines are too long
@@ -1,219 +0,0 @@
|
||||
[
|
||||
{
|
||||
"ip": "43.174.150.2",
|
||||
"tcp_ms": 176.9,
|
||||
"handshake_ms": 599.1,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.4",
|
||||
"tcp_ms": 609.9,
|
||||
"handshake_ms": 794.4,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.9",
|
||||
"tcp_ms": 791.7,
|
||||
"handshake_ms": 973.8,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.10",
|
||||
"tcp_ms": 973.8,
|
||||
"handshake_ms": 1153.9,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.7",
|
||||
"tcp_ms": 1156.6,
|
||||
"handshake_ms": 1914.6,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.21",
|
||||
"tcp_ms": 1906.3,
|
||||
"handshake_ms": 2689.0,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.1",
|
||||
"tcp_ms": 2703.3,
|
||||
"handshake_ms": 3606.3,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.11",
|
||||
"tcp_ms": 3600.5,
|
||||
"handshake_ms": 3795.7,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.5",
|
||||
"tcp_ms": 3800.5,
|
||||
"handshake_ms": 3996.5,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.6",
|
||||
"tcp_ms": 3996.7,
|
||||
"handshake_ms": 4194.4,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.8",
|
||||
"tcp_ms": 4194.1,
|
||||
"handshake_ms": 5120.6,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.18",
|
||||
"tcp_ms": 5114.5,
|
||||
"handshake_ms": 5299.6,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.20",
|
||||
"tcp_ms": 5298.5,
|
||||
"handshake_ms": 6025.5,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.12",
|
||||
"tcp_ms": 6031.7,
|
||||
"handshake_ms": 6218.7,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.3",
|
||||
"tcp_ms": 6225.4,
|
||||
"handshake_ms": 7469.9,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.23",
|
||||
"tcp_ms": 7457.2,
|
||||
"handshake_ms": 7648.1,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.29",
|
||||
"tcp_ms": 7644.9,
|
||||
"handshake_ms": 7827.4,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.26",
|
||||
"tcp_ms": 7829.8,
|
||||
"handshake_ms": 8188.7,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.15",
|
||||
"tcp_ms": 8197.2,
|
||||
"handshake_ms": 8388.9,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.16",
|
||||
"tcp_ms": 8388.6,
|
||||
"handshake_ms": 8585.0,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.32",
|
||||
"tcp_ms": 8574.8,
|
||||
"handshake_ms": 9287.3,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.19",
|
||||
"tcp_ms": 9297.3,
|
||||
"handshake_ms": 9490.1,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.13",
|
||||
"tcp_ms": 9494.5,
|
||||
"handshake_ms": 9692.0,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.22",
|
||||
"tcp_ms": 9686.6,
|
||||
"handshake_ms": 10704.7,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.24",
|
||||
"tcp_ms": 10704.7,
|
||||
"handshake_ms": 11717.3,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.25",
|
||||
"tcp_ms": 11717.6,
|
||||
"handshake_ms": 12484.2,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.34",
|
||||
"tcp_ms": 12479.1,
|
||||
"handshake_ms": 12667.2,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.14",
|
||||
"tcp_ms": 12681.3,
|
||||
"handshake_ms": 13862.1,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.42",
|
||||
"tcp_ms": 13843.7,
|
||||
"handshake_ms": 14038.8,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.37",
|
||||
"tcp_ms": 14043.4,
|
||||
"handshake_ms": 14412.6,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
},
|
||||
{
|
||||
"ip": "43.174.150.35",
|
||||
"tcp_ms": 14414.9,
|
||||
"handshake_ms": 15780.7,
|
||||
"tls": "TLSv1.3",
|
||||
"alpn": "http/1.1"
|
||||
}
|
||||
]
|
||||
@@ -1,114 +0,0 @@
|
||||
[
|
||||
{
|
||||
"ip": "43.175.107.217",
|
||||
"http": "400",
|
||||
"total_s": 1.010907,
|
||||
"elapsed_ms": 3525.3,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.118.111",
|
||||
"http": "400",
|
||||
"total_s": 1.113784,
|
||||
"elapsed_ms": 2782.9,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.18.50",
|
||||
"http": "200",
|
||||
"total_s": 1.504578,
|
||||
"elapsed_ms": 1629.3,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.17.109",
|
||||
"http": "200",
|
||||
"total_s": 1.675108,
|
||||
"elapsed_ms": 2895.5,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.113.139",
|
||||
"http": "200",
|
||||
"total_s": 1.79808,
|
||||
"elapsed_ms": 3461.2,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.119.214",
|
||||
"http": "200",
|
||||
"total_s": 1.836886,
|
||||
"elapsed_ms": 4706.9,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.115.202",
|
||||
"http": "200",
|
||||
"total_s": 1.977182,
|
||||
"elapsed_ms": 3401.5,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.115.123",
|
||||
"http": "200",
|
||||
"total_s": 1.987886,
|
||||
"elapsed_ms": 3563.1,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.115.149",
|
||||
"http": "200",
|
||||
"total_s": 2.029702,
|
||||
"elapsed_ms": 3139.6,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.119.174",
|
||||
"http": "200",
|
||||
"total_s": 2.116343,
|
||||
"elapsed_ms": 3937.1,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.119.230",
|
||||
"http": "200",
|
||||
"total_s": 2.65339,
|
||||
"elapsed_ms": 4696.5,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.174.126.220",
|
||||
"http": "200",
|
||||
"total_s": 3.056871,
|
||||
"elapsed_ms": 4404.8,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.115.41",
|
||||
"http": "200",
|
||||
"total_s": 3.309262,
|
||||
"elapsed_ms": 4261.4,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.174.126.152",
|
||||
"http": "200",
|
||||
"total_s": 3.333779,
|
||||
"elapsed_ms": 4557.8,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.17.185",
|
||||
"http": "200",
|
||||
"total_s": 3.525846,
|
||||
"elapsed_ms": 5194.5,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ip": "43.175.16.233",
|
||||
"http": "200",
|
||||
"total_s": 4.754878,
|
||||
"elapsed_ms": 6255.5,
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
-3579
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
EO 优选 IP 免费探测脚本 (方案 B 免费替代 wetest.vip)
|
||||
原理: EdgeOne 边缘节点(腾讯云香港 43.174.x/43.175.x 段)对任意 EO 接入域名做 SNI 路由。
|
||||
若某 IP:443 能对 box.shiroko.one 完成 TLS 握手, 则该 IP 可作为境内 A 记录直连目标。
|
||||
用法:
|
||||
python eo-probe-ips.py # 扫默认段列表(已知优选 IP 所在 /24)
|
||||
python eo-probe-ips.py --host box.shiroko.one --out eo-hits.json
|
||||
判定: TCP 连通 + TLS 握手成功(SNI=目标host) -> hit; 记录握手 RTT。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 已知/疑似 EO 香港优质 IP 所在段(来自社区 itdog 截图统计 + 教程)
|
||||
DEFAULT_CIDRS = [
|
||||
"43.174.150.0/24", "43.174.78.0/24", "43.174.79.0/24", "43.174.57.0/24",
|
||||
"43.175.130.0/24", "43.175.132.0/24", "43.175.151.0/24", "43.175.165.0/24",
|
||||
"43.175.185.0/24", "43.175.150.0/24", "43.174.152.0/24", "43.174.153.0/24",
|
||||
"43.175.131.0/24", "43.175.133.0/24",
|
||||
]
|
||||
|
||||
def cidr_hosts(cidr: str):
|
||||
net, bits = cidr.split("/")
|
||||
bits = int(bits)
|
||||
a, b, c, d = map(int, net.split("."))
|
||||
base = (a << 24) + (b << 16) + (c << 8) + d
|
||||
count = 1 << (32 - bits)
|
||||
for i in range(1, count - 1): # skip network & broadcast
|
||||
v = base + i
|
||||
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
|
||||
|
||||
async def probe(ip: str, host: str, timeout: float, sem: asyncio.Semaphore):
|
||||
"""TCP connect + TLS handshake (SNI=host). Return dict or None."""
|
||||
async with sem:
|
||||
loop = asyncio.get_running_loop()
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
# async connect
|
||||
await loop.sock_connect(sock, (ip, 443))
|
||||
tcp_ms = (time.perf_counter() - t0) * 1000
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.set_alpn_protocols(["http/1.1"])
|
||||
tls_sock = ctx.wrap_socket(sock, server_hostname=host)
|
||||
tls_sock.settimeout(timeout)
|
||||
tls_ms = (time.perf_counter() - t0) * 1000
|
||||
ver = tls_sock.version()
|
||||
alpn = tls_sock.selected_alpn_protocol()
|
||||
tls_sock.close()
|
||||
sock.close()
|
||||
return {"ip": ip, "tcp_ms": round(tcp_ms, 1),
|
||||
"handshake_ms": round(tls_ms, 1), "tls": ver, "alpn": alpn}
|
||||
except Exception as e:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="box.shiroko.one")
|
||||
ap.add_argument("--cidrs", nargs="*", default=None)
|
||||
ap.add_argument("--concurrency", type=int, default=256)
|
||||
ap.add_argument("--timeout", type=float, default=2.5)
|
||||
ap.add_argument("--out", default="eo-hits.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
cidrs = args.cidrs or DEFAULT_CIDRS
|
||||
ips = []
|
||||
for c in cidrs:
|
||||
ips.extend(cidr_hosts(c))
|
||||
print(f"[*] 目标 host: {args.host} 段数: {len(cidrs)} 待测 IP: {len(ips)} 并发: {args.concurrency}")
|
||||
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
hits = []
|
||||
done = 0
|
||||
t_start = time.perf_counter()
|
||||
|
||||
# chunk to avoid building all tasks at once
|
||||
CH = args.concurrency * 4
|
||||
for i in range(0, len(ips), CH):
|
||||
chunk = ips[i:i + CH]
|
||||
tasks = [asyncio.create_task(probe(ip, args.host, args.timeout, sem)) for ip in chunk]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for r in results:
|
||||
if r:
|
||||
hits.append(r)
|
||||
done += len(chunk)
|
||||
if done % (len(ips) // 10 or 1) == 0 or done == len(ips):
|
||||
el = time.perf_counter() - t_start
|
||||
print(f" [{done}/{len(ips)}] hits={len(hits)} elapsed={el:.1f}s")
|
||||
|
||||
hits.sort(key=lambda x: x["handshake_ms"])
|
||||
print(f"\n[*] 完成: {done} IP 用时 {time.perf_counter()-t_start:.1f}s, 命中 {len(hits)}")
|
||||
for h in hits[:40]:
|
||||
print(f" {h['ip']:16s} tcp={h['tcp_ms']:6.1f}ms hs={h['handshake_ms']:6.1f}ms tls={h['tls']} alpn={h['alpn']}")
|
||||
|
||||
if hits and args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(hits, f, ensure_ascii=False, indent=2)
|
||||
print(f"[*] 结果已存: {args.out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
EO 优选 IP 扫描 v2 (schannel 证书校验版)
|
||||
判据更严格: 用 curl (Windows schannel, 系统证书链) + --resolve 强制 SNI,
|
||||
只有当 TLS 证书链校验通过(浏览器视角可访问)且返回 HTTP 响应才算 hit。
|
||||
这能区分 "CDN 通用池 IP (证书 *.cdn.myqcloud.com 不匹配)" 与 "真正承载目标站点的 EO 边缘"。
|
||||
用法: python eo-probe-v2.py --cidrs 43.174.246.0/24 43.174.247.0/24 ...
|
||||
"""
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
DEFAULT_CIDRS = [
|
||||
"43.174.150.0/24", "43.174.151.0/24", "43.174.152.0/24", "43.174.153.0/24",
|
||||
"43.174.78.0/24", "43.174.79.0/24", "43.174.57.0/24",
|
||||
"43.175.130.0/24", "43.175.131.0/24", "43.175.132.0/24", "43.175.133.0/24",
|
||||
"43.175.150.0/24", "43.175.151.0/24", "43.175.152.0/24",
|
||||
"43.175.165.0/24", "43.175.166.0/24",
|
||||
"43.175.185.0/24", "43.175.186.0/24",
|
||||
"43.174.246.0/24", "43.174.247.0/24", # EO 官方当前分配段(新加坡)
|
||||
]
|
||||
|
||||
def cidr_hosts(cidr):
|
||||
net, bits = cidr.split("/")
|
||||
bits = int(bits)
|
||||
a, b, c, d = map(int, net.split("."))
|
||||
base = (a << 24) + (b << 16) + (c << 8) + d
|
||||
count = 1 << (32 - bits)
|
||||
for i in range(1, count - 1):
|
||||
v = base + i
|
||||
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
|
||||
|
||||
def probe(ip, host, timeout):
|
||||
t0 = time.perf_counter()
|
||||
# schannel on Windows: 默认走系统 CA, 证书不匹配会直接失败 => http_code 000 + 错误
|
||||
cmd = ["curl", "-sS", "--resolve", f"{host}:443:{ip}",
|
||||
"--connect-timeout", str(timeout), "--max-time", str(timeout * 2),
|
||||
"-o", "NUL", "-w", "%{http_code}|%{time_total}|%{time_starttransfer}",
|
||||
f"https://{host}/"]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * 3,
|
||||
errors="replace")
|
||||
out = r.stdout.strip()
|
||||
parts = out.split("|")
|
||||
code = parts[0] if parts else "000"
|
||||
el = (time.perf_counter() - t0) * 1000
|
||||
err = r.stderr.strip() if r.stderr else ""
|
||||
if code not in ("000",):
|
||||
return {"ip": ip, "http": code, "total_s": float(parts[1]) if len(parts) > 1 else None,
|
||||
"elapsed_ms": round(el, 1), "note": err.splitlines()[-1] if err else ""}
|
||||
# 000: 看看是不是证书错误
|
||||
note = ""
|
||||
if "SEC_E_WRONG_PRINCIPAL" in err or "certificate" in err.lower():
|
||||
note = "cert-mismatch"
|
||||
elif err:
|
||||
note = err.splitlines()[-1][:80]
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="box.shiroko.one")
|
||||
ap.add_argument("--cidrs", nargs="*", default=None)
|
||||
ap.add_argument("--workers", type=int, default=64)
|
||||
ap.add_argument("--timeout", type=float, default=3)
|
||||
ap.add_argument("--out", default="eo-hits-v2.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
cidrs = args.cidrs or DEFAULT_CIDRS
|
||||
ips = []
|
||||
for c in cidrs:
|
||||
ips.extend(cidr_hosts(c))
|
||||
print(f"[*] host={args.host} 段={len(cidrs)} IP={len(ips)} workers={args.workers}")
|
||||
|
||||
hits = []
|
||||
t_start = time.perf_counter()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
|
||||
futs = {ex.submit(probe, ip, args.host, args.timeout): ip for ip in ips}
|
||||
done = 0
|
||||
for fut in concurrent.futures.as_completed(futs):
|
||||
done += 1
|
||||
r = fut.result()
|
||||
if r:
|
||||
hits.append(r)
|
||||
print(f" HIT {r['ip']:16s} http={r['http']} total={r['total_s']}s {r['note']}")
|
||||
if done % 500 == 0:
|
||||
print(f" [{done}/{len(ips)}] hits={len(hits)} elapsed={time.perf_counter()-t_start:.0f}s")
|
||||
|
||||
print(f"\n[*] done in {time.perf_counter()-t_start:.0f}s, hits={len(hits)}")
|
||||
if hits:
|
||||
hits.sort(key=lambda x: x["total_s"] or 99)
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(hits, f, ensure_ascii=False, indent=2)
|
||||
print(f"[*] saved: {args.out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
EO 全段扫描 v3 (方案 B: 找所有能服务 box.shiroko.one 的 EO 边缘 IP)
|
||||
两段式: 1) asyncio TCP:443 预筛全段 2) openssl 对 TCP 通的 IP 做 SNI 证书核验 (CN 含 host)
|
||||
输出: eo-all-hits.json [{ip, cn, tcp_ms}]
|
||||
用法: python eo-scan-all.py --host box.shiroko.one --nets 43.174.0.0/16 43.175.0.0/16 --workers 400
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def iter_hosts(net: str):
|
||||
net, bits = net.split("/")
|
||||
bits = int(bits)
|
||||
a, b, c, d = map(int, net.split("."))
|
||||
base = (a << 24) + (b << 16) + (c << 8) + d
|
||||
count = 1 << (32 - bits)
|
||||
for i in range(1, count - 1):
|
||||
v = base + i
|
||||
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
|
||||
|
||||
async def tcp_probe(ip: str, port: int, timeout: float, sem: asyncio.Semaphore):
|
||||
async with sem:
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setblocking(False)
|
||||
loop = asyncio.get_running_loop()
|
||||
await asyncio.wait_for(loop.sock_connect(s, (ip, port)), timeout)
|
||||
ms = (time.perf_counter() - t0) * 1000
|
||||
s.close()
|
||||
return ip, ms
|
||||
except Exception:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def tls_cn(ip: str, host: str, timeout: float):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["openssl", "s_client", "-connect", f"{ip}:443", "-servername", host,
|
||||
"-tlsextdebug", "-no_ign_eof"],
|
||||
capture_output=True, text=True, timeout=timeout, errors="replace",
|
||||
input="Q")
|
||||
out = r.stdout + r.stderr
|
||||
cn = ""
|
||||
for line in out.splitlines():
|
||||
ls = line.strip()
|
||||
if ls.startswith("subject="):
|
||||
cn = ls
|
||||
break
|
||||
return {"ip": ip, "cn": cn or "(no-subject)", "ok": host in cn}
|
||||
except Exception as e:
|
||||
return {"ip": ip, "cn": f"ERR:{type(e).__name__}", "ok": False}
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="box.shiroko.one")
|
||||
ap.add_argument("--nets", nargs="*",
|
||||
default=["43.174.0.0/16", "43.175.0.0/16"])
|
||||
ap.add_argument("--workers", type=int, default=300)
|
||||
ap.add_argument("--timeout", type=float, default=2.0)
|
||||
ap.add_argument("--out", default="eo-all-hits.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
all_ips = []
|
||||
for n in args.nets:
|
||||
all_ips.extend(iter_hosts(n))
|
||||
print(f"[*] host={args.host} 网段={args.nets} 总IP={len(all_ips)}")
|
||||
|
||||
# Stage 1: TCP 预筛
|
||||
sem = asyncio.Semaphore(args.workers)
|
||||
open_ips = []
|
||||
|
||||
async def run_tcp():
|
||||
CH = args.workers * 6
|
||||
for i in range(0, len(all_ips), CH):
|
||||
chunk = all_ips[i:i + CH]
|
||||
tasks = [asyncio.create_task(tcp_probe(ip, 443, args.timeout, sem)) for ip in chunk]
|
||||
res = await asyncio.gather(*tasks)
|
||||
for r in res:
|
||||
if r:
|
||||
open_ips.append(r)
|
||||
if (i + CH) % 20000 < CH or i + CH >= len(all_ips):
|
||||
print(f" tcp预筛 [{i+CH}/{len(all_ips)}] open={len(open_ips)}", flush=True)
|
||||
t0 = time.perf_counter()
|
||||
asyncio.run(run_tcp())
|
||||
print(f"[*] TCP 预筛完成: {len(all_ips)} -> {len(open_ips)} open, {time.perf_counter()-t0:.0f}s")
|
||||
|
||||
if not open_ips:
|
||||
print("[!] 无开放 IP")
|
||||
return
|
||||
|
||||
# Stage 2: openssl 证书核验
|
||||
hits = []
|
||||
with ThreadPoolExecutor(max_workers=64) as ex:
|
||||
futs = [ex.submit(tls_cn, ip, args.host, args.timeout * 2) for ip, _ in open_ips]
|
||||
done = 0
|
||||
for fut in futures_as_completed(futs):
|
||||
done += 1
|
||||
r = fut.result()
|
||||
if r and r["ok"]:
|
||||
hits.append(r)
|
||||
print(f" HIT {r['ip']:16s} {r['cn']}", flush=True)
|
||||
if done % 200 == 0:
|
||||
print(f" 证书核验 [{done}/{len(open_ips)}] hits={len(hits)}", flush=True)
|
||||
|
||||
print(f"\n[*] 全部完成: hits={len(hits)}")
|
||||
if hits:
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
json.dump(hits, f, ensure_ascii=False, indent=2)
|
||||
print(f"[*] saved: {args.out}")
|
||||
|
||||
def futures_as_completed(futs):
|
||||
import concurrent.futures
|
||||
for fut in concurrent.futures.as_completed(futs):
|
||||
yield fut
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
gen_changelog.py — 生成 PocketBase 风格的 changelogs JSON(无需 PocketBase 服务)
|
||||
|
||||
用途:
|
||||
你在本地维护一个 Markdown 源文件(按 `## <时间戳>` 分段写 changelog),
|
||||
本脚本把它导出成前端 change-logs.html 所 fetch 的 tamabox.changelogs.json。
|
||||
输出格式完全兼容 PocketBase list API 的返回结构,但无需部署任何后端服务,
|
||||
因此不会在你的 1C1G 服务器上多占哪怕 1 MB 常驻内存。
|
||||
|
||||
输出位置:static/tamabox.changelogs.json —— 该文件被 static 包以 //go:embed 打入
|
||||
二进制,由 flamego.Static 在 /static 路径同源提供,前端直接 fetch 同源路径,
|
||||
不再依赖任何外部域名(去掉 api-direct.337845818.best 等外链,降低可用性/安全风险)。
|
||||
|
||||
源文件格式(dev/changelog-source.md 示例):
|
||||
## 2024-03-04 21:45:55
|
||||
新增:用户私信功能上线。
|
||||
修复:移动端问题列表间距过窄。
|
||||
|
||||
## 2024-02-29 00:00:55
|
||||
优化:首页加载速度提升约 30%。
|
||||
|
||||
注意:
|
||||
- 时间戳支持 `YYYY-MM-DD HH:MM:SS` / `YYYY-MM-DD` / 带毫秒的 `...fffZ` / ISO 格式,
|
||||
脚本会统一归一化为 PocketBase 风格的 `YYYY-MM-DD HH:MM:SS.fffZ`(UTC)。
|
||||
- 正文里的换行默认会被转成 `<br>`,因为前端用 x-html 渲染;传 --no-br 可关闭。
|
||||
- 导出时按时间倒序排列,最新一条排在最前(前端读 items[0] 即取最新)。
|
||||
|
||||
用法:
|
||||
python dev/gen_changelog.py
|
||||
python dev/gen_changelog.py 源文件.md 输出.json
|
||||
python dev/gen_changelog.py 源文件.md 输出.json --no-br
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COLLECTION_ID = "tamabox"
|
||||
COLLECTION_NAME = "tamabox-changelogs"
|
||||
DEFAULT_SRC = "dev/changelog-source.md"
|
||||
DEFAULT_OUT = "static/tamabox.changelogs.json"
|
||||
|
||||
TS_LINE = re.compile(r"^##\s+(.+?)\s*$")
|
||||
FULL_TS = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}Z$")
|
||||
|
||||
|
||||
def normalize_ts(raw: str) -> str:
|
||||
"""把用户输入的时间归一化为 PocketBase 风格时间戳:YYYY-MM-DD HH:MM:SS.fffZ(UTC)。"""
|
||||
raw = raw.strip()
|
||||
if FULL_TS.match(raw):
|
||||
return raw
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S.%fZ", "%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(raw, fmt)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") + ".000Z"
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ".000Z"
|
||||
except ValueError:
|
||||
sys.exit(f"[错误] 无法解析时间戳: {raw!r}")
|
||||
|
||||
|
||||
def parse_source(text: str):
|
||||
"""按 `## <时间戳>` 分段,返回 [(ts, content), ...],顺序与源文件一致。"""
|
||||
entries, cur_ts, buf = [], None, []
|
||||
for line in text.splitlines():
|
||||
m = TS_LINE.match(line)
|
||||
if m:
|
||||
if cur_ts is not None:
|
||||
entries.append((cur_ts, "\n".join(buf).strip()))
|
||||
cur_ts, buf = m.group(1).strip(), []
|
||||
elif cur_ts is not None:
|
||||
buf.append(line)
|
||||
if cur_ts is not None:
|
||||
entries.append((cur_ts, "\n".join(buf).strip()))
|
||||
if not entries:
|
||||
sys.exit("[错误] 源文件未找到任何 '## <时间戳>' 分段")
|
||||
return entries
|
||||
|
||||
|
||||
def build(entries, br=True):
|
||||
"""把条目导出为 PocketBase list API 风格 JSON(最新在前)。"""
|
||||
parsed = [(normalize_ts(ts), c) for ts, c in entries]
|
||||
parsed.sort(key=lambda x: x[0], reverse=True) # 倒序:最新 published_at 在前
|
||||
items = []
|
||||
for ts, content in parsed:
|
||||
items.append({
|
||||
"collectionId": COLLECTION_ID,
|
||||
"collectionName": COLLECTION_NAME,
|
||||
"content": content.replace("\n", "<br>\n") if br else content,
|
||||
"created": ts,
|
||||
"id": "none",
|
||||
"published_at": ts,
|
||||
"updated": ts,
|
||||
})
|
||||
return {
|
||||
"page": 1,
|
||||
"perPage": len(items) or 1,
|
||||
"totalItems": len(items),
|
||||
"totalPages": 1,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
br = "--no-br" not in args
|
||||
paths = [a for a in args if not a.startswith("--")]
|
||||
src = paths[0] if len(paths) > 0 else DEFAULT_SRC
|
||||
out = paths[1] if len(paths) > 1 else DEFAULT_OUT
|
||||
try:
|
||||
with open(src, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except FileNotFoundError:
|
||||
sys.exit(f"[错误] 找不到源文件: {src}")
|
||||
data = build(parse_source(text), br=br)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
latest = data["items"][0]["published_at"] if data["items"] else "N/A"
|
||||
print(f"[完成] 已生成 {out}({data['totalItems']} 条记录,最新发布于 {latest})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# dev/gofmt_keep_crlf.py
|
||||
#
|
||||
# 运行 gofmt -w,但保留文件原始行尾(CRLF / LF),避免 gofmt 把 CRLF 文件整文件
|
||||
# 重写成 LF 造成的万行级 diff 污染。
|
||||
#
|
||||
# 用法:
|
||||
# python dev/gofmt_keep_crlf.py <file1> [file2 ...]
|
||||
# git diff --name-only | grep '\.go$' | xargs python dev/gofmt_keep_crlf.py
|
||||
#
|
||||
# 实现要点:先把源文件按 LF 写入临时副本交给 gofmt,gofmt 输出也是 LF;
|
||||
# 若原文件是 CRLF,再把输出整体换回 CRLF 写回,从而只保留真实的格式化改动。
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
GOFMT = r"C:/Program Files/Go/bin/gofmt"
|
||||
|
||||
|
||||
def fmt_one(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
crlf = b"\r\n" in data
|
||||
|
||||
tmp = path + ".gofmt.tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data.replace(b"\r\n", b"\n"))
|
||||
try:
|
||||
r = subprocess.run([GOFMT, "-w", tmp], capture_output=True)
|
||||
if r.returncode != 0:
|
||||
sys.stderr.write(
|
||||
"gofmt FAILED on %s:\n%s\n" % (path, r.stderr.decode("utf-8", "replace"))
|
||||
)
|
||||
return False
|
||||
with open(tmp, "rb") as f:
|
||||
out = f.read()
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if crlf:
|
||||
out = out.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
||||
with open(path, "wb") as f:
|
||||
f.write(out)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
files = [a for a in sys.argv[1:] if a.strip()]
|
||||
if not files:
|
||||
sys.stderr.write("usage: gofmt_keep_crlf.py <file> [file ...]\n")
|
||||
return 2
|
||||
ok = True
|
||||
for p in files:
|
||||
if not fmt_one(p):
|
||||
ok = False
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/OtherFiles/Github/TamaBox-v4-mainline/dev/s3stub/s3_stub.go"}}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Command preview-honeypot 把全部伪装家族渲染成静态 HTML,供人工核对外观。
|
||||
//
|
||||
// 用法:go run ./dev/preview-honeypot
|
||||
// 产物:outputs/honeypot-preview/index.html(9 个 iframe 并排)
|
||||
//
|
||||
// 改动 internal/security/honeypot/family.go 的皮肤后重新跑一次,
|
||||
// 比对着源码想象 CSS 可靠得多 —— 上一版就是靠它发现 Tomcat 按钮没背景色。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
)
|
||||
|
||||
type item struct {
|
||||
fam honeypot.Family
|
||||
action string
|
||||
note string
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := "outputs/honeypot-preview"
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
items := []item{
|
||||
{honeypot.FamilyWordPress, "/wp-login.php", "/wp-login.php · /wp-admin"},
|
||||
{honeypot.FamilyPhpMyAdmin, "/phpmyadmin/index.php", "/phpmyadmin · /pma"},
|
||||
{honeypot.FamilyTomcat, "/manager/html", "/manager/html · 401 Basic"},
|
||||
{honeypot.FamilyJenkins, "/jenkins/login", "/jenkins"},
|
||||
{honeypot.FamilyGrafana, "/grafana/login", "/grafana"},
|
||||
{honeypot.FamilyGitLab, "/users/sign_in", "/users/sign_in"},
|
||||
{honeypot.FamilyLuCI, "/cgi-bin/luci", "/cgi-bin/luci"},
|
||||
{honeypot.FamilyJoomla, "/administrator/index.php", "/administrator"},
|
||||
{honeypot.FamilyGeneric, "/admin", "/admin · /console"},
|
||||
}
|
||||
|
||||
var cards strings.Builder
|
||||
for _, it := range items {
|
||||
name := it.fam.String()
|
||||
body := honeypot.RenderFamily(it.fam, honeypot.LoginPageVars{
|
||||
Action: it.action,
|
||||
Redir: "/wp-admin/",
|
||||
Failed: false,
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".html"), body, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Fprintf(&cards, `<figure><figcaption><b>%s</b><span>%s</span></figcaption><iframe src="%s.html" title="%s"></iframe></figure>`,
|
||||
name, it.note, name, name)
|
||||
}
|
||||
|
||||
page := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<title>蜜罐伪装家族预览</title>
|
||||
<style>
|
||||
body{margin:0;padding:28px 24px 40px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f6f7f9;color:#1d2327}
|
||||
h1{font-size:18px;font-weight:600;margin:0 0 6px}
|
||||
p.lead{margin:0 0 22px;font-size:13px;color:#646970}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}
|
||||
figure{margin:0;background:#fff;border:1px solid #dcdcde;border-radius:8px;overflow:hidden}
|
||||
figcaption{display:flex;justify-content:space-between;gap:8px;padding:9px 12px;font-size:12px;border-bottom:1px solid #f0f0f1}
|
||||
figcaption span{color:#787c82}
|
||||
iframe{width:100%;height:520px;border:0;display:block}
|
||||
</style></head><body>
|
||||
<h1>蜜罐:9 个伪装家族</h1>
|
||||
<p class="lead">每个 iframe 是 /wp-admin 这类诱饵路径的真实响应(同一份 RenderFamily 输出,未做任何美化)。Tomcat 家族在真实响应里是 401 + Basic Auth,这里有页面是为了看内容。</p>
|
||||
<div class="grid">` + cards.String() + `</div></body></html>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(page), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("预览已生成:", filepath.Join(dir, "index.html"))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// s3_stub.go 是 internal/storage/s3.go 的本地编译桩:s3.go 依赖 cgo 库
|
||||
// chai2010/webp(需要 libwebp + C 工具链),Windows 沙箱无此环境。
|
||||
// 通过 `go build -overlay=dev/overlay.json` 用本文件替换 s3.go,做全模块类型检查。
|
||||
// 符号覆盖 = s3.go 中被包内其他文件(oss.go 引用 isSupportedImage)与
|
||||
// 包外调用方(route/user、route/question、route/chat 等)引用的全部标识符。
|
||||
// 桩仅在类型检查时生效,不会被真正调用;生产构建在 Linux(cgo + libwebp)下不受影响。
|
||||
package storage
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
)
|
||||
|
||||
// MaxUploadBytes 与真实实现保持一致(8MB 单图上限,bodylimit 中间件依赖此值语义)。
|
||||
const MaxUploadBytes = 8 * 1024 * 1024
|
||||
|
||||
// EncodeSpec 与真实实现的编码档位结构保持字段一致。
|
||||
type EncodeSpec struct {
|
||||
MaxSide int
|
||||
Quality float32
|
||||
}
|
||||
|
||||
// GeneralEncodeSpec 与真实实现保持一致(route/question 引用)。
|
||||
var GeneralEncodeSpec = EncodeSpec{MaxSide: 1600, Quality: 80}
|
||||
|
||||
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func ConvertStaticImageToWebP(data []byte, spec EncodeSpec) ([]byte, error) { return nil, nil }
|
||||
|
||||
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) { return nil, nil }
|
||||
|
||||
func isSupportedImage(data []byte) bool { return true }
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# TamaBox Cloudflare Tunnel 部署安全自检脚本
|
||||
#
|
||||
# 架构前提:
|
||||
# 访客 → Cloudflare 边缘(a.b.com 自定义主机名证书 / a.a.com 主域)
|
||||
# → cloudflared Tunnel(源站仅出站连接)→ 127.0.0.1:5877 (TamaBox)
|
||||
#
|
||||
# 用法:
|
||||
# 第一部分(在源站服务器上执行): ./security-selfcheck.sh local
|
||||
# 第二部分(在你自己的电脑上执行):SERVER_IP=源站公网IP CF_DOMAIN=a.a.com CUSTOM_DOMAIN=a.b.com ./security-selfcheck.sh remote
|
||||
# 两部分都跑: ./security-selfcheck.sh all
|
||||
#
|
||||
set -u
|
||||
|
||||
MODE="${1:-all}"
|
||||
SERVER_IP="${SERVER_IP:-}"
|
||||
CF_DOMAIN="${CF_DOMAIN:-a.a.com}"
|
||||
CUSTOM_DOMAIN="${CUSTOM_DOMAIN:-a.b.com}"
|
||||
APP_PORT="${APP_PORT:-5877}"
|
||||
|
||||
section() { printf "\n========== %s ==========\n" "$1"; }
|
||||
ok() { echo "[✓] $1"; }
|
||||
warn() { echo "[!] $1"; }
|
||||
bad() { echo "[✗] $1"; }
|
||||
|
||||
# 用 bash /dev/tcp 探测 TCP 端口是否可达(3 秒超时)
|
||||
port_open() {
|
||||
(timeout 3 bash -c "echo > /dev/tcp/$1/$2") 2>/dev/null
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 第一部分:源站本地检查
|
||||
# ----------------------------------------------------------------
|
||||
local_checks() {
|
||||
section "1. 应用与数据库端口绑定检查(核心)"
|
||||
|
||||
BIND=$(ss -tlnp 2>/dev/null | grep ":${APP_PORT} " || true)
|
||||
echo "$BIND"
|
||||
if echo "$BIND" | grep -qE "127\.0\.0\.1:${APP_PORT}|\[::1\]:${APP_PORT}"; then
|
||||
ok "TamaBox 只绑定在 127.0.0.1:${APP_PORT},外部无法直连(正确)"
|
||||
elif [ -z "$BIND" ]; then
|
||||
warn "端口 ${APP_PORT} 未监听——确认 TamaBox 是否在运行"
|
||||
else
|
||||
bad "TamaBox 绑定在 0.0.0.0/*:${APP_PORT}(监听所有网卡)!"
|
||||
echo " 修复:conf/app.ini [server] 段设置 listen = 127.0.0.1:${APP_PORT} 并重启"
|
||||
fi
|
||||
|
||||
section "2. PostgreSQL / Redis 是否暴露公网"
|
||||
for p in 5432 6379; do
|
||||
BIND=$(ss -tlnp 2>/dev/null | grep ":${p} " || true)
|
||||
if echo "$BIND" | grep -qE "0\.0\.0\.0:${p}|\*:${p}"; then
|
||||
bad "端口 ${p} 绑定在所有网卡!必须改为 127.0.0.1 或加防火墙(云数据库直接暴露=高危)"
|
||||
else
|
||||
ok "端口 ${p} 未对外监听(${BIND:-未运行})"
|
||||
fi
|
||||
done
|
||||
|
||||
section "3. cloudflared 状态与 ingress 配置"
|
||||
if systemctl is-active --quiet cloudflared 2>/dev/null || pgrep -x cloudflared >/dev/null 2>&1; then
|
||||
ok "cloudflared 正在运行"
|
||||
else
|
||||
bad "cloudflared 未运行——Tunnel 断开,所有域名不可达"
|
||||
fi
|
||||
CFG=""
|
||||
for f in /etc/cloudflared/config.yml /root/.cloudflared/config.yml /home/*/.cloudflared/config.yml; do
|
||||
[ -f "$f" ] && CFG="$f" && break
|
||||
done
|
||||
if [ -n "$CFG" ]; then
|
||||
echo "配置文件:$CFG"
|
||||
grep -nE "hostname|service|originRequest" "$CFG" || true
|
||||
if grep -qE "service:\s*http://127\.0\.0\.1:${APP_PORT}|localhost:${APP_PORT}" "$CFG"; then
|
||||
ok "ingress service 指向 127.0.0.1:${APP_PORT}(正确)"
|
||||
else
|
||||
warn "未在 config.yml 中找到指向 127.0.0.1:${APP_PORT} 的 service,请人工核对 ingress 规则"
|
||||
fi
|
||||
else
|
||||
warn "未找到 config.yml(若用 token 模式 + 远端 ingress,请到 Cloudflare 面板核对 Public Hostname 的 service 地址)"
|
||||
fi
|
||||
|
||||
section "4. 本地请求验证(模拟 cloudflared 的视角)"
|
||||
RESP=$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://127.0.0.1:${APP_PORT}/" || true)
|
||||
if [ "$RESP" = "200" ] || [ "$RESP" = "302" ] || [ "$RESP" = "303" ]; then
|
||||
ok "本地 HTTP 请求返回 $RESP,应用正常"
|
||||
else
|
||||
warn "本地请求返回 ${RESP:-无响应},请人工确认"
|
||||
fi
|
||||
|
||||
section "5. 防火墙状态(仅供参考)"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw status verbose 2>/dev/null | head -15 || true
|
||||
elif command -v firewall-cmd >/dev/null 2>&1; then
|
||||
firewall-cmd --list-all 2>/dev/null || true
|
||||
else
|
||||
iptables -L INPUT -n --line-numbers 2>/dev/null | head -15 || true
|
||||
fi
|
||||
warn "请人工确认:除 SSH(22) 外,80/443/5877 等端口不应有公网入站放行规则"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 第二部分:外部探测(在自己的电脑上执行)
|
||||
# ----------------------------------------------------------------
|
||||
remote_checks() {
|
||||
if [ -z "$SERVER_IP" ]; then
|
||||
bad "未设置 SERVER_IP 环境变量,跳过直连探测(用法见文件头注释)"
|
||||
else
|
||||
section "6. 直连源站 IP 探测(核心:防 IP+端口直连)"
|
||||
for p in "$APP_PORT" 80 443 5432 6379; do
|
||||
if port_open "$SERVER_IP" "$p"; then
|
||||
case "$p" in
|
||||
22) warn "端口 22 开放(SSH,正常,但建议改密钥登录/限来源 IP)" ;;
|
||||
*) bad "端口 ${p} 从公网可达!存在被直连/攻击面,去服务器防火墙封掉它" ;;
|
||||
esac
|
||||
else
|
||||
ok "端口 ${p} 从公网不可达"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
section "7. 经域名的正常访问验证"
|
||||
for d in "$CF_DOMAIN" "$CUSTOM_DOMAIN"; do
|
||||
CODE=$(curl -s -m 10 -o /dev/null -w "%{http_code}" "https://${d}/" || true)
|
||||
RAY=$(curl -sI -m 10 "https://${d}/" | grep -i "^cf-ray:" || true)
|
||||
if [ -n "$RAY" ]; then
|
||||
ok "https://${d}/ 返回 ${CODE:-?},且带 ${RAY%%$'\r'} → 确认流量经过 Cloudflare 边缘"
|
||||
elif [ "$CODE" = "200" ] || [ "$CODE" = "302" ] || [ "$CODE" = "303" ]; then
|
||||
warn "https://${d}/ 返回 ${CODE} 但没有 cf-ray 头 → 流量可能没走 Cloudflare(自定义主机名未生效?)"
|
||||
else
|
||||
bad "https://${d}/ 不可达(${CODE:-无响应})"
|
||||
fi
|
||||
done
|
||||
|
||||
section "8. 伪造头穿透测试(验证 Cloudflare 会重写伪造头)"
|
||||
# 若伪造头被 CF 覆盖,应用侧记录的仍是真实 IP;此测试主要用于演示,无法从响应直接观察,
|
||||
# 需配合 TamaBox 侧日志/封禁记录核对(见第 9 项说明)。
|
||||
curl -s -m 10 -o /dev/null \
|
||||
-H "CF-Connecting-IP: 203.0.113.66" \
|
||||
-H "X-Forwarded-For: 203.0.113.66" \
|
||||
"https://${CUSTOM_DOMAIN}/" || true
|
||||
ok "已发送带伪造 CF-Connecting-IP 的请求(CF 边缘会将其重写为你的真实 IP)"
|
||||
|
||||
section "9. HTTP 明文与 HSTS 检查"
|
||||
LOC=$(curl -sI -m 10 "http://${CUSTOM_DOMAIN}/" | grep -iE "^(HTTP|location):" | tr -d '\r')
|
||||
echo "$LOC"
|
||||
HSTS=$(curl -sI -m 10 "https://${CUSTOM_DOMAIN}/" | grep -i "strict-transport-security" | tr -d '\r')
|
||||
if [ -n "$HSTS" ]; then ok "$HSTS"; else warn "未返回 HSTS 头(若浏览器直访 http 可能被中间人,建议确认 securityHeaders 已生效)"; fi
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
local) local_checks ;;
|
||||
remote) remote_checks ;;
|
||||
all) local_checks; remote_checks ;;
|
||||
*) echo "用法: $0 {local|remote|all}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
section "自检完成"
|
||||
echo "逐项核对上面的 [✓]/[!]/[✗],所有 [✗] 都需要修复后再跑一遍。"
|
||||
@@ -1,51 +0,0 @@
|
||||
// 临时校验程序:用 aws-sdk-go-v2 的 SigV4 signer 给同一请求签名,
|
||||
// 与 dev/theme-builder/serve.py 手写的实现比对。验证完即删。
|
||||
//
|
||||
// 每行 stdin:method \t url \t region \t payloadHash \t amzDate
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
)
|
||||
|
||||
func main() {
|
||||
keyID := os.Getenv("AWS_KEY_ID")
|
||||
secret := os.Getenv("AWS_SECRET")
|
||||
s := bufio.NewScanner(os.Stdin)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
method, urlStr, region, payloadHash, ts := f[0], f[1], f[2], f[3], f[4]
|
||||
|
||||
t, err := time.Parse("20060102T150405Z", ts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req, err := http.NewRequest(method, urlStr, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
|
||||
creds := credentials.NewStaticCredentialsProvider(keyID, secret, "")
|
||||
c, err := creds.Retrieve(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := v4.NewSigner().SignHTTP(context.Background(), c, req, payloadHash, "s3", region, t); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(req.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 把 Windows 侧工作区同步到 WSL 里的测试部署目录。
|
||||
#
|
||||
# 为什么不能直接 rsync --delete:WSL 侧有几份「只存在于目标机」的文件,
|
||||
# 源目录里没有它们,--delete 会把它们当多余文件删掉:
|
||||
# - conf/app.ini 本地测试配置(数据库密码、对象存储密钥;模板见 dev/wsl-app.ini)
|
||||
# 第一次踩这个坑时整份 app.ini 被删,服务器重启后起不来。
|
||||
#
|
||||
# 用法:在仓库根目录执行
|
||||
# bash dev/sync-wsl.sh
|
||||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DST=/home/tamakyi/tb/src
|
||||
|
||||
cd "$SRC"
|
||||
tar -cf - \
|
||||
--exclude=.git \
|
||||
--exclude=outputs \
|
||||
--exclude=.workbuddy \
|
||||
. 2>/dev/null |
|
||||
wsl -d Debian -e bash -c '
|
||||
set -e
|
||||
DST=/home/tamakyi/tb/src
|
||||
rm -rf /tmp/tbsync && mkdir -p /tmp/tbsync
|
||||
tar -xf - -C /tmp/tbsync
|
||||
# 只同步代码与资源,绝不动目标侧的 conf/app.ini
|
||||
rsync -a --delete --exclude "conf/app.ini" /tmp/tbsync/ "$DST"/
|
||||
rm -rf /tmp/tbsync
|
||||
echo "SYNCED -> $DST"
|
||||
'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user