Commit Graph
100 Commits
Author SHA1 Message Date
tamakyi e807dea218 feat(share-card): 字体自动发现 + 启动探测,缺字体不再等到第一次请求才 500
- 系统候选补上 glyf 轮廓的 CJK 字体(文泉驿正黑/微米黑、DroidSansFallback、
  AR PL UMing),并把 Noto Sans CJK 压到最后:它是 CFF/OTTO,freetype 必定解析失败,
  排前面只会白白多读十几 MB
- static/fonts 同时按当前工作目录与可执行文件目录查找,systemd 没配
  WorkingDirectory 时也能找到(原先只按 CWD,文件在却报「没有可用字体」)
- 启动时探一次字体:命中打 Info 带路径,缺失打 WARN 并给出补救命令
  (apt install fonts-wqy-zenhei / 放 static/fonts/ / 配 font_path)
- .ttc 先提取首个字面再用(仅对 glyf 轮廓的集合有效,如文泉驿);
  失败结果缓存 1 分钟,避免公开端点被「每请求重新解析十几 MB」放大

实测:本地实例 apt install fonts-wqy-zenhei 后无需任何配置,启动即打出
font=/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc,/share-card 与单题分享图
从 500 变为 200 image/jpeg,中文与二维码渲染正常。
2026-10-27 12:37:48 +08:00
tamakyi 7802bb2078 fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
tamakyi a3cf99a8da fix(stats): 环比句点明月份与条数,不再写「上月…环比 -75%」
「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份,
再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为
是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。

  改前:上月提问量环比 -75.0%。
  改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。

口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数
写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手
核对百分比(5620/4890 确实是 +14.9%)。

实现:
- `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September"
  ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。
- 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板,
  四种语言里「多了 / 少了」的位置和词形都不一样。
- `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。

测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」,
覆盖上升/下降/持平;`TestMonthText` 锁语言差异。

顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的
monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来,
截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
2026-10-25 09:27:57 +08:00
tamakyi 79199ac3cd fix(stats): 删掉「没有推测」这类对站长说的话,文案统一以访客为读者
「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」,
是在向读者解释「我没有编」—— 那是作者视角的免责,不是访客需要的信息。
读者看到「没有推测」只会想:那我该怀疑什么?删掉,卡片留标题 + 结论列表
即可(`stats.insight.title` 已经说明这组是什么)。

顺着把整页 40 余条文案过了一遍,同类的还有页脚:

  「累计数字与图表是当天重算一次的快照(当天第一次访问时算,服务重启也会重算)」
  → 「累计数字与图表每天更新一次」

访客不需要知道缓存是懒重算、更不需要知道重启会触发重算;真要精确的新鲜度,
上一行「快照时间」已经给了到秒的值。这条注的职责只是说清「累计按天、今日实时」。

保留的两处,判断过不属于这个问题:
- `stats.foot.privacy` 与顶部 `stats.lead` 后半句近乎同义 —— 页脚隐私声明是惯例,
  重复一次不算噪音,删了反而有人以为底部没声明。
- 「(实时)」在 60 秒缓存下仍成立。

删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`
(原来靠 `tb-card-note` 的 16px 撑开)。顺手修掉模板里一条过期注释 ——
它还写着「四条时间」「哪个是基准日」,上一轮已改成三行「快照时间」。

i18n 四语言同步(有词条数量对等测试兜底);build + i18n/stats/template/templates
测试全绿,中英页脚与数据速览卡片截图确认过间距。
2026-10-25 09:18:00 +08:00
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00
tamakyi 219adefc95 fix(stats): 「今日回答」改按 answered_at 计,不再把置顶算成回答
`TodayCounts` 的今日回答数用 `updated_at >= 0 点` 过滤。GORM 的 Update
一律刷新 updated_at,于是箱主今晚整理一批三个月前的老问题(置顶、
公开/私密切换,一次回答都没发生),/stats 顶部「今日回答」就凭空多出几十条。

实测(临时测试,跑完已删):一条三天前回答的提问,依次 SetPinned /
SetPrivate / SetPublic 之后,today answers 从 1 变成 2。

改成 `answered_at >= since`(首次回答时间,改写回答不重置):
口径变成「今天首次被回答的提问数」,与页面文案「今日回答 / Answered
today」和「回答耗时按首次回答算」都对齐。历史行 answered_at 为空时
`>=` 不成立、自然不计数 —— 那些行本来也不可能「今天被回答」。

性能:answered_at 没有索引,这条 COUNT 是全表过滤;但改动前的
updated_at 同样没有索引,本来就全是 Seq Scan。20 万行实测
旧 36.7ms / 新 38.0ms(差 3.5%,噪声级),执行计划都是
Seq Scan + Aggregate。页面另有 60 秒缓存,不需要为它加索引;
注释里写了真慢了再加 idx_questions_answered_at。

新增 TestTodayAnswersIgnoreNonAnswerWrites(改前红:期望 1 得到 2;
改后绿)。
2026-10-24 10:37:36 +08:00
tamakyi 8380fc9e2b fix(db): 「最后一个回答」不再被置顶/公开切换推到现在
`QuestionStats` 的 `last_a` 取的是 `MAX(updated_at)`,而 GORM 的 Update
一律刷新 updated_at:置顶、公开/私密切换、标记已读都会走 Update。
后果是箱主整理一下旧问题(一次回答都没发生),公开接口
`/api/v1/site-stats` 就说「刚刚有人回答了」。

实测(真库,临时测试已删):一条 9 天前回答的提问,置顶/公开切换后
LastAnswerAt 从 `10-15 10:23` 变成 `10-24 10:23`,偏差 216 小时。

改成 `COALESCE(answered_at, updated_at)`:
- 新数据取 answered_at(首次回答时间,改写回答不重置);
- 加列之前的历史行 answered_at 为空,退回 updated_at —— 与改动前口径
  一致,不会让这些行从统计里消失(有测试锁住这一半)。

同时删掉 `GetHotQuestions`:全仓零调用点(只有接口声明 + 实现,route/
里 Questions 的 15 个调用不含它),且同样按 updated_at 排序 ——
死代码 + 错误排序,留着就是给以后的人埋坑,要用时按 answered_at 重写。

影响面:`/api/v1/site-stats` 的 `last_answer_at` 字段与 `text` 里的
「最后一个回答发生在」,字段名与结构不变,只是值更准(更早)。无 schema
变更、无接口变更。性能:执行计划仍是 Seq Scan + Aggregate,5 万行实测
7.9ms vs 8.0ms,差异在噪声带内(0~1.8ms,折合每行约 30ns),
而该接口有 30 分钟进程内缓存。

新增两个回归测试:
- TestQuestionStatsLastAnswerIgnoresLaterEdits(改前红、改后绿)
- TestQuestionStatsLastAnswerFallsBackToUpdatedAt(历史行回退)
2026-10-24 10:30:43 +08:00
tamakyi cbb8908275 feat(stats): 近 30 天折线补纵轴刻度
原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己
在看 3 次还是 300 次的起伏。现在补上三条刻度(上限 / 一半 / 0)加对应
的横向虚线。

纵轴上限取 niceMax(峰值),不是峰值本身。峰值正好压在顶格里时,读者
分不清那到底是确切的数还是被裁掉的数;向上取整到 1/2/1.5/3/4/6/8×10^n
之后顶部留白固定,三条刻度又都是整数和好读的数(1.5 倍只在量级≥100
时参与,否则算出来是 7.5 这种半格)。上限恒为偶数,中间刻度必然是整数。

刻度文字不放进 SVG:viewBox 用 preserveAspectRatio="none" 横向拉伸填满
卡片,里面的文字会跟着被拉扁,窄屏尤其明显。改成左边一列 HTML 标签 +
横向虚线仍在 SVG 里(描边锁 non-scaling-stroke)。列宽靠一份看不见的
「最宽刻度」占位撑开,不写死 em。

同时把 Polyline/AreaPath 收敛成单个 NewLineChart:坐标、面积、刻度必须
共用同一个上限,分两次算就会各按自己看到的峰值归一化,出现刻度和线错开
的图。面积路径改为沿基线闭合,不再漏进轴标签的留白区。

新增 chart_test.go 锁住三条不变式:中间刻度必为整数、上限不离开峰值一倍、
刻度/折线/面积共用上限;另有 0..2000 的穷举检查。
2026-10-24 01:57:36 +08:00
tamakyi 0226956feb fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题
在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。

1. 回答耗时不能用 updated_at - created_at(P0)
   GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
   实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
   于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
   和偏低的 24 小时回复占比都是这么来的。
   修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
   写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
   首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
   否则历史值会随着后续的已读/置顶继续变大。

2. 环比句式读不通
   中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
   改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。

3. 脚注说「当日 0 点的快照」不实
   实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
   基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。

4. 数据库故障时请求会排成 N×30s
   ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
   加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
   todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。

测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
2026-10-24 01:45:34 +08:00
tamakyi 0c282914c2 fix(stats): 今日计数不再被每日缓存冻结,日期分组不依赖库会话时区
自查部署结果时发现两处正确性问题,都是本页上线时引入的:

1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」,
   实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取,
   60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针
   (那会被并发读踩成数据竞争)。

2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库
   会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。
   实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 ——
   日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。
   现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自
   time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。
   过滤条件仍是裸列,索引照用。

另外两处顺手修:

- PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。
- 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时,
  排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。

测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次)
与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。
2026-10-24 01:20:02 +08:00
tamakyi 64436d9d61 fix(stats): 数据速览用完整星期名,脚注时间改逐行对齐
- 「提问最集中在 三 19 点前后」:洞察复用了热力图列头的短标签
  stats.wd.*(一…日 / Mon…),那是为窄列头设计的单字词条,
  进句子就成了「三」。新增 stats.weekday.1..7 完整名(周一 / Monday /
  月曜日),洞察改用该组,stats.wd.* 只留给列头;补测试断言锁住。
- 页脚入口文案「数据」→「统计数据」(四语言同步)。
- 页面脚注四条时间由「基准日 x · 计算于 y」一行改为两列网格逐行排列,
  标签统一带冒号,下面两段说明保持独立段落。
2026-10-24 01:08:50 +08:00
tamakyi a5441c99fd feat(stats): 新增公开数据页 /stats,入口收敛到页脚
首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
2026-10-24 00:57:08 +08:00
tamakyi c680671b7e feat(user/questions): 手机端时间跨年自动补年份,避免省略年份后产生歧义
DateCompact:与当前同一年的渲染「月-日 时:分」,跨年补回完整「年-月-日 时:分」。
只砍年份会让「去年今天」和「今年今天」显示成同一串,逐条补年份比按年分组便宜得多
(列表带游标分页 + 可切排序/筛选,组标题会跨页重复或错位)。
桌面端不受影响,仍显示 Y-m-d H:i:s。
测试覆盖年份边界(同年 1/1 与 12/31、跨年);预览数据里放了一条 2025 年的提问用于肉眼核对。
2026-10-23 21:04:50 +08:00
tamakyi 812b70cb4c fix(user/questions): 筛选标签改为「已读 是/否」,手机端条目标签行不再折行
- 筛选栏第 5 列标签「阅读状态」四字,在手机约 53px 的窄列里必然折成两行,
  改为「已读」,选项改为 全部/是/否(option value 仍是 read/unread,后端不变)。
  词条 qlist.readStatus 由 qlist.readFilter + qlist.filterYes/filterNo 取代,四语言同步。
- 条目行「时间 + 状态标签 + 标记/置顶」在手机上放不下:实测 360 宽可用 306px,
  完整时间单条就占 127px,2 标签 + 2 按钮需 343px。手机端时间改为「月-日 时:分」
  (tb-short/tb-text 复用状态标签的 640px 断点,桌面端仍显示完整年月日时分秒),
  同时把 .qlist-pill 的基础内边距 40/8 收到 34/6(桌面 @media 仍覆盖回 62/10)。
  结果:2 标签 + 2 按钮在 360 宽即单行;三标签(私密+待答+未读)在 ≤375 仍会折行。
2026-10-23 21:02:37 +08:00
tamakyi 86ea96e5ca feat(autoban): 封禁响应图改为 ban_image_path 配置,默认仍是纯文字
上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、
由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。

改为配置驱动:
- 新增 [security] ban_image_path(相对路径以进程工作目录为基准);
- 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字
  "403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为
  一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn);
- 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。

安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。
把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件
原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。

顺带删掉 internal/route/assets(webp + embed),已无引用。

测试:
- TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文;
- TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、
  Content-Length 与文件一致、body 与文件逐字节相同;
- TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误
  都不得装载,且**不得把文件内容当响应体吐出去**;
- TestLoadBanImageTooLarge:超 2 MB 拒绝;
- TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。
变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去:
"[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。

实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
2026-10-23 15:25:07 +08:00
tamakyi 6fad0cbe50 docs: 说明封禁响应的图片体与替换方式 2026-10-23 15:04:14 +08:00
tamakyi 0318ba81b4 feat(autoban): 封禁响应体改成一张 403 图片
被封禁来源访问任何路径时,原来只回 13 字节的 "403 Forbidden":既没有信息量,
人肉访问(尤其是共享出口被连累的误封)时看着像站点挂了;扫描/爆破脚本也常靠
读这行明文确认「自己已被拦」。

改为返回一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),状态码不变:
- 图片用 //go:embed 编进二进制,不依赖外部 URL、不占公开路径;
- 显式给 Content-Length(8 KB 超过 Go 的 2 KB 写缓冲,不设会退回 chunked);
- Cache-Control: no-store —— 中间层缓存 403 没有意义,还会让解封后的响应继续命中;
- HEAD 只回头不写 body;
- 放在 internal/route/assets/ 而不是 static/:static 下的东西有可公开访问的 URL,
  等于多一个指纹入口。

注意链接:给的 R2 预签名地址 X-Amz-Expires=604800(7 天),热链到期就会挂,
所以是把字节取回来打包,而不是重定向或代理。

测试:
- TestWriteBanned:状态码 403、image/webp、Content-Length 与内嵌字节数一致、
  body 前 12 字节是 RIFF/WEBP(防止被换成 HTML 或明文这类回归);
- TestWriteBannedHead:HEAD 无 body 但有 Content-Length;
- 原 TestHoneypotBansEvenWhenAutoBanDisabled 里断言 "403 Forbidden" 明文的用例
  改为断言 image/webp 与体积。
变异验证:把 writeBanned 改回写明文 + text/plain,两条用例均按预期变红。
2026-10-23 15:04:13 +08:00
tamakyi 9d3f5a72fa docs: 补 XML-RPC 端点行为、动态 token 与 .php 变体说明,记实现取舍 2026-10-23 12:58:49 +08:00
tamakyi 307bd94ed1 feat(honeypot): /xmlrpc.php 走真实 XML-RPC,动态 token 不再恒定
线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装:

一、/xmlrpc.php 回的是 HTML 登录页(P1)
我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来,
一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。
更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、
且绕开登录页限速插件,是 WP 爆破里产出最高的入口。

新增 xmlrpc.go:
- 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致)
- POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
- POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
- 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除
  methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让
  配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进
  fields(xmlrpc.0/1/…),配对失败也不丢原文。
  multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串
  密码是常见字典形态,只留第一组等于丢掉整份字典。
- 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。

二、进入页面的隐藏值每次请求都一样(P2)
两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能
判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token,
不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级),
页头、hidden 与 Set-Cookie 共用同一套。

顺带:
- X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身
  就是破绽;没配 external_url 时干脆不发这个头。
- Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0
  (no-store 更像安全设备/代理加的,不像登录页)。
- Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。
- Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php,
  原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。

测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id /
非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且
是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、
所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 +
回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。
四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、
.php 兜底失效。

性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),
AssetFor 仍 0 分配。
2026-10-23 12:58:48 +08:00
tamakyi 7225f680d4 docs(memory): 记录蜜罐家族伪装的实现取舍与预览工具的用途 2026-10-23 12:38:43 +08:00
tamakyi db97ac13ed docs: 蜜罐文档补家族伪装与资源开销
honeypot.md:
- 开篇从「一个假 WordPress 后台」改为「按扫描目标伪装成对应后台」,列出 9 个家族
  与各自触发路径;
- §1 补静态资源的处理(回占位内容、不落库、插件 .php 回 404 的理由);
- §3 封禁路径表补一行「Tomcat Basic 认证」的归属,说明 401 也走凭据通道;
- 补资源开销的实际数字,便于运维判断这功能值不值。

cli.md:paths 说明补家族汇总。
2026-10-23 12:38:42 +08:00
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00
tamakyi edde075406 update workbuddy file 2026-10-23 11:14:27 +08:00
tamakyi 29ddee07db docs(memory): 记录封禁键/取证地址分离的根因、测试与变异验证经验 2026-10-23 10:53:13 +08:00
tamakyi 90a284a3dd docs: 写清「封的是一把键」与取证地址的区别
honeypot.md:
- §3 新增小节:LimitKey 折叠规则(IPv4 原样 / IPv6 折 /64)、邮件里两个地址
  各自含义、封禁只在内存里、建封 WARN 的字段与 grep 方法;
- §4 注明取证存的是精确地址,不是 /64 聚合键;
- §8 排障首行补「邮件说封了但还能访问」的三种原因与判定路径,
  并提醒「邮件里的来源 IP 不是封禁键,拿它对 ban_key 永远对不上」。
cli.md:stats 说明改为「来源排行(IPv6 按 /64 聚合)」,补 ip 语义。
2026-10-23 10:53:12 +08:00
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00
tamakyi aa758884f9 docs(memory): 分享卡缓存 key 排查结论 2026-10-23 00:58:43 +08:00
tamakyi 675cecb21d fix(share): 单题卡缓存 key 补 user.Domain;纠正过期的 TTL 注释
先查了「缓存 key 漏项」到底有没有实际影响,结论是**当前没有**:

- 用户域名只在注册时写入(register.go / CreateUser),没有改域名的入口,
  所以两张卡的二维码内容不会在运行期变化
- ExternalURL / ShareBackground 来自配置,改了必须重启,而缓存是进程内的,
  重启即清空 —— 不存在「配置变了缓存还在」的窗口
- 头像、背景上传走 R2 随机 key(picture/日期/随机 hex,内容不可变),
  换图必然换 URL,key 随之变化,不会出现「同名不同图」

只有一条理论缺口:域名要是将来能被修改,单题卡 key 没算 user.Domain,
会出现最长一小时的「二维码指向旧域名」。补上这一项成本为零,先按事实算进去。
shareCardCacheKey 本来就含 Domain,两张卡现在一致。

顺带把两处「TTL 30 分钟」的注释改成 1 小时(代码一直是 time.Hour)。
新增 TestShareCardCacheKeysCoverDrawnFields:逐个改动画进图里的字段,
断言 key 必须随之变化;去掉 user.Domain 后立刻报
"user.Domain is drawn on the question card but not in questionShareCardCacheKey"。
2026-10-23 00:58:31 +08:00
tamakyi 8f849123f7 docs(memory): 两张分享卡部署复验(几何 + 四档宽度解码) 2026-10-23 00:38:53 +08:00
tamakyi d5398f9c2f docs(memory): 单题卡二维码 5px 与不落盘的决定 2026-10-23 00:27:27 +08:00
tamakyi 310f85156a perf(share): 单题卡二维码同样提到 5px/模块
单题卡右侧卡片只有 210×255,二维码最大只能到 148px(37 模块 = 4px/模块),
缩到 480 宽照样解不出。

- QQRCardW 210 → 236、QQRCardH 255 → 300、QQRSize 150 → 190(37 模块 → 185px)
  卡片仍居中对齐(x 679、y 120),左侧文字区随 QQRCardX 派生,自动收窄 26px
- 二维码卡片绘制抽成 drawQuestionQRCard,可在测试里直接调用出图
- 新增 TestQuestionQRCardLayout:二维码必须正方形、模块 ≥5px、四边留白 ≥20px、
  居中偏差 ≤1px,TEST_PREVIEW=1 输出整卡 PNG;把 QQRSize 改回 150 立刻 FAIL

实测:二维码 185px(5px/模块)、居中偏差 0.5px、上下留白 34px;
gozxing 在 960 / 720 / 480 / 320 宽下都能解出正确链接。
2026-10-23 00:27:07 +08:00
tamakyi d0a1bb8435 docs(memory): 分享卡模块像素实验矩阵与 2x 成本基准 2026-10-22 23:37:04 +08:00
tamakyi 72f08cd7c9 perf(share): 用「同画布放大二维码」替代 2x 渲染,模块 4px → 5px
960×540 的卡被聊天软件缩到 480 宽时,二维码每模块只剩 2px,解码器读不出。
2x 渲染能解决,但实测成本不低(见下),先用零代价的办法把模块做大。

实测矩阵(go-qrcode 纠错 Q / 真实 55 字符链接 / 37 模块,含内嵌头像):
  尺寸 148px = 4.0px/模块 -> 960 o 720 o 480 x 320 x
  尺寸 190px = 5.0px/模块 -> 960 o 720 o 480 o 320 o 240 x
纠错降到 M(33 模块)只省 11% 模块数,容错却从 25% 掉到 15%,不划算;
PNG 只能保住原图,缩到 480 一样解不出,体积还是 JPEG 的 5 倍。

- QRCodeSize 165 → 190(37 模块取整为 5px/模块;生产短域名 33 模块为 165px)
- QRCardH 315 → 335 容纳变高的二维码,卡片整体仍垂直居中
- 新增 TestGenerateQRModuleSizeAtLeast5:三种常见链接长度下模块必须 ≥5px,
  把 QRCodeSize 改回 165 立刻 FAIL

渲染成本基准(1x vs 2x):JPEG 编码 12 → 48ms、背景 Lanczos 缩放 25 → 41ms、
画布分配 3 → 13ms,二维码生成本身 0.8ms —— 2x 的代价主要在编码与缩放,
与二维码尺寸无关,所以放大二维码不增加任何 CPU。
2026-10-22 23:36:02 +08:00
tamakyi 9fb003cc39 docs(memory): 分享卡复验结论(着墨偏差实测、二维码静默区回归、4px/模块上限) 2026-10-22 23:24:32 +08:00
tamakyi b35c2a8f45 fix(share): 二维码不再被静默区吃掉三分之一尺寸;卡内间距收到 4px 栅格
上一版改成按模块整数倍出图时,静默区仍然画在图像里:49 个模块(41 + 4×2)
挤进 165px 只剩 3px/模块、符号 111px,比改造前的 135px 还小 —— 这是自己引入的
回归。测试卡上验证时才发现:二维码在 960 宽的卡里只占 111px,微信/QQ 二次
压缩到 480 宽后模块不足 1.5px,gozxing 直接解不出来。

- generateQR 改 qr.DisableBorder:图像只输出符号,静默区交给承载它的白色卡片
  (四周留白 30px 以上,远超 4 模块)。符号尺寸按 size/modules 取整,最多
  浪费一个模块宽度
- 调用方按实际符号尺寸居中与排版,不再假设等于目标尺寸
- 单题卡 QQRSize 128 → 150:37 模块的题目链接因此拿到 4px/模块(原 3px)
- 卡内垂直间距 26/21/21/15 → 24/20/20/16,落在 4px 栅格上

验证:profile 卡二维码 111px → 148px(37 模块 × 4px,用满 90%),单题卡
111px → 148px;gozxing 解码 960 原图、75% 缩放图均能读出正确 URL(含内嵌
头像)。守门测试加了两条:符号宽度必须等于 modules×perModule 且吃掉目标尺寸
≥80%(变异成「含静默区」立刻 FAIL),头像测试改判四角必须是定位图案而非
白色(防止符号被裁切)。
2026-10-22 23:22:34 +08:00
tamakyi a215c2ba2b docs(memory): dev 分享卡 500 的根因(字体从未入库)与二维码模块对齐 2026-10-22 22:33:53 +08:00
tamakyi d51629ef2f fix(share): 字体缺失不再永久失败;二维码按模块整数倍绘制
测试环境部署后分享卡整站 500「字体文件未配置或无法加载」。根因不是代码
回退,而是字体从来就不在仓库里:static/fonts/ 既没有入库(git 历史为空),
也没有 embed,凡是按仓库新建的环境都缺这个文件,只有手工补过字体的老环境
才正常。

- getFont 不再用 sync.Once 永久缓存失败:补上字体文件即生效,无需重启;
  失败时错误信息带上「已尝试的路径」,排障不用再猜是配置还是文件
- 候选路径:font_path → static/fonts/*.{otf,ttf,ttc} → 常见系统 CJK 字体,
  换过字体文件名也能用
- app.sample.ini 注明字体必须自备、缺失的后果

顺带做 P1 的第一项:generateQR 改为按模块整数倍出图(41 模块 @165px →
每模块 4px、符号 164px 居中)。原先是 256px 生成后最近邻缩到 165px,
每模块 4.02px,模块宽窄相差 1px,小尺寸二次压缩后更容易扫不动。
内嵌头像改为按目标尺寸等比(size*48/256)+ 2px 白边,静默区不受影响。

守门测试新增两条:TestGenerateQRIsModuleAligned(扫描线游程必须是模块
宽度的整数倍,变异成等比缩放后立刻 FAIL)、TestGenerateQRWithLogo
(中心是头像、四角静默区纯白)。
2026-10-22 22:33:22 +08:00
tamakyi 80e042d154 chore: 分享卡排查记录;.gitignore 忽略 .workbuddy/local 2026-10-22 21:42:56 +08:00
tamakyi 0054e508b7 fix(share): 分享卡链接按真实字宽居中,长链接按 host/path 收敛
原实现用字符宽度系数估算文本宽度,把标点/符号一律按 1em 计,
而 URL 里的 : / . _ - 实际只有 0.25~0.35em,整句高估 55%:
15px 的 box.shiroko.one/_/tamakyi 估算 352.5px、真实 227.9px,
越过卡片可用宽 230px 后触发等比缩字号,最终只画到 9.6px 且左偏 40px;
链接越长字号越小(长域名实测 5.3px),整段挤在卡片左侧。

- measureTextWidth 改为逐字累加 HMetric.AdvanceWidth + Kern,
  scale = fontSize*64 与 freetype 在 72 DPI 下的换算一致,测量结果与
  DrawString 返回笔位逐位相等;字体不可用时回退估算
- drawTextCentered 随之修正,底部水印原本偏 9.8px 一并归位
- 新增 displayURL(去协议前缀)/ layoutLink / ellipsizeMiddle:
  单行 15→13px;放不下按 host/path 拆两行 13→11px;host 超宽才中间省略;
  字号下限 11px,行长恒定不超过 QRCardW-40
- 卡片内容高度按链接行数计算,两行时整体重新居中

守门测试见 route/question/share_layout_test.go(测量=笔位、限宽、按着墨
边界判定居中 ±3px、TEST_PREVIEW=1 出图)。
2026-10-22 21:42:50 +08:00
tamakyi 90c0cfd27f docs(memory): 透明底素材的生成与抠图口径 2026-10-22 19:59:23 +08:00
tamakyi ebc4586ef4 theme(shiroko): 新增平涂极简风肖像素材(透明底)
按用户给的参考图风格生成三张白子肖像:平涂、零描边、纯黑圆角矩形眼、无嘴、腮红、头部裁边。

- static/images/themes/shiroko/portraits/portrait-{front,tilt,close}.webp(907–1024×921,共 197KB)
- 未接进 --tb-deco,留给用户自行摆位;docs 里写了接入示例与提示词骨架。
- 抠图走"指定纯色底 + 按色值算 alpha":ImageGen 的 transparent 至今返回无 alpha 的 RGB,
  按白底抠会吃掉白衬衫、也填不到闭合光环内部。
2026-10-22 19:58:45 +08:00
tamakyi 1addeb56b8 fix templates v4 2026-10-22 18:50:15 +08:00
tamakyi 0181fc5c66 fix templates v3 2026-10-22 18:26:30 +08:00
tamakyi b939d26ff0 fix templates v2 2026-10-22 17:49:25 +08:00
tamakyi bd02eb7ed2 fix templates v1 2026-10-22 17:04:10 +08:00
tamakyi 8fcb23f7b1 记忆:生成器顶栏面板 2026-10-22 16:16:05 +08:00
tamakyi e4810040a7 生成器新增「顶栏」面板:实色 / 半透明磨砂 / 全透明(仅预览)
以前顶栏只能在配色里填一个颜色字符串:写 rgba 拿不到模糊,档位和颜色又分在两处,
改了一处就忘了另一处。现在三档一个面板说话。

- S.navbar = {mode, alpha, blur},基色仍取「配色 → 顶栏」,navbarValue() 统一计算:
  solid 先 flatten 压平(旧工程里可能自带 alpha)、glass 加 alpha、clear 出 transparent。
  半透明档额外写 .uk-navbar-container{backdrop-filter:blur(N)} —— 令牌只能给颜色,
  模糊必须落成规则;选择器不带 data-theme,深浅两套共用一条。
- 全透明**只给预览**:validate() 拦导出,与 internal/theme 的 TestNavbarNotFullyTransparent
  同一条规矩,面板上直接写明原因。
- 面板带「导航文字 x.x:1」读数,按压平后的顶栏算(半透明时是顶栏压在底色上的结果),
  低于 4.5 变橙。
- 旧工程迁移:配色里塞过 rgba(...,0.52) 的,按 alpha 反推档位(1→实色 / 0→全透明 / 其余→半透明),
  打开旧工程不会突然变实色。
- 自检 ?dump=navbar(10 项:三档产出、blur 有无、全透明被拦、alpha 0.2/0.95 边界)。
- 滑杆步长 0.02 → 0.01:0.55 会被吸附成 0.54/0.56,手感像坏了。
2026-10-22 16:15:49 +08:00
tamakyi 8941744621 new-sakura:顶栏改半透明磨砂,并立规矩禁止全透明
线上(box.shiroko.one/_/tamakyi)两个现象来自同一处:`--tb-navbar` 是实色,
顶栏那条带(y=20..80)把从右上角长出来的花枝齐刷刷切掉一段。

第一版顺手改成 transparent —— 花枝完整了,但顶栏"消失":导航失去依托,
文字直接压在花纹上。用户当场否掉,要求半透明。

- 顶栏:`rgba(253,246,248,0.72)` / 深色 `rgba(34,26,30,0.72)`,配
  `.uk-navbar-container{backdrop-filter:blur(12px)}`(令牌只能是颜色,模糊写在规则里)。
  0.72 有依据:导航文字 #6d555d 纯底 6.34:1,最坏情况(深花瓣在顶栏后面)压平后 5.08:1。
- 花枝 top -16px → 26px:顶栏半透明后,700~760px 宽度下枝头正好压在「登录」上(截图确认)。
- 手机底栏 #tb-mobile-utils 在 base.css 写死 rgba(255,255,255,.92),粉底上是一条白杠,
  主题里改成同色系 0.92。
- 新增 TestNavbarNotFullyTransparent:拦 `transparent` / `rgba(...,0)` / `#rrggbb00`,
  并说明"这不是审美偏好,是踩过之后立的规矩"。变异验证过(改回 transparent 立即 FAIL)。
  约定同时写进 static/css/themes/_scaffold.css 的令牌说明。
- StaticVersion 20261022b → 20261022c:css 改了不提版本号,浏览器拿着旧样式,
  现象就是"改了但线上没变"。
2026-10-22 16:05:37 +08:00
tamakyi 2981731d4f announcement v3 2026-10-22 15:28:04 +08:00
tamakyi 33fa643465 announcement v2 2026-10-22 15:09:44 +08:00
tamakyi e1e840f895 announcement v1 2026-10-22 15:08:24 +08:00
tamakyi c9f22cb81b 修窄屏装饰层压不住作用域块(手机上一直显示桌面版樱花)
@media 不提升特异性:作用域块 html[data-theme-style][data-page-scope="box"] 是 (0,2,1),
媒体查询里写的 html[data-theme-style] 只有 (0,1,1),于是「窄屏装饰层全置 none」这行
从来没生效 —— 手机上照样铺开桌面的花枝与花朵。深色作用域块是 (0,3,1),更压不住。

- 媒体查询里补三条同特异性的通配规则(作用域 0,2,1 / 深色基础 0,2,1 / 深色+作用域 0,3,1),
  媒体块在文件更后,同特异性即胜出。
- 新增 TestNarrowMediaOverridesScopedDeco:外层每处非 none 的 --tb-deco,都要求窄屏里有
  同属性名、属性数不少于它的声明。变异验证过(删掉深层那条立即 FAIL)。
  它同时报出 shiroko 的 900px 块只覆盖浅色 —— 但深色块本就是 none,不算缺陷,故跳过。
- 窄屏改由加强版底纹承担樱花(透明度 ×1.9、格子 180px):桌面那套 0.18~0.34 的透明度
  在 390px 上实测接近纯粉。顶栏与卡片之间那条 35px 带子试过放小花,question/admin 页卡片
  从 y=110 起会切掉一半,最终不放装饰层。
2026-10-22 11:18:29 +08:00
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00
tamakyi 48a1938fb2 新增 new-sakura 主题:纯 CSS 樱花,每个页面位置不同
樱花不依赖任何图片素材:整朵花、带缺口的花瓣、带枝的花朵树都是内联 SVG 的
data URI,写在主题 CSS 里。好处是零请求、任意缩放不糊、换花色只改一个色值;
代价是 CSS 约 50KB(gzip 约 12KB),走 StaticVersion 缓存。

- 六个页面作用域各一套 --tb-deco 位置(global / box / question / admin /
  profile / vote),10 槽三组等长;位置一律「关键字 + 偏移」两值写法。
- 窄屏(≤640px)装饰层全关、底纹缩到 200px:卡片占满宽度后枝条会糊在字上。
- 深色换亮一档的粉、关掉底纹(浅粉压在深底上会变噪点)、背景用渐变避免死黑。
- 亮色 primary 从候选 #c14a72(4.39:1)压深到 #b8436a(4.87:1),
  muted 5.27:1,都踩住 AA;深色对应 8.17:1 / 5.82:1。
- 生成脚本 dev/theme-tools/gen_new_sakura.py(花形、位置表、配色都在里面),
  CSS 是产物,不手改。
- 四份词典加 theme.name.new-sakura;conf/app.sample.ini 的 available 登记。

为什么不用生成器:生成器的装饰层只认图片素材,没有内联 SVG 图层,
见 docs/theme-new-sakura.md「为什么不用主题生成器出图」。
2026-10-22 10:45:39 +08:00
tamakyi 8daa5b0bec 生成器默认监听 0.0.0.0,局域网/手机可直接打开
- serve.py:--host 默认改 0.0.0.0;启动时列出所有网卡地址(0.0.0.0 不能直接点开);
  --host 127.0.0.1 仍是「只给本机」的收紧方式,README 说明公共网络用它。
- 改单线程为 ThreadingTCPServer:上传几 MB 的图会占住连接,单线程时 iframe 预览
  和其它请求都得排队。
- 写接口加同源校验(Origin 与 Host 比对,无 Origin 放行):挡住同网段里「另一个网页
  借访客浏览器发写请求」,curl / 脚本不受影响。
- index.html:提示文案不再写死 127.0.0.1,改用 location.host —— 用局域网地址打开时
  不该反过来提示「请用 127.0.0.1 打开」。
- mock_s3.py 加 --host(默认仍回环),便于在别的设备上联调。
2026-10-21 21:46:34 +08:00
tamakyi 12c4f3e1be 生成器可检测与管理远端主题素材(列表 / 差异 / 覆盖 / 删除)
「传上去」之后还得知道「传得对不对」:同一 slug 传过三次、换过 slug 旧图还在、
重新导出后远端仍是旧版,这些在页面上都看不出来。

- serve.py:s3_request 支持 query;新增 ListObjectsV2(含分页)与 DeleteObject,
  端点 /api/s3/list、/api/s3/delete。
- index.html:远端素材清单按对象键名跟本地比 —— 一致 / 旧图(同名但大小不同)/
  多余(当前主题已不引用)/ 缺失。「补齐差异」只传缺的与旧图,「删除多余」批量删,
  单行可 ↻ 覆盖或 ✕ 删除;删除走 confirm 二次确认。改过 slug 会提示重新检测。
- 修:list 返回完整键而 upload/delete 收相对键,不剥前缀会删到不存在的键
  (S3 回 204,看起来删成功其实没删)。加 relKey() 统一。
- check_sigv4.py 补带 query 的用例(ListObjectsV2 三参数、乱序 token)→ 8/8;
  selftest_upload.py 补列表/分页/幂等删除 → 20 项全过。
2026-10-21 21:29:35 +08:00
tamakyi 9c961b3c05 主题素材可放任意 S3 兼容存储,生成器内测通后一键上传
素材位置改成两选一(本地随二进制 / 自行填 Endpoint 的 S3 兼容存储),
R2、MinIO、B2、OSS-COS 的 S3 端点都只差 Endpoint/Region/路径风格。

- serve.py:手写 SigV4(PUT/GET/DELETE/HEAD),新增 /api/s3/test 与
  /api/s3/upload。测试连接依次验连通、写权限、读回一致、匿名可读,
  失败把 S3 的 <Code> 翻成可执行的建议;上传带 immutable 缓存头。
- index.html:新增「素材托管」面板(模式切换 + 7 个字段 + 测试/上传按钮),
  上传集合与导出共用 usedAssetIds();成功后自动回填「素材前缀」。
  密钥只进 sessionStorage 与内存,不进草稿/工程 json/导出 zip。
- 修:usedAssetIds 返回 Set,误用 length/map 导致「还没有素材」。
- 修:字段填完 700ms 内刷新会丢配置 → 失焦与模式切换时立即落盘。
- 自检:check_sigv4.py 与 aws-sdk-go-v2 signer 逐字节对拍(6/6),
  selftest_upload.py 起假 S3 跑通测试连接→上传→读回(16 项),
  mock_s3.py 供手动点面板用。
2026-10-21 21:08:39 +08:00
tamakyi 6c21184733 主题生成器支持素材前缀(主题素材可放 R2/OSS)
主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材),
所以只要让 CSS 里的地址指向对象存储即可。

- 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致);
  填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」
- 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接,
  只改 urlOf 会出现「背景走 CDN、装饰还指本地」
- 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检
- INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出
- 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、
  缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令
- README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
2026-10-21 19:32:55 +08:00
tamakyi f37ff28a46 提问管理页面板补卡片底:换主题后内容不再裸铺在场景上
现象:只有 /user/questions 这一页的「卡片」像完全透明的,其它后台页正常。

根因不是主题,是模板本来就没有卡片容器:提问列表是
<div class=uk-margin-medium-bottom> + <hr> 直接铺在页面上,整个模板只有 1 处 .uk-card
(还是投票 tab 里的「我的投票」标题条)。默认主题底色是纯白,所以看不出来;
换成带整页插画的主题后,场景整片透上来,就成了「卡片透明」。

给 .qlist-panel 兜一层卡片底(--tb-surface + 圆角 + 阴影),三个 tab 共用;
面板内已有的一张 .uk-card-default 降成 --tb-surface-2 去阴影,避免卡片套卡片。
≤640px 收紧内边距。与主题无关,默认外观也会多这层卡(和其它后台页一致)。
2026-10-21 19:32:29 +08:00
tamakyi 34a262091b 修 shiroko 单车/步枪素材「白色没抠」:白上白只能全白转透明
根因不是抠图参数:素材是白底+白车架,车架涂装与背景白是同一个值(都是 #f9f9f9),
颜色上无法分离。四角 floodfill 只吃掉外圈,轮圈内、车架三角、车筐、枪与车架之间的白整块留着,
量化就是不透明像素里近白(>240) 占 50%。

- 处置:min(r,g,b)≥238 软过渡 + 饱和度>14 保留彩色 → 白全透明。近白降到 0.0%,
  不透明率 19.4%,29KB;轮辐/车架/车筐成为描边,粉轮圈、蓝枪托、接触阴影保留
- 试过「按近白厚度区分白洞与白涂装」(腐蚀测厚度)→ 失败:轮辐间扇形白楔 9~13px 与车架管
  5~10px 重叠,阈值怎么调都会留白楔,比原图更难看
- 画布保持 397×259 不裁:deco-size 是 auto 259px,宽度按比例算,裁了锚点会漂
- css 图片 ?v= → 20261021a;StaticVersion 20261021c → 20261021d(css 内容变了必须换缓存键)
- docs:assets-spec 补「白上白」取舍与验收项「不透明像素近白占比≈0」,theme-shiroko 素材表同步
2026-10-21 19:18:50 +08:00
tamakyi e9ac72dd7d docs(memory): 复检部署结论(色块已修、四条作用域主题链路、window-size 陷阱) 2026-10-21 19:03:09 +08:00
tamakyi 1f29d3eb2e 修设置页主题色块被模板过滤器替换成 ZgotmplZ
html/template 对 style 属性做 CSS 过滤,linear-gradient(...) 未标记安全会被整体替换成
ZgotmplZ —— 线上设置页四个主题色块一直是灰底(既有问题,不是这次配色改动引入)。

- FuncMap 新增 safeCSS(string → template.CSS);值只来自 internal/theme.swatchOf,
  由两个已校验的色值拼成,不接受用户输入,所以这里放开是安全的
- user/profile.html 的色块改 {{ safeCSS .Swatch }}
- 新增 TestThemeSwatchRenders:渲染 user/profile,断言不含 ZgotmplZ 且含渐变串;
  已做变异验证(去掉 safeCSS 后该用例失败)
2026-10-21 18:53:10 +08:00
tamakyi 27856dd4a2 字号阶梯收尾 + 主色补到 WCAG AA(新增 --tb-on-primary)
一号事项(剩余令牌化):
- templates/user/profile.html 20 处、base.css 9 处 font-size 全部落到 --tb-fs-*
- base.css 导航四档(0.95/0.88/0.82/0.78)合并成「桌面 md → ≤1000 xs」;logo 三档统一 lg
- 有理由保留的:.tb-pin-flag / .md-body code 的 0.9em(相对父级)、验证码 1.4/1.2rem(等宽字形)、
  chat 关闭按钮 40px、password 页 3em、mail/*(邮件客户端不保证 var());行内 12px/0.75rem 等值顺手换掉

二号事项(主色对比度):
- base/default #1e87f0(3.42) → #0e6fd0(4.70);shiroko #4a7fc1(3.69) → #3b6dac(4.74);
  sakura #e5678f(2.80) → #ba3e66(4.70,饱和度压到 .5 保持柔和)。同色相只压亮度,目标 4.7 留余量
- 深色块 muted 之前只对 --tb-bg 算(4.7 以为过了),对压平后的卡片其实 4.31/4.17 → 提到 #a18790 / #7f8ea1
- 新增 --tb-on-primary(亮 #ffffff / 暗 #0b1016):深色模式主色偏亮,白字只有 2.2~3.1:1,
  用同一个令牌兼顾「链接色要深」和「按钮底要深」是矛盾的。.uk-button-primary 与
  .uk-label:not(语义色) 改吃它 → 深色按钮变成浅底深字(6.10 / 8.65 / 7.27)
- 顺手封掉几处主色泄漏:header 验证码 focus、profile .feat-day.is-on、explore 随机投票的 JS outline、
  vote-results 行内色,全部改成令牌(否则换主题时不跟着变)

生成器同步:paletteVars 导出 --tb-on-primary(白字/深字择优);?dump=palette 增加
onPrimaryCr / onPrimaryPickedBest / warnings —— 主色卡中间调时只警告不判失败;
配色面板给橙色提示。_scaffold 令牌 29 → 30。StaticVersion → 20261021c。

验证:TestSwatch 断言同步为 #ba3e66;8 种组合(base 亮暗 + 三主题亮暗)对比度全部 ≥4.5;
go test internal/{theme,templates,conf,i18n} 全绿;生成器 validate/fit/quality/palette/layers/fx/fxpipe 全过。
2026-10-21 18:39:08 +08:00
tamakyi 252b22a342 docs(memory): 记录模板令牌化提交与四处有意的观感变化 2026-10-21 18:28:52 +08:00
tamakyi 001306d940 模板样式令牌化:字号落到 5 档阶梯、圆角改走 --tb-radius
11 个模板里的硬编码字号/圆角换成令牌,为之后的深色与主题一致性打底。

字号(base.css 的 --tb-fs-xs/md/lg/xl/hero = 12/14/16/24/28):
- 0.70~0.88rem、0.72rem 等五六档零碎值 → --tb-fs-xs(标签/meta) / --tb-fs-md(正文、列表、按钮)
- 0.95~1rem → --tb-fs-lg;1.5rem 大数字 → --tb-fs-xl;1.75rem 首页主标题 → --tb-fs-hero
- 以下几处**故意改变了观感**(不是等价替换,先记下来免得以后当 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(变大)。

圆角:
- 8/9/10px → --tb-radius-sm(8) / --tb-radius(12,对话框与气泡);3/4/5/6px → --tb-radius-xs(6)
- 头像 50%、胶囊 999px 保留原样(语义值,不是视觉随意值)

其它:header 验证码输入框边框 #ccc → var(--tb-border);chat 气泡与图片的圆角走内联 var()。

仍未令牌化(留给下一轮):templates/user/profile.html 还有 16 处 font-size、base.css 自身 11 处
(0.78/0.82/0.88/0.95/1.1),邮件模板 mail/* 保持 px 不动(邮件客户端不保证 var())。
2026-10-21 18:28:26 +08:00
tamakyi dc90d96bde 删除生成参考图 shiroko-cutout.png,文档改为「参考图不入库」
- docs/theme-shiroko.md 三处引用不再指向具体文件(它只是 image-to-image 的 image1,从不进 embed)
- 顺手修过时说明:--tb-deco 是 10 个槽位,不是 6 个
2026-10-21 18:24:16 +08:00
tamakyi 8364a2fa3d docs(memory): 记录 test01 删除已提交 2026-10-21 18:20:16 +08:00
tamakyi 2d13c31d62 删除验证用的 test01 主题
test01 是为验证生成器导出的临时主题(css + 2 张 webp),已从仓库移除。
删主题只需删文件:build() 读不到 css 会打印 warning 并从可用列表忽略;
仍选着 test01 的箱主会静默回退到站点默认外观。
2026-10-21 18:20:00 +08:00
tamakyi db4b3fa095 主题体系跟上设计令牌化:派生文字色按对比度下限、主题 muted 补到 AA
生成器(dev/theme-builder):
- paletteVars 的 text-secondary/text-muted 从「往底色混 36%/55%」改成 mixToContrast(5.5/4.5):二分找最淡且达标的位置,文字本身不达标就原样返回
- 半透明卡片先 flatten 压平到底色再算对比度;参考背景取「页面底色 + 压平后的卡片」中更不利的
- 配色面板加读数(正文/次要/淡文字/主色),低于 4.5 变橙;默认 primary #4a7fc1(3.69) → #3d6fac(4.63)
- 新增 ?dump=palette 四种配色回归;README 补配色一节

主题/base(含用户本轮 base.css 改动):
- sakura muted #a08c94(2.8:1) → #78696e(4.61)、shiroko #8794a7(2.76:1) → #656e7a(4.63):主题覆盖令牌,基座的 AA 修复到不了主题页
- 深色顶栏链接补 secondary 覆盖:亮色块已是次要文字色,深色块原来 !important 定成正文色,同一顶栏权重跳一档
- _scaffold.css 令牌清单 23 → 29(补 fs 阶梯与 page-position),muted 说明 AA 下限
- StaticVersion 20261021a → 20261021b
2026-10-21 17:42:43 +08:00
tamakyi 54df166d4e docs(memory): 记录蜜罐文档产出与 path 脏字符的最终定位 2026-10-21 16:26:48 +08:00
tamakyi 3d453749eb docs: 新增蜜罐部署运维文档,cli.md 补 honeypot 命令与排障条目
docs/honeypot.md(新增):
- 生效范围与匹配规则:挂在 NotFound、业务前缀强制剔除、三类匹配方式;
- 配置项表,并单独警告「行尾注释会被并入值」(IgnoreInlineComment 的坑);
- 两条封禁路径:GET 探测走常规阈值、提交凭据才一次即封,附原因
  (GET 就封等于拿不到字典,这是最容易误解的一点);
- 凭据文件格式/权限/保留期,以及为什么不入库(备份、主从、BI);
- trust_proxy 部署必读:回环 IP 会让取证失真且一次封禁封掉整站;
- 升级清理旧版明文列(purge-creds 不可逆);
- 排障表 + 设计取舍(GET 不封、canary 位置、邮件带明文等)。

docs/cli.md:
- §0 补两条实测踩坑:「替换二进制必须重启进程」「strings 不认中文,
  用 grep -ac」;
- 命令总览加 honeypot;
- 新增 §7 honeypot 命令(hits/stats/paths/size/prune/purge-creds),
  原 7/8/9 顺延为 8/9/10;
- 排错表加 5 条:回环 IP、整站 403、进程未重启、strings 误判、命令不存在。
2026-10-21 16:25:58 +08:00
tamakyi 74312fba58 fix(honeypot): cleanPath 处理零宽字符,并在清洗生效时打诊断日志
上一版只用 strings.TrimSpace,实测漏掉「看不见但不算 White_Space」的字符:
  cleanPath("\u200b/wp-login.php") = "/\u200b/wp-login.php"
(零宽没被删,还因为首字符不是 '/' 多补了一个斜杠,拼出更怪的路径)

改为 TrimFunc 同时匹配 unicode.IsSpace 与 U+200B/200C/200D/FEFF,且只处理首尾
—— 中间的零宽字符可能是攻击 payload,取证要保留原样。

另加诊断日志:清洗前后不一致时 WARN,用 %q 打出原始值。
测试环境出现过「记录里 path 带前导空白、本地链路却复现不出来」的情况,
不可见字符必须转义输出才定位得到。

测试:TestCleanPath 补零宽空格 / BOM / 中间零宽字符保留三个用例。
2026-10-21 16:18:25 +08:00
tamakyi 846bb8a3d9 fix(honeypot): 清洗反代链路的脏路径,回环来源 IP 打配置告警
测试环境实测(Cloudflare → nginx → 源站)发现两处:
1. 凭据文件里记录的 path 是 " /wp-login.php"(前导空格);
2. 来源 IP 记成 127.0.0.1。

path:Match 内部会 TrimSpace 所以仍能命中,但落库与假表单 action 用的是原始
req.URL.Path —— 取证数据带脏字符,爆破脚本还可能把表单提交到错误路径。
新增 cleanPath(trim + 补前导斜杠,不改大小写) 在 Serve 入口清洗一次。

IP:记录到回环地址说明站点在反代后面但 [security] trust_proxy 未开启。
此时所有访客在源站眼里是同一个 IP:取证数据失去意义,且一次蜜罐封禁会
连带封掉整站(含普通访客)。新增一次性 WARN(24h 冷却),只提示不改行为 ——
本地直连调试时回环 IP 属正常。sample.ini 的 trust_proxy 段补对应说明。

测试:TestCleanPath / TestIsLoopbackIP 覆盖前导空格、制表符、空串、
无前导斜杠、大小写保持,以及 shared.LimitKey 折叠后的 "::/64" 形式。
2026-10-21 15:54:39 +08:00
tamakyi 5e8decab8f docs(memory): 记录蜜罐审计修复、凭据外置与提交状态 2026-10-21 15:47:13 +08:00
tamakyi c29e1d071f fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 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 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00
tamakyi 566b13eedd fix(conf): 样例配置去掉行尾注释(会被并入值导致静默失效)
conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
**不剥离**行尾注释:`port = 1440 ; 注释` 解析出来的值是 "1440 ; 注释",
int 解析失败,而 MapTo 仍然返回 nil error —— 配置项静默回退零值。

实测照抄 app.sample.ini 的后果:
- text_censor_source 的值带注释,被 validateTextCensorSource 拒绝,直接起不来;
- ip_blacklist / security_alert_email 变成带注释的垃圾字符串;
- honeypot_max_rows / honeypot_auto_clean 静默失效(后者靠 sanitize 兜底才没出事)。

改动:
- 17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF;
- 蜜罐段补 honeypot_cred_dir,以及「提交凭据才封禁」的语义说明;
- 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言值不含 ';'
  (比逐字段断言彻底,新增配置项自动纳入)。
  变异验证:给 ban_window_seconds 加回行尾注释,测试按预期失败;
- keynorm_test 补 HoneypotBanMinutes 的缺省值断言(缺省 1440 / 显式 0 交给 autoban 回退)。
2026-10-21 15:43:13 +08:00
tamakyi bfdc5089c5 docs(memory): 记录 ini 键名静默失效的根因与配置约定 2026-10-21 13:41:49 +08:00
tamakyi d300167a30 fix(conf): 配置键名容错 + 未知键告警 + 蜜罐保留期缺省值修正
现场故障:配置写 `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 <"
   (带尾随比较符),更加匹配不上;
3. 警告文案里写的也是错键名,等于在误导运维往错的键上配。

修复(三层):
- 键名归一化(internal/conf/keynorm.go):修剪首尾空白、剥掉尾随 `<` `>`
  `=` 等比较符残留,并按别名表纠正常见误拼。归一化结果必须命中该段
  合法键名白名单才会被采用 —— 不认识的键不猜,交给未知键告警。
- 未知键告警(internal/conf/unknown.go):启动时逐段比对键名,无法识别的
  键 WARN 出来并给出最接近的合法键名建议。合法键名由反射从结构体 ini tag
  得出(单一事实来源,新增配置项自动纳入,不会脱节)。
- 缺省值语义修正(internal/conf/honeypot_defaults.go):
  * 保留期缺省 30 天(原先零值 0 = 永不清理);显式 <= 0 也回退 30。
    要关闭自动清理请用 honeypot_auto_clean = false。
  * honeypot_auto_clean 缺省 true。原先靠 bool 零值,ini 的 bool 解析是
    「有键才赋值」,未配置与显式关闭无法区分 → 老配置升级上来自动清理
    实际是关着的。现改用 HasKey 判定。
  * honeypot_max_rows 缺省 200000;显式 0 = 不限(会告警提醒只增不减)。
  * honeypot_threshold 缺省 1。

实测 ini 行为(探针程序,已删):
- 键缺失或值非法时 MapTo **不覆盖**预置默认值 → 可安全预置缺省值;
- 键名大小写敏感(Honeypot_Auto_Clean 不生效);
- HasKey 能区分「缺失」与「显式 0」;
- 段不存在时 MapTo 返回 nil 且不修改目标。

顺带清理 app.sample.ini 里的两条死配置(app.title / server.xsrf_expire):
代码中从未读取(站点标题走 i18n),留着会让新检测对官方模板误报。

验证:
- 用现场原始配置端到端复现 → 归一化后保留期 30 天、自动清理开启、上限 20 万,
  日志明确说明「配置键名已自动纠正」;
- 误拼键(honeypot_autoclean / honeypot_max_row)自动纠正;未知键(totally_bogus)
  WARN 告警;
- 显式 honeypot_auto_clean = false 被尊重为关闭;完全未配置时缺省开启;
- 新增 TestSampleIniHasNoUnknownKeys 回归检测:官方样例配置零误报;
- go build ./... + go test -timeout 600s ./... 全绿,go vet 无输出。
2026-10-21 13:41:19 +08:00
tamakyi cbb0c51a14 docs(memory): 记录蜜罐表体积实测数据与 PostgreSQL 死元组/VACUUM 语义 2026-10-21 13:17:06 +08:00
tamakyi 63e851ddae feat(honeypot): 自动保留期清理 + 行数上限 + VACUUM 空间回收 + size 子命令
问题:honeypot_hits 是只追加审计表,写入完全由外部扫描流量驱动(不可预期、
不可节流),而原实现只在手动跑 CLI prune 时才清理,站点运行期表体积只增不减。

实测取证(本地 PostgreSQL 建同构表灌 10.5 万行真实数据):
- 典型行 244 字节(含索引),10.5 万行 24 MB
- 保留期清理对照(行数恒定 10 万,每轮删 25% 插 25%):
  有 VACUUM 22→24MB 收敛;无 VACUUM 22→52MB 持续膨胀
- VACUUM 不缩小文件,只让空间可复用;二次实测(5 万行、连续 4 轮删插)
  行数恒定 5 万、文件稳定 15MB 不再增长

改动:
- 新增 HoneypotRetention 后台清理器(每小时一轮):时间闸 honeypot_retain_days
  + 数量闸 honeypot_max_rows,清理后调 Compact 回收空间
- db: 新增 Compact()(PG 执行 VACUUM (ANALYZE),走 GORM Exec 非事务路径)、
  DeleteOldestBeyond()(跨方言通用)、ListSinceByIP()、Size()
- 去掉 honeypot_hits.provider 无用索引(10 万行占 648kB,全代码库零查询使用),
  并在启动时显式清理既有库上的旧索引(GORM 去 tag 不会自动删)
- 修 CLI hits --ip 过滤 bug:原先取回最近 N 条再在 Go 侧筛,导致指定 IP 的
  历史命中基本查不出来 → 改到 SQL 层过滤
- 新增 CLI honeypot size 子命令:报告行数、磁盘占用、时间跨度,并与两道闸对照
- 新增配置 honeypot_auto_clean / honeypot_max_rows
2026-10-21 13:16:14 +08:00
tamakyi 7d52155411 fix(honeypot): 蜜罐封禁与 enable_auto_ban 解耦 + 泄露扫描后缀匹配
- autoban: 新增 RecordHoneypot 独立通道(不受 enabled 约束)+ 独立阈值/时长;
  IsBanned 不再因 enable_auto_ban=false 而忽略已有封禁
- honeypot: 新增 honeypotSuffixes 后缀匹配,覆盖 /backup.zip、/db.sql、/id_rsa.pem
- 新增配置 honeypot_threshold / honeypot_ban_minutes
- 补测试:autoban/honeypot_test.go、route 侧禁用自动封禁仍封禁
2026-10-21 12:36:33 +08:00
tamakyi 79051acae8 miguan v1 2026-10-21 12:09:28 +08:00
tamakyi 2d4609dc0e docs(memory): test01 排查结论(窄屏全藏 / themeName 回退) 2026-10-20 18:24:21 +08:00
tamakyi 6c014b3ad2 修 test01:窄屏装饰全藏导致素材消失、主题名显示成 i18n key
- 生成器:勾「窄屏隐藏全部装饰层」时给橙色警告(几层会消失)、图层行标「窄屏隐藏」、加层与导出结果再提醒;「窄屏」下拉在被全藏覆盖时标注不生效
- 新增 ?narrow=hide 复现该状态(这是「素材在手机上看不到」的头号来源:@media 把 10 槽全写 none,桌面完全正常)
- 主题名:新增 theme.DisplayName + 模板函数 themeName,i18n 未登记 theme.name.<slug> 时回退 slug;原来模板直接调用 i18n.T,缺词条就显示 "theme.name.test01"
- test01.css 删掉 @media 全 none 段:装饰是 fit 单位,浏览器会自己按视口缩,不需要这段
- StaticVersion → 20261020g(主题 css 内容变了,必须换缓存键,否则浏览器还会用旧文件)
2026-10-20 18:23:21 +08:00
tamakyi 3d0239d8f1 fix themes maker v1 2026-10-20 18:01:09 +08:00
tamakyi 1b027df62e 生成器:实测尺寸取整显示,去掉面板里放不下的源图尺寸 2026-10-20 17:53:10 +08:00
tamakyi 4bd600fb46 主题生成器:素材尺寸按视口自适应 + 导出画质可调
- 尺寸单位 4 档:fit(按高)/fitw(按宽)/px/vh,输出 auto clamp(min(calc(100vh-24px),55%),基准,上限),下限让位视口 → 自适应单位永不把图撑出屏幕
- fitw 补上宽幅素材:fit 只按高度缩,16:9 素材在 390 竖屏仍出 1001px 宽(照裁)
- 视口预设带高度(含 390×844);layerMetrics 统一出 CSS/预览框/溢出检测,图层编辑区显示实测尺寸并报越界,可一键按当前视口修好
- 窄屏每层策略(原样/缩小/隐藏)+ 缩放系数;修 @media 只覆盖全局、独立配置页面在手机上从不生效的特异性 bug
- 导出画质:质量滑块(上限 0.95,Skia 在 q=1 切无损会反常)+ 长边上限,改动后从原图重算
- base.css 新增 --tb-page-position(默认 center center,旧主题不受影响),StaticVersion +1
- 新增 ?dump=fit / ?dump=quality 自检,README 与 _scaffold.css 同步
2026-10-20 17:51:31 +08:00
tamakyi d2a5177afe docs(memory): 图层/背景交互修复与 ensureOwn 约束 2026-10-20 15:57:09 +08:00
tamakyi 937a8901b2 fix(theme-builder): 图层换不了图、背景关不掉、草稿清不掉
三个问题都是交互设计造成的,核心逻辑其实没坏(实测换图是生效的):

1. 「每层都只有这张图、换不了」
   - 加层时 l.asset 默认绑 S.assets[0],连加三层全是同一张;
   - 换图的下拉只在「选中那一层」的编辑器里,用户根本看不出每层能各自换。
   改为:新层不自动绑图(素材池只剩一张时才绑),每行带缩略图 + 「图片」下拉直接换。

2. 「背景图一直保留、关不掉」
   - 「跟随全局」状态下背景下拉是 disabled,用户在箱主页看到背景还在又点不动。
   改为:一律可点,一改就自动把这一页切成独立配置,并把全局当前值复制过来当起点
   (改背景不会顺带把图案和图层弄丢;图层重新发 id,避免两个作用域共用一批 id)。

3. 「上次的数据关闭后还在」
   - 之前只能靠隐藏参数 ?fresh=1 绕过 localStorage 草稿,UI 上没有出口。
   顶栏加「新建」:清空工程 + 删草稿,二次确认。

顺带:
- 「加一层」不再因 inherit 而 disabled(点了自动切独立),改为独立后若全局层数已达 10 会明确提示;
- 跟随全局时不给拖拽框 —— 那时 cfgOf 返回的是 global 对象,一拖就改到全局去了;
- 编辑器里删掉重复的图片下拉,只留尺寸。

回归:?dump=layers 全绿 —— 加层后 asset 为 [null,null]、行内下拉 2 个、
换第 2 层后 [null,blue] 且 CSS 只出现 blue;box 页 inherit 时 addDisabled/bgDisabled 均为 false;
在 box 加层后 inherit=false、本页 3 层而 **全局仍是 2 层**(没被污染);
新建后 assets 0、层 0、草稿删除、scope 回 global。
另外 ?dump=fx 13 项、?dump=validate 空、生成的 CSS 过 TestThemeCSSComplete + TestDecoSlotCount。
2026-10-20 15:56:59 +08:00
tamakyi 927f378d9d docs(memory): 记录图片处理设计,并压缩 MEMORY.md 去掉过期的 6 槽位说法 2026-10-20 14:53:40 +08:00
tamakyi c3d34b53d2 feat(theme-builder): 图片处理——透明度 / 压白 / 去色 / 裁边 / 去白底
素材卡片左上角 fx 按钮进处理面板,非破坏:
- orig 存上传时的原始字节(不重编码),fx 只存参数,每次从 orig 重算。
  「压白 16% → 20%」不会叠加两次压缩;打开看一眼就关一个像素都不动
  (参数没变直接跳过,有损编码反复走会累积模糊)。
- 预览按 420px 宽算(滑杆要跟手),完成时用全分辨率重算;加了序号丢弃过期结果,
  否则快速拖动时旧渲染会覆盖新的。

功能:
- 不透明度 0~100%(CSS 背景图没法单独设 opacity,只能烘焙)
- 压白 v×(1-k)+255×k,与主题里场景的雾度同一公式
- 去色、裁掉透明边
- 去白底:四边漫水 + 封闭白孔检测 + 可选 1px 收边

去白底两个坑(都写进注释了):
- 手内侧 / 两腿之间是闭合白区,四边漫水进不去,得按连通域单独掏(面积≥24)
- 容差默认 16 而不是 22:背景 253、白袜子 237 只差 16,
  容差 30 会把袜子吃穿成一个透明洞

验证(都是跑出来的,不是看缩略图):
- ?dump=fx 13 项算法断言全过:封闭孔开/关、容差 16 保袜子/30 吃穿、
  收边后轮廓 1px、alpha 50%→128、压白 20% 时 237→241、去色后 R=G=B、裁边 80×40
- ?dump=fxpipe 链路:弹窗开关、预览 300×960、参数重开后仍在、
  改参后 dataURL 变、无改动时 dataURL 一个字节不变
- ?dump=fxkey 端到端:白底立绘去白底后透明占比 46.3%,
  与手写 floodfill 抠的 46.6% 差 0.3%
- 生成 CSS 仍通过 TestThemeCSSComplete + TestDecoSlotCount

顺带:工程文件升到 v3(存 orig + fx,未处理时不重复存 dataURL),
读 v2 旧工程时把 dataURL 当 orig 用。新增 ?loadurl= 把项目里的图直接当素材载入。
2026-10-20 14:52:36 +08:00
tamakyi bf1c994c8f docs(memory): 生成器 P0 四项的实现要点与踩到的坑 2026-10-20 13:33:17 +08:00
tamakyi 8ffa7b0ba7 feat(theme-builder): 补 P0 四项——配置留得住、图层可排序、窄屏段、导出前校验
之前只能算「试一次」,关掉标签页全部配置丢失,导出的主题在手机上也是废的。

1) 配置持久化
- 每次改动 700ms 后写 localStorage,打开自动恢复;?fresh=1 从空白开始
- 「保存工程 / 打开工程」落成 theme-<slug>.json(图片 base64 内嵌,单文件可带走)
- zip 里也带一份 json,下次改不用从零搭
- 配额超了只提示一次,并指向 json

2) 图层上移/下移
顺序就是层级(background-image 第一层在最上),配反了以前只能删了重加。
列表标注「最上层 / 第 N 层」。

3) 窄屏段
新增窄屏配置:竖版背景图、隐藏装饰层、断点(默认 900px),导出生成 @media 块。
只写浅色规则——深色块特异性 0,2,1 > 0,1,1,本来就压住它。
实测 390 视口下首层装饰从 url(char.webp) 变成 none,1512 下不变。

4) 导出前校验
slug、图片层是否选了图、是否引用已删素材、层数是否超 10、
生成 CSS 的每处装饰是否真的 10 个值(和 Go 的 TestDecoSlotCount 同规则)。
有问题直接列出来,不用导出后再等 go test。

顺带:面板按使用频率重排(图层→背景→窄屏→图片池→配色);
拖拽框改成虚线+左上角标签,不再用半透明填充罩住人物;示例重复载入不再叠加素材。

验证:示例 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount(27 处声明);
草稿往返(带 demo 保存 → 不带 demo 打开仍 validate 为空)实测通过。
2026-10-20 13:32:26 +08:00
tamakyi 7b532574bb chore: outputs 不再纳入版本控制
.gitignore 里原本写的是 `./outputs` —— gitignore 不认 `./` 前缀,整条规则失效,
于是 247 个产物(预览快照、截图、历史 review 报告)一直被跟踪,
每次跑 TestGenPreview 都会脏一遍工作区。

改成 `/outputs/` 并 git rm -r --cached outputs:索引里删除,磁盘文件全部保留。
克隆者不再拿到这些一次性产物;需要预览快照时跑
TEST_PREVIEW=1 go test ./templates -run TestGenPreview 重新生成。
2026-10-20 12:17:13 +08:00
tamakyi ef18d91920 docs(memory): 页面作用域机制、10 槽位约束与生成器重做的坑 2026-10-20 10:54:34 +08:00
tamakyi ddee221de2 feat(theme): 主题支持按页面分别配装饰,生成器改用真实页面画布
原来的生成器只能配「背景 + 人物 + 道具」三件套,预览还是自己画的仿制页面 ——
跟真实页面不像,也没法给不同页面配不同内容。

机制层:
- internal/context.pageScope() 按路径归类,输出 <html data-page-scope>:
  box / question / admin / profile / vote(首页与 /explore 不加载主题,不列)
- /user/questions 单独归为 admin:题目列表信息密,和设置表单的装饰诉求不同
- base.css 装饰槽位 6 -> 10,背景 8 层 -> 12 层;主题按作用域覆盖时同样写满 10 个
- 新增 TestDecoSlotCount:逐文件逐声明检查 10 个值。少写一个不报错,
  只会让图层与 size/position 错位(整页重复同一张图 / 人物跑到左上角),注释守不住

生成器:
- 画板改成 iframe 加载 outputs/preview-*.html(真实模板渲染),配 serve.py 起本地服务
  解决 file:// 下父页面读不到 iframe DOM 的问题
- 八个页面作用域(去掉不生效的首页/探索),每个可独立配背景与最多 10 层装饰
- 图层类型:图片 / 径向光斑 / 线性光;9 宫格锚点 + 像素偏移,可在画板上直接拖
- 位置统一写 calc(0%|50%|100% ± Npx):百分比是相对 (容器-图片),
  不用 left/bottom 关键字混搭,避免三值语法非法导致整条声明作废
- 配色只填 6 个基础色,其余 4 个必需令牌按方向派生(surface-2 往 text 靠,
  浅色变深、深色变亮,方向自动正确)

配套:
- zz_preview_test.go 增加单题页 / 题目列表 / 投票页预览,作为生成器画板
- docs/theme-page-scope.md 记录作用域取值、选择器特异性与 10 槽位约束
- StaticVersion 20261020e(base.css 变了)

验证:生成器导出的 CSS 通过 TestThemeCSSComplete + TestDecoSlotCount;
真实箱主页计算样式为 12 层对齐;临时主题验证「question 覆盖为 none」生效、
未覆盖时回退全局。
2026-10-20 10:53:44 +08:00
tamakyi feb5b1f78d feat(dev): 主题生成器——选图/拖位置/导出 zip
- dev/theme-builder/index.html:单文件、不联网、图片本地转 WebP
- 预览与线上同机制(pattern + 6 槽 deco + page-image),拖拽反解 calc(50% - Npx)
- 导出 CSS + 图片 + 安装说明,CSS 通过 TestThemeCSSComplete
- 自检:?selftest=1 吐 CSS/zip 字节,?demo=1 载入示例
2026-10-20 10:24:35 +08:00
tamakyi b150e4b49f docs(memory): 素材规格书要点 2026-10-20 09:54:52 +08:00
tamakyi 84c2cab278 docs(theme): 补素材规格书(尺寸/比例/雾度/换图清单) 2026-10-20 09:54:33 +08:00
tamakyi 0eff8dffdb docs(memory): 耳朵/白孔/场景站位与 background-position 百分比陷阱 2026-10-20 09:29:29 +08:00