Compare commits

...
8 Commits
Author SHA1 Message Date
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
33 changed files with 3698 additions and 60 deletions
+4
View File
@@ -37,3 +37,7 @@ go.work.sum
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
/data/
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
__pycache__/
*.pyc
+42
View File
@@ -573,3 +573,45 @@ base.css 还原成 CRLF,插入行要用同一行尾,否则变 MIXED。
细节与取舍写进新文档 `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。
+180
View File
@@ -0,0 +1,180 @@
# 2026-10-22
## new-sakura 主题:纯 CSS 樱花
用户要求「用编辑器生成 new-sakura,CSS 做樱花、每页位置不同、上传到已配好的 S3」。
- **结论:生成器做不了这个** —— 它的装饰层只支持图片素材(导出打 zip / 传对象存储),
没有内联 SVG 图层。所以改成手写主题 CSS(结构完全遵循生成器输出:令牌 + 10 槽 deco +
scope 覆盖),生成器只用来做视觉验证。
- 实现:`dev/theme-tools/gen_new_sakura.py` 生成 `static/css/themes/new-sakura.css`。
花/瓣/枝/底纹都是内联 SVG 的 data URI(`quote(svq, safe="")` 全编码,避免 URI 里的
逗号被 `TestDecoSlotCount` 当成分层符)。CSS 50KB(gzip ≈12KB)。
- 六个 scope 各一套 `--tb-deco` 位置;窄屏(≤640px)装饰全关、底纹缩到 200px;
深色换亮粉 + 关底纹(浅粉压深底=噪点)。
- 调参踩到的:①花枝 `auto 390px` 放右上角时可见部分只剩一团花 → 缩到 300px 并内移;
②枝上 5 朵 scale≈1 的花是一串糖葫芦 → 改成 8 朵 0.4~0.62;
③底纹 260px 双花双瓣读成「包装纸」→ 改成 300px 一朵花 + 两片瓣、各自错位。
- 对比度:primary 候选 `#c14a72` 只有 4.39:1,压深到 `#b8436a`(4.87);muted `#77626a`(5.27);
深色 `#ff93b4`(8.17)/`#ab9099`(5.82)。
- i18n 四份词典加 `theme.name.new-sakura`(插在 shiroko 之后,gofmt_keep_crlf 后每份只 +1 行);
`conf/app.sample.ini` 的 available 登记。
- ⚠️ 本机 Chrome/Edge 的 localStorage 里**没有**生成器的 S3 配置记录,项目也没有
`conf/app.ini` → 「已预先配置好的 S3」在本机取不到,上传这一步需要用户给凭据。
纯 CSS 主题本身零素材,严格来说 S3 无事可做(已向用户确认)。
**R2 自定义域可用 + 素材包一键上传(接上一条)**
- `r2.337845818.best` 实测公开可读(7 个对象全部 200、Content-Type 正确);桶的 S3 endpoint 仍 403。
- 樱花位图从 `gen_new_sakura.py` 的同一份 SVG 导出(`export_assets.py` 直接 import 它,不另画),
`--upload` 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt(域名/体积写实数)。
- `serve.upload_files` 支持逐文件 `cacheControl`:原来硬编码 immutable,而
`INSTALL.txt` / 镜像 CSS 的键不带版本 → 改了看不到。现在位图 1 天、CSS 5 分钟、README 60 秒。
- ⚠️ **CF 边缘缓存不会因为重传而失效**:第一版以 immutable 传过,`cf-cache-status: HIT`、Age>800,
重传后拿到的仍是旧副本(带 `?cb=` 才是新内容)。清缓存只能去控制台按 URL 清。
- ⚠️ **gzip 纠错**:服务端 `internal/route/gzip.go` 会压 CSS,内联版 51KB 实际传输 **3.9KB**,
位图版 2.7KB + 5 张图 48.9KB ≈ 51.6KB。所以"换位图省 CSS 体积"是错的 ——
位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。
- `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。
## 线上 521 排障(box.shiroko.one 打不开)
- 现象:`https://box.shiroko.one` 全路径 **521**,同机 IP+端口正常。
- 实测链路:`box.shiroko.one` → CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 接入/优选,
上游 `source.cfnode.eu.org`),**不是** `<UUID>.cfargotunnel.com`。DNS/TLS 均正常(tls=0),
响应体 `error code: 521`,5 次请求全 LAX,0.6s 快速失败(RST 特征,非超时)。
- 对照实验有价值:`box.tama.guru` 走**同一个** CNAME 目标却正常 301 → 中转 zone 边缘健康;
它不回源,而 box.shiroko.one 要回源才 521 ⇒ 断点在「CF → 源站」这一段。
- `shiroko.one` / `www` / `tama.guru` apex 都 CNAME `navi-ew2.pages.dev`(CF Pages 导航站),与本站无关,
别拿它们的 200 当证据。
- 候选根因(待服务器侧确认):① cloudflared 断开(严格说 tunnel 无 connector 多为 502/1033);
② `scripts/edgeone-origin-whitelist.sh` 的 `REJECT tcp-reset` 拦掉 CF 直连回源——脚本注释里
「521」本就是白名单漏放的既有症状;③ 回源 IP 过期(服务器 IP 变动 / 第三方面板未更新)。
- 注意矛盾点:用户本地能连 IP+端口 ⇒ 白名单大概率没生效或不覆盖该端口 ⇒ 更偏向 ③/tunnel 侧。
**根因已确认(服务器实测)**
- `systemctl is-active cloudflared` = **inactive**,`pgrep` 无进程 → Tunnel 断。
journald 最后一条 2026-10-21T21:42:22Z(CST 10-22 05:42),此后无日志 ⇒ **停了约 7 小时**。
- 死前日志模式:`accept stream listener encountered a failure while serving` +
`failed to accept QUIC stream: timeout: no recent network activity` + `datagram handler
error="context canceled"` + `Retrying connection in up to 1s`,connIndex 2/3 反复 →
UDP 7844 QUIC 长时间不稳(美西 23.94.211.138)。**inactive 而非 failed ⇒ 是被正常 stop 的,
不是崩溃**,与用户「昨晚升级 nginx 到 1.30.x」的时间点吻合(升级流程连带停服)。
- 应用侧全好:`*:5877` 监听(tamabox pid),loopback 200。
- `iptables -S INPUT` 只有 `-i eth0 --dport 8118/5877 -j DROP`,**EO_IN / ipset EO_ORIGIN 都不存在**
⇒ EdgeOne 白名单脚本当前未生效。且 DROP 限定 `-i eth0`,网卡名若不叫 eth0 则规则空转。
- 关键推论:CF 一定走 tunnel 而非直连 5877(否则 DROP 空转应 200 而非 521)⇒ 521 完全由 tunnel 断解释。
**续:start 之后仍 521**
- 用户确认:**5877 只允许 Tailscale(100.84.113.84:5877)访问**,公网靠 iptables DROP 封掉。
⇒ 排除「CF 直连公网 5877」这条路径,CF 只能经 tunnel 回源。
- `systemctl start cloudflared` → `active`,`sleep 5` 后 curl 域名仍 **521**。
- 待分辨:① connector 尚未完成边缘注册(sleep 5 偏短);② 本机 tunnel 与 CF/SaaS 侧
`box.shiroko.one` 绑定的 origin(应为 `<uuid>.cfargotunnel.com`)不是同一个;
③ 该 hostname 的 Public Hostname / ingress 未指向 `http://127.0.0.1:5877`。
- 决定性隔离实验:临时 quick tunnel(`cloudflared tunnel --url http://127.0.0.1:5877`)
拿 trycloudflare 域名直测 —— 200 则应用+出站正常,问题在 CF 侧绑定。
**journald 取证(关键)**
- `protocol=quic`,`location=la01`。从 10-21 13:53:30 起就是死循环:
`INF Registered tunnel connection` → 约 3s 后 `ERR failed to accept QUIC stream:
timeout: no recent network activity` + `datagram handler error="context canceled"` +
`WRN failed to serve tunnel connection error="accept stream listener encountered a failure
while serving"` → `INF Retrying connection in up to 1s` → 再注册。connIndex 0/1/2 全部如此,
每 ~3 分钟一轮,一直持续到 10-21 21:42:40。
- ⇒ **UDP 7844 的 QUIC 在这台机器上基本不可用**(RackNerd 美西 → CF la01),这是历史顽疾,
不是本次新故障。根治手段 = `TUNNEL_TRANSPORT_PROTOCOL=http2`(走 TCP 443)。
- 🔴 但 12:56 `systemctl start` 之后 journal 里**一条新日志都没有**(最后仍是 10-21 21:42:40,
PID 626)⇒ 新进程疑似启动即退出/token 无效,`is-active=active` 可能是重启窗口里的瞬时值。
需 `systemctl status` 看 Restart 计数与 ExecMainStatus。
- 旁证:EdgeOne 那条备用通道同样不通(18080 无监听、EO_IN/ipset 均不存在)。
**结论定案**
- 重大纠正:cloudflared **从未死过**。`systemctl status` 显示 `active (running) since 2026-10-20 05:32:44`,
Main PID 626 存活 2 天+。前面「21:42 后无日志」不是进程退出,而是 QUIC 连接最后注册成功后没再报错
(只有 24h 一次的版本提醒)。所以「tunnel 断导致 521」这个假设是错的。
- 改 `TUNNEL_TRANSPORT_PROTOCOL=http2` 后:4 个 connector 全部 `Registered tunnel connection
... protocol=http2`,`SUMMARY: Environment is healthy. Cloudflared will use 'http2' as primary
protocol`,零错误 ⇒ **tunnel 侧完全健康**。QUIC 抖动问题也顺手解决了(该 drop-in 值得长期保留)。
- 但站点仍 521 ⇒ **问题 100% 在 CF 侧 origin/路由,与本机 tunnel 无关**。
- 公网实测 `23.94.211.138`:5877 / 18080 / 80 / 443 **全部 timeout**(不止 5877)。
⇒ 若第三方 SaaS 的 origin 填的是「公网 IP:5877」,CF 同样连不上 → 521。
这解释了「突然」:用户近期把 5877 收敛为仅 Tailscale(100.84.113.84)可达。
- 本机 tunnel 身份(从 token 解出,注意 token 已明文暴露需轮换):
tunnel id `ba34e35d-e517-498a-b2cd-7275c22e354e`,account tag `0c99c40b475a65b393abaca3ca91731b`。
- 修复方向:①(推荐)第三方面板把 origin 改成 `ba34e35d-...cfargotunnel.com`,并在
Zero Trust → Tunnels → 该 tunnel 的 Public Hostnames 补 `box.shiroko.one → http://127.0.0.1:5877`;
②(不推荐)放行 CF 回源网段到 5877,等于重新暴露源站。
- ⚠️ 安全 P0:tunnel token 在对话截图里明文出现,等同凭据泄露 → 必须 Refresh token 后重装服务。
**真凶:SaaS origin 指向被封的公网**
- SaaS 自定义主机名 `box.shiroko.one` 的「源服务器」= `box-direct.337845818.best`。
- 实测该 origin 域名 **解析到 Cloudflare 自己的 IP**(104.21.47.52 / 172.67.144.141,
`Server: cloudflare`,CF-RAY)⇒ 它是**橙云(proxied)记录**,不是灰云 A 记录。
`http://` 301(Always Use HTTPS)、`https://` **521** —— 它自己也回源失败。
- 源站 `23.94.211.138`(colocrossing,RackNerd 上游):22 / 80 / 443 / 5877 / 18080
**全部 timeout**。不只应用端口,连 SSH 都不通 ⇒ 该机对公网几乎无入站(仅白名单/Tailscale)。
- 结论链:CF 回源 `box-direct` → 该记录橙云 → 真实源站端口被封 → **521**。
所有共用 `-direct` origin 的站点同时 521 ⇒ 用户说的「cloudflared 构建的网站全部 521」
与 cloudflared 无关,是**公网入站收敛**这一个动作把整批站点打掉的。nginx 升级纯属时间巧合。
- 修复(保持「只允许组网」的安全目标):① Zero Trust → 该 tunnel → Public Hostnames 加
`box.shiroko.one → http://127.0.0.1:5877`;② SaaS 面板把每个自定义主机名的「源服务器」
从 `xxx-direct.337845818.best` 改为 `ba34e35d-e517-498a-b2cd-7275c22e354e.cfargotunnel.com`。
注意不能把 `-direct` 改成「灰云 CNAME 到 cfargotunnel」——tunnel 路由要求橙云,SaaS origin 又
要求非橙云,两者冲突,必须直接在 origin 字段填 cfargotunnel。
**⚠️ 纠正(官方文档)**
- 上一条的后半段判断错了。CF for SaaS 文档明确:custom origin **必须**是该账号下
**proxied(橙云)的 A / AAAA / CNAME** 记录,不能用 IP。
⇒ `box-direct.337845818.best` 是橙云,**格式上完全正确**,不是配置错误。
用 tunnel 的正确形态就是「橙云 CNAME → `<uuid>.cfargotunnel.com`」,所以那条路径依然成立,
只是理由不是「橙云错了」。
- 真正断点仍是:`23.94.211.138` 的 **443 从公网不可达**(实测 timeout)。
但用户贴的 `iptables -S INPUT` 里**只有 8118 / 5877 两条 DROP,没有 443**
⇒ 对 443 的封锁来自 iptables 之外(云厂商安全组 / 上游 / 其他链),或者 nginx 压根没监听 443。
- 我上一轮的探测命令 `grep ':(80|5877|18080)'` **漏了 443**,这是取证缺口,需要补
`ss -tlnp | grep -E ':(443|80|5877)'` + `systemctl is-active nginx`。
- 注意:SaaS 回源默认 Host = 原始自定义主机名(box.shiroko.one),而 tunnel 的 Public Hostname
只能从账号内 zone 选(shiroko.one 不在 CF)⇒ 走 tunnel 时必须用 Origin Rule / 源站主机头覆盖,
否则 tunnel 会 404。
**最终根因(用户自查确认):nginx 停了 11 小时**
- 时间线:`10-22 00:12:43 Started`(升级后)→ `02:31:10 Stopping...` →
`02:31:15 Stopping timed out. Terminating.` → `Failed with result 'timeout'` → `Stopped`
→ **中间 11 小时无任何记录** → `13:34:42` 用户手动 `systemctl start` 才起来。
- ⇒ 全站 521 的直接原因就是 nginx 不在 443 监听;CF 回源 `box-direct`(橙云 A → 23.94.211.138:443)
拿不到东西。cloudflared/tunnel 全程健康且**从未参与这条链路**。
- 重启后实测:`box.shiroko.one` 连续 3 次 200,`box-direct` 200,源站 443 OPEN。
- 教训(我的误判链,值得记住):排障时先确认「443 上跑的是什么、进程在不在」(`ss -tlnp` +
`systemctl is-active nginx`),再往 DNS/CDN/tunnel 方向挖。这次因为 CNAME 指向第三方 SaaS
+ cloudflared 恰好健康,被带了很远。
- 遗留技术债:
① `listen 443 ssl http2` 在 1.25.1+ 已废弃 → 改 `listen 443 ssl;` + `http2 on;`;
② `Failed to parse PID from file /run/nginx.pid: Invalid argument` —— 大概率是上次 SIGKILL
残留的 pid 文件,配合 `TimeoutStopSec` 过短(5s 就 Terminating)导致 stop 卡死;
③ 全站无可用性监控(11 小时空窗没人知道)→ 建议外部 uptime + cron watchdog
`*/5 * * * * systemctl is-active --quiet nginx || systemctl start nginx`。
**6) 窄屏覆盖被作用域块压死(真 bug,已修)**
- 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。
- 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]`
是 (0,2,1),媒体里写的 `html[data-theme-style]` 只有 (0,1,1) → 压不住;深色作用域块 (0,3,1) 更难。
- 修法:媒体查询里按「作用域通配 `[data-page-scope]`(0,2,1)」+「深色基础(0,2,1)」+
「深色+作用域(0,3,1)」各写一条,靠文件更晚取胜。shiroko 的 900px 块只对浅色有效,
但它的深色块本来就是 none,所以不算坏。
- 守门:新增 `internal/theme/narrow_test.go::TestNarrowMediaOverridesScopedDeco`
——外层每处非 none 的 `--tb-deco`,媒体查询里必须有「同属性名、属性数不少于它」的选择器;
变异验证过(删掉深色基础那条立刻 FAIL)。
- 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin
页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。
- 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。
## 站内公告功能(配置驱动)
- 新增 `[announcement]` 段(content/start/end),改完重启生效;判定全在服务端按 time.Now()。
- 改动:conf/announcement.go(解析+Active+单测)、conf.Init MapTo、keynorm sectionKeySets 登记(漏了会误报未知段)、
template.FuncMap 新增 `announcement`(返回安全 HTML,空串 falsy 配 `{{ with }}`)、
internal/template/announcement.go(切段转义→URL 包链接,不能「先转义再正则」否则 &#34; 被吞进 URL)、
header.html 导航下方插 `uk-container-xsmall` 容器包 `.tb-announce`、base.css 样式(StaticVersion → 20261022a)。
- 时间格式:`2006-01-02 15:04` / 带秒 / 纯日期 / RFC3339;裸时间按服务器本地时区(容器是 UTC),启动 INFO 打印解析结果。
- 时间写错只 WARN 关公告不阻断启动(与未知键口径一致);窗口左闭右开。
- 预览验证:TEST_PREVIEW=1 + 临时 test 设 conf.Announcement 渲染 question/list,chrome --headless=old 截图 OK(亮/暗都看过)。
+24
View File
@@ -44,3 +44,27 @@
- 导出面板「素材前缀」`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`(手动点面板)。
+19 -1
View File
@@ -262,6 +262,24 @@ chat_max_messages = 200
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
daily_message_limit_max = 999
[announcement]
; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ──────────────────────
; content :公告正文,留空 = 关闭公告。用 \n 表示换行;http(s) 地址会自动变成链接;
; 内容按纯文本渲染,不支持 HTML 标签(写 <b> 会原样显示)。
; 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)。
; ⚠️ 不带时区偏移的时间按【服务器本地时区】解释(容器默认 UTC),
; 要精确到分钟请写 RFC3339;启动日志会打出解析后的时间与时区,可据此核对。
; 时间格式写错不会导致启动失败:日志 WARN 后关闭公告。
; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one"
; start = "2026-10-24 00:00"
; end = "2026-10-25 04:00"
content = ""
start = ""
end = ""
[theme]
; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。
; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台;
@@ -273,6 +291,6 @@ daily_message_limit_max = 999
; 改选择器与令牌值,再把 <slug> 加进 available —— 不需要改模板。
; 需要图片素材的,放 static/images/themes/<slug>/(static/fs.go 已嵌 images/themes 目录)。
default = default
available = default,sakura,shiroko
available = default,sakura,shiroko,new-sakura
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko
+51
View File
@@ -0,0 +1,51 @@
// 临时校验程序:用 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"))
}
}
+85 -5
View File
@@ -16,13 +16,26 @@
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
# 2) 起服务(只读,不改项目任何文件)
python dev/theme-builder/serve.py # 默认 8777
python dev/theme-builder/serve.py # 默认监听 0.0.0.0:8777,同局域网可访问
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共网络请加这个)
# 3) 打开
http://127.0.0.1:8777/dev/theme-builder/
# 3) 打开终端里打印的地址
# 本机 http://127.0.0.1:8777/dev/theme-builder/
# 局域网 http://192.168.x.x:8777/dev/theme-builder/
```
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
默认绑 `0.0.0.0`,是为了能躺在床上用手机 / 平板调主题:启动时会把本机各网卡的地址都列出来,
照着点就行(手机与电脑要在同一网段)。第一次启动 Windows 会弹防火墙询问,要允许私有网络。
⚠️ 两个必须知道的点:
- **上传接口没有账号体系**:能打开这个端口的人,都能用你填的密钥往对象存储写 / 删文件。
公共网络(咖啡馆、宿舍、公司网)一律 `--host 127.0.0.1`。写接口还会校验 `Origin` 与
`Host` 同源,挡掉「别的网页借你浏览器发请求」,但挡不住同网段主动连过来的人。
- **换地址 = 换 origin**:`127.0.0.1` 与 `192.168.x.x` 在浏览器眼里是两个站点,
草稿(localStorage)与 S3 密钥(sessionStorage)不共享 —— 第一次用新地址要重填一次密钥。
不想用脚本也行:在项目根跑 `python -m http.server 8777`,但那就只有静态预览、没有上传能力。
## 它和线上是同一条链路
@@ -190,13 +203,80 @@ WebP 体积 ≈ 像素数 × 质量。**真正的大头是像素**:CSS 里只
| --- | --- | --- |
| 质量 | 0.88 | 0.85~0.9 肉眼几乎看不出差别,低于 0.8 才明显糊 |
| 长边上限 | 1600 | 只缩不放。整页背景建议 1920/不限制;装饰人物 1200 够用 |
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片自己传对象存储(R2/OSS)。**前缀里不要带 slug** |
| 素材前缀 | 空 | 空 = 图片放 `static/images/themes/<slug>/` 随二进制发布;填 `https://cdn.example.com/themes` = CSS 写绝对地址,图片放自己的对象存储(R2 / MinIO / B2 / OSS / COS,任何 S3 兼容都行)。**前缀里不要带 slug** |
导出的 CSS 只有 `urlOf()` 一处决定图片地址,图层、背景、窄屏块全走它,不会出现
「背景走 CDN、装饰还指本地」的半吊子状态。素材前缀只影响导出,预览永远是内联 dataURL。
详细取舍(生成器前缀 / 应用内 301 / 边缘改写)见 `docs/theme-assets-cdn.md`。
## 素材托管(本地 / 任意 S3 兼容)
「图片放哪」由你自己定,面板里二选一:
- **本地**(默认):打进 `static/images/themes/<slug>/`,随二进制发布。部署即生效、不依赖外部服务,
代价是换一张图要重新编译。
- **S3 兼容存储**:传到你自己的对象存储,导出的 CSS 写绝对地址。这里刻意不锁厂商 ——
R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是同一套 SigV4,
差别只在 `Endpoint`、`Region`、是否路径风格,全都做成了字段。
填好 Endpoint / Region / 桶名 / 路径前缀 / 密钥后:
1. **测试连接** —— 依次验四件事:`HEAD` 桶(连通与凭据)、`PUT` 一个探针对象(写权限)、
`GET` 读回比对(内容一致)、匿名 `GET`(传上去的图能不能被浏览器直接读到),最后删掉探针。
失败时会把 S3 的 `<Code>` 翻成人话(`SignatureDoesNotMatch` → 密钥/Region/时钟三选一)。
2. **上传素材** —— 把本次主题真正引用到的图(与导出同一个集合,`usedAssetIds()`)逐张 PUT,
键为 `<路径前缀>/<slug>/<id>.webp`,带 `Cache-Control: public, max-age=31536000, immutable`。
成功后自动把上面的「素材前缀」填成推导出的公开地址。
⚠️ 上传走的是本机 `serve.py` 的 `/api/s3/test`、`/api/s3/upload`,浏览器不直连对象存储 ——
所以**不用给桶配 CORS**,密钥也不会出现在页面发往第三方的请求里。它只存在当前标签页的
`sessionStorage` 与内存:不进 `localStorage` 草稿、不进工程 json、不进导出 zip。
用 `python -m http.server` 起的只有静态服务,探测不到接口时按钮会禁用并说明原因。
公开地址留空时按 endpoint 推导(path 风格 = `<endpoint>/<桶>/<前缀>`)。R2 与私有桶
需要自定义域或 CDN 域名,那种情况自己填「公开前缀」。测试里的「匿名可读」会给一条提醒,
但不会拦上传 —— 很多人就是靠自定义域分发的。
### 检测与清理
传上去只是第一步,「传上去之后对不对」是另一回事:同一个 slug 传过三次、换过 slug
旧图还留在桶里、重新导出后远端还是旧版 —— 这些在页面上都看不出来。所以还有一块
「远端素材」,点「**检测**」列出 `<路径前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
| 状态 | 判断依据 | 能做什么 |
| --- | --- | --- |
| 一致 | 远端大小 == 本地编码后大小 | — |
| 旧图 | 同名但大小不同(多半重新导出过) | 行内 ↻ 用本地覆盖 |
| 多余 | 远端有、当前主题已不再引用(换过 slug / 删过素材) | 行内 ✕ 或「删除多余」批量删 |
| 缺失 | 本地有、远端没有 | 「补齐差异」一次传完 |
- 「**补齐差异**」只传缺失 + 旧图,一致的不重传(省流量也省时间);
- 「**删除多余**」列清单后二次确认,走 `DeleteObject`(幂等,不存在的键也算成功);
- 改过 slug 会提示「重新检测」—— 拿旧主题的清单去比新主题,会满屏「多余」;
- 只动远端,本地素材与工程文件不受影响。
厂商怎么填(2026 年的常见值,各家控制台都有「S3 API」页面):
| 厂商 | Endpoint | Region | 路径风格 |
| --- | --- | --- | --- |
| Cloudflare R2 | `https://<账号ID>.r2.cloudflarestorage.com` | `auto` | 勾选 |
| MinIO(自建) | `http://<host>:9000` | `us-east-1`(看服务端配置) | 勾选 |
| Backblaze B2 | `https://s3.<区域>.backblazeb2.com` | 区域名,如 `us-west-004` | 勾选 |
| 阿里云 OSS | `https://s3.oss-<区域>.aliyuncs.com` | 区域,如 `cn-hangzhou` | 看控制台 |
| 腾讯云 COS | `https://cos.<区域>.myqcloud.com` | 区域,如 `ap-hangzhou` | 看控制台 |
AWS S3 或已绑定的桶级自定义域可以取消「路径风格」(走 `<桶>.<endpoint>`)。
两条自检(都在本目录,不依赖 UI):
```bash
python dev/theme-builder/check_sigv4.py # 与 aws-sdk-go-v2 的 signer 逐字节对拍
python dev/theme-builder/selftest_upload.py # 起假 S3,跑「测试连接 → 上传 → 读回」
python dev/theme-builder/mock_s3.py --port 9900 # 想手动点面板时,先起这个假 S3
# 生成器在别的设备上打开时,假 S3 也要 --host 0.0.0.0,面板里的 Endpoint 填本机 IP
```
面板底部实时显示 `3 张,共 19KB;最大一张 bg.webp 7KB(1920×1080)`。
改完立刻按「原图 + fx 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
来回试不会叠加压缩。
+103
View File
@@ -0,0 +1,103 @@
"""校验 serve.py 手写的 SigV4 与 aws-sdk-go-v2 的 signer 逐字节一致。
为什么需要这个:SigV4 错一个字节,服务端只会回一句 SignatureDoesNotMatch,
既不说哪里错、也不会在本地暴露出来 —— 上传功能就成了「点了没反应」。
这里用官方 SDK 当参照实现做对拍,改完签名相关代码跑一遍即可。
用法(项目根):
python dev/theme-builder/check_sigv4.py
⚠️ 用例只放 ASCII 键:aws-sdk-go-v2 裸 SignHTTP 默认会二次转义 URI,
而 S3 客户端内部把 DisableURIPathEscaping 设成了 true(单次编码)。
即同一个路径两种行为,把非 ASCII 键放进来只会比出「SDK 的两个模式之差」。
我们跟随 S3 客户端的单次编码(AWS 文档的 RFC3986 规则),并由生成器把
slug 限制在 [a-z0-9-_],从根上避开这个争议区。
"""
import os
import subprocess
import sys
import urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
import serve # noqa: E402
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
TS = "20130524T000000Z"
EMPTY = serve.EMPTY_SHA256
BODY_HASH = serve._sha256(b"TamaBox theme builder probe\n")
# (说明, method, endpoint, extra cfg, key, query)
CASES = [
("path-style 桶级 HEAD", "HEAD", "http://127.0.0.1:9000", dict(bucket="tb-assets"), "", None),
("path-style 带前缀对象", "PUT", "http://127.0.0.1:9000",
dict(bucket="tb-assets", prefix="themes"), "shiroko/bg1.webp", None),
("virtual-host 对象", "GET", "https://s3.us-west-004.backblazeb2.com",
dict(bucket="my-bucket", region="us-west-004", pathStyle=False), "themes/shiroko/bg1.webp", None),
("R2 region=auto", "PUT", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", prefix="themes"), "x/a.webp", None),
("R2 虚拟主机", "GET", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", pathStyle=False), "themes/s/t.webp", None),
("带端口 https", "DELETE", "https://minio.lan:9443",
dict(bucket="static", region="us-east-1"), "themes/s/t.webp", None),
# 带 query:ListObjectsV2 的 canonical query 要按键排序、逐项编码(/ 编成 %2F)
("ListObjectsV2 三参数", "GET", "http://127.0.0.1:9000", dict(bucket="tb-assets"),
"", {"list-type": "2", "max-keys": "1000", "prefix": "themes/shiroko/"}),
("ListObjectsV2 分页(乱序传入)", "GET", "https://acc.r2.cloudflarestorage.com",
dict(bucket="cdn", region="auto", pathStyle=False), "",
{"prefix": "themes/", "continuation-token": "1/abc+d", "max-keys": "2", "list-type": "2"}),
]
def build(endpoint, extra, key):
cfg = serve.S3Config(dict(extra, endpoint=endpoint, accessKey=KEY_ID, secretKey=SECRET))
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
return cfg, path
def go_sign(payloads):
env = dict(os.environ, AWS_KEY_ID=KEY_ID, AWS_SECRET=SECRET,
GOPROXY="https://goproxy.cn,direct", GOSUMDB="off", GOFLAGS="-mod=mod")
p = subprocess.run(["go", "run", "./dev/sigv4ref"], input="\n".join(payloads),
capture_output=True, text=True, env=env, cwd=ROOT)
if p.returncode != 0:
sys.stderr.write(p.stderr[-2000:])
raise SystemExit("go run 失败(检查 GOPROXY / Go 是否可用)")
return [x for x in p.stdout.strip().splitlines() if x.strip()]
def main():
payloads = []
for _, method, endpoint, extra, key, query in CASES:
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
url = cfg.url(path) + (("?" + serve.canonical_query(query)) if query else "")
payloads.append("\t".join([method, url, cfg.region, body, TS]))
go = go_sign(payloads)
if len(go) != len(CASES):
raise SystemExit("返回条数不对:go=%d cases=%d" % (len(go), len(CASES)))
bad = 0
for (desc, method, endpoint, extra, key, query), g in zip(CASES, go):
cfg, path = build(endpoint, extra, key)
body = BODY_HASH if method == "PUT" else EMPTY
py = serve.sign_v4(cfg, method, path, body, TS, cfg.host, serve.canonical_query(query))
if g.rsplit("Signature=", 1)[-1] == py.rsplit("Signature=", 1)[-1]:
print("OK %s" % desc)
else:
bad += 1
print("DIFF %s\n go %s\n py %s\n path %s" % (desc, g, py, path))
print("\n%d/%d 一致" % (len(CASES) - bad, len(CASES)))
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
+654 -13
View File
@@ -103,6 +103,40 @@
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)}
/* 素材托管:两选一(本地 / S3 兼容),选 S3 才展开字段。
字段一律「短标签 + 输入框」,标签宽度按最长那个(公开访问前缀 = 84px)对齐,
否则同一列里会被 flex 压成参差的锯齿。 */
.hostpick{display:flex;gap:6px;margin-bottom:9px}
.hostpick button{flex:1;font-size:11px;padding:4px 6px}
.s3stat{font-size:11px;padding:6px 8px;border-radius:6px;border:1px solid var(--ui-line);
margin-top:8px;line-height:1.55;white-space:pre-wrap;word-break:break-all}
.s3stat.ok{color:var(--ui-ok);border-color:#2f5744}
.s3stat.bad{color:var(--ui-bad);border-color:#5a3036}
.s3stat.warn{color:var(--ui-warn);border-color:#5a4a2a}
.s3acts{display:flex;gap:6px;margin-top:8px}
.s3acts button{flex:1}
.s3acts button:disabled{opacity:.45;cursor:not-allowed}
/* 远端清单:key 名弹性、大小右对齐、状态徽标定宽,操作按钮只留两个小方块。
240px 面板里四列已经是极限,再加列就会把文件名挤成省略号。 */
.remote{border-top:1px solid var(--ui-line);margin-top:10px;padding-top:9px}
.remote .rhead{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.remote .rhead span{font-size:11px;color:var(--ui-text2);flex:1}
.remote .rlist{display:flex;flex-direction:column}
.rrow{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto auto;gap:5px;
align-items:center;font-size:11px;padding:2px 0}
.rrow .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rrow .sz{color:var(--ui-text2);font-variant-numeric:tabular-nums;font-size:10px}
.rrow .bg{font-size:10px;padding:0 5px;border-radius:999px;border:1px solid var(--ui-line);
color:var(--ui-text2);white-space:nowrap}
.rrow .bg.same{color:var(--ui-ok);border-color:#2f5744}
.rrow .bg.changed{color:var(--ui-warn);border-color:#5a4a2a}
.rrow .bg.extra{color:var(--ui-bad);border-color:#5a3036}
.rrow button{padding:0;width:20px;height:20px;line-height:1;font-size:11px;border-radius:4px}
.remote .rsum{font-size:11px;color:var(--ui-text2);margin-top:6px;line-height:1.5}
.remote .racts{display:flex;gap:6px;margin-top:7px}
.remote .racts button{flex:1;font-size:11px;padding:4px 6px}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
@@ -235,6 +269,11 @@
<div id="export-editor"></div>
</section>
<section>
<h2>素材托管 <span class="r"><span class="conn" id="host-state">本地</span></span></h2>
<div id="host-editor"></div>
</section>
<section>
<h2>配色</h2>
<div class="pal" id="palette"></div>
@@ -255,7 +294,7 @@
<b>需要本地服务才能预览真实页面</b>
<span>在项目根执行:</span>
<code>python dev/theme-builder/serve.py</code>
<span>然后打开 http://127.0.0.1:8777/dev/theme-builder/</span>
<span>然后打开 serve.py 启动时打印的地址(本机 127.0.0.1,局域网用本机 IP)</span>
</div>
</div>
<div class="out" id="out"></div>
@@ -342,6 +381,18 @@ const D = {
填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。
前缀里不要带 slug,slug 由导出时自动拼在后面。 */
assetBase: '',
/* 素材托管:local = 随二进制发布;s3 = 任意 S3 兼容存储(R2 / MinIO / B2 / OSS / COS ...)。
密钥不在这里 —— 它只活在浏览器内存与 sessionStorage 里,绝不进工程文件。
publicBase 留空时按 endpoint 推导,所以这里默认空串而不是假域名。 */
host: {
mode: 'local',
endpoint: '',
region: 'auto',
bucket: '',
prefix: 'themes',
pathStyle: true,
publicBase: '',
},
dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
@@ -361,6 +412,12 @@ const S = {
narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export),
assetBase: '',
host: Object.assign({}, D.host),
/* 运行时状态:测试/上传结果,不参与持久化 */
s3: { kind: '', text: '', busy: false, canUpload: null },
/* 远端清单(检测结果)。at=0 表示这次会话还没检测过;slug 变了要重新检测,
否则会拿旧主题的 key 跟新主题的本地素材比,满屏「多余」。 */
remote: { objects: [], at: 0, slug: '', prefix: '' },
dark: Object.assign({}, D.dark),
};
@@ -376,6 +433,9 @@ function blankScope(k){
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
/* ---------------------------- 小工具 ---------------------------- */
/* 当前访问地址。局域网里用 http://192.168.x.x:8777 打开时,提示文案不该再写死 127.0.0.1 ——
否则用户明明已经打开了,却被告诉「请用 127.0.0.1 打开」。 */
const HERE = location.protocol + '//' + location.host + '/dev/theme-builder/';
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
@@ -822,7 +882,7 @@ function onFrameLoad(){
$('empty').style.display = 'flex';
$('conn').className = 'conn bad';
$('conn').textContent = '需要本地服务';
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 http://127.0.0.1:8777/dev/theme-builder/ 打开。', 'e');
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 ' + HERE + ' 打开。', 'e');
return;
}
$('empty').style.display = 'none';
@@ -1593,9 +1653,12 @@ function renderExportEditor(){
if (S.assetBase){
const cdn = document.createElement('p');
cdn.className = 'hint';
// 已经在「素材托管」里配了 S3 的人,不需要再被教一遍「把 zip 里的图传上去」
const how = S.host.mode === 's3'
? '用下面的「素材托管」直接上传即可(配好 Endpoint / 桶 / 密钥 → 测试连接 → 上传素材);'
: `记得把 zip 里的 static/images/themes/${S.slug}/ 整个传到这个路径下;仓库里就不需要这些图了。`;
cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— `
+ '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;'
+ '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。';
+ how + '换 CDN 域名要重新导出 CSS。';
el.appendChild(cdn);
}
@@ -1607,6 +1670,563 @@ function renderExportEditor(){
el.appendChild(tip);
}
/* ---------------------- 素材托管(本地 / 任意 S3 兼容) ---------------------- */
/* 素材放哪有两选:
local —— 打进 static/images/themes/<slug>/,随二进制发布,部署即生效;
s3 —— 传到自己选的对象存储,导出的 CSS 写绝对 URL。
第二项刻意不锁厂商:R2 / MinIO / Backblaze B2 / 阿里 OSS(S3) / 腾讯 COS(S3) 用的是
同一套 SigV4,差别只在 endpoint、region、是否路径风格 —— 那就全做成字段,让人自己填。
上传走本机 serve.py 的代理(/api/s3/upload):浏览器不直连存储,因此不用配 CORS,
密钥也不会跟着页面请求发到第三方去。 */
const SEC_SESSION_KEY = 'tamabox-theme-builder-s3-secrets';
const s3Secrets = { accessKey: '', secretKey: '' };
try {
// sessionStorage 而不是 localStorage:关掉标签页就该忘掉密钥。
const raw = sessionStorage.getItem(SEC_SESSION_KEY);
if (raw) Object.assign(s3Secrets, JSON.parse(raw) || {});
} catch (e) { /* 隐私模式 / file:// 下取不到,忽略 */ }
function saveSecrets(){
try { sessionStorage.setItem(SEC_SESSION_KEY, JSON.stringify(s3Secrets)); } catch (e) {}
}
/** 按 endpoint 推导公开访问前缀。桶若开了公开读(或 MinIO 直连)就能直接命中;
R2 / 私有桶需要自定义域,那种情况请自己填「公开访问前缀」。 */
function derivePublicBase(h){
const raw = String(h.endpoint || '').trim();
if (!raw || !h.bucket) return '';
let u;
try { u = new URL(/^https?:\/\//i.test(raw) ? raw : 'https://' + raw); }
catch (e) { return ''; }
const prefix = String(h.prefix || '').replace(/^\/+|\/+$/g, '');
const base = h.pathStyle
? `${u.origin}/${h.bucket}`
: `${u.protocol}//${h.bucket}.${u.host}`;
return prefix ? `${base}/${prefix}` : base;
}
/** 最终写进 CSS 的前缀(不带 slug)。手填优先,空则推导。 */
function publicBaseOf(h){
const own = String(h.publicBase || '').trim().replace(/\/+$/, '');
return own || derivePublicBase(h).replace(/\/+$/, '');
}
function s3Config(){
const h = S.host;
return {
endpoint: String(h.endpoint || '').trim(),
region: String(h.region || '').trim() || 'auto',
bucket: String(h.bucket || '').trim(),
prefix: String(h.prefix || '').trim().replace(/^\/+|\/+$/g, ''),
pathStyle: !!h.pathStyle,
accessKey: s3Secrets.accessKey,
secretKey: s3Secrets.secretKey,
};
}
function s3Missing(cfg){
const miss = [];
if (!cfg.endpoint) miss.push('Endpoint');
if (!cfg.bucket) miss.push('桶名');
if (!cfg.accessKey) miss.push('Access Key');
if (!cfg.secretKey) miss.push('Secret Key');
return miss;
}
function setS3State(kind, text){
S.s3.kind = kind || '';
S.s3.text = text || '';
const chip = $('host-state');
if (chip){
chip.className = 'conn' + (kind === 'ok' ? ' ok' : kind === 'bad' ? ' bad' : '');
chip.textContent = kind === 'ok' ? '已连通' : kind === 'bad' ? '未通过'
: (S.host.mode === 's3' ? 'S3' : '本地');
}
const box = $('s3-result');
if (box){
box.className = 's3stat' + (kind ? ' ' + kind : '');
box.textContent = text || '';
box.style.display = text ? '' : 'none';
}
}
/** 探测本机服务有没有上传接口。`python -m http.server` 起的只有静态文件服务。 */
async function ensureUploadPing(){
if (S.s3.canUpload !== null) return S.s3.canUpload;
try {
const r = await fetch('/api/s3/ping', { cache: 'no-store' });
const j = await r.json();
S.s3.canUpload = !!(j && j.upload);
} catch (e) {
S.s3.canUpload = false;
}
renderHostEditor();
return S.s3.canUpload;
}
async function sbApi(path, body){
const r = await fetch(path, {
method: 'POST', cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const txt = await r.text();
try { return JSON.parse(txt); }
catch (e) { throw new Error(`本地服务返回了非 JSON(HTTP ${r.status}):${txt.slice(0, 120)}`); }
}
function renderHostEditor(){
const el = $('host-editor');
if (!el) return;
el.innerHTML = '';
const pick = document.createElement('div');
pick.className = 'hostpick';
[['local', '本地(随二进制)'], ['s3', 'S3 兼容存储']].forEach(([k, text]) => {
const b = document.createElement('button');
b.textContent = text;
if (S.host.mode === k) b.className = 'on';
b.onclick = () => { S.host.mode = k; setS3State('', ''); renderAll(); saveDraft(); };
pick.appendChild(b);
});
el.appendChild(pick);
if (S.host.mode !== 's3'){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '图片打进 static/images/themes/<slug>/,随二进制发布 —— 部署即生效、不依赖外部服务,'
+ '代价是换一张图要重新编译。选右边可以把素材放到自己的对象存储。';
el.appendChild(p);
setS3State('', '');
return;
}
// ---- 字段 ----
const field = (label, key, opts) => {
opts = opts || {};
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
s.style.width = '84px';
const i = document.createElement('input');
i.type = opts.password ? 'password' : 'text';
i.spellcheck = false;
i.autocomplete = 'off';
i.placeholder = opts.placeholder || '';
i.style.cssText = 'flex:1 1 0;width:0;min-width:0';
if (opts.password){
i.value = s3Secrets[key] || '';
i.oninput = () => { s3Secrets[key] = i.value; saveSecrets(); };
} else {
i.value = S.host[key] == null ? '' : String(S.host[key]);
i.oninput = () => {
S.host[key] = i.value;
scheduleSave();
if (key === 'endpoint' || key === 'bucket' || key === 'prefix' || key === 'pathStyle') refreshDerived();
};
// 失焦立刻落盘:scheduleSave 有 700ms 防抖,而填完最后一个字段就去点
// 「测试连接 → 上传 → 刷新」时,草稿可能还没写下去,配置就白填了。
i.onchange = () => saveDraft();
}
r.append(s, i);
el.appendChild(r);
return i;
};
const derived = document.createElement('p');
derived.className = 'hint';
derived.id = 's3-derived';
function refreshDerived(){
const auto = derivePublicBase(S.host) || '(填 Endpoint 与桶名后自动推导)';
derived.textContent = '图片地址 = ' + (publicBaseOf(S.host) || auto) + '/' + (($('slug').value || '').trim() || 'my-theme')
+ '/<id>.webp';
}
field('Endpoint', 'endpoint', { placeholder: 'https://<账号>.r2.cloudflarestorage.com' });
field('Region', 'region', { placeholder: 'auto' });
field('桶名', 'bucket', { placeholder: 'my-theme-assets' });
field('路径前缀', 'prefix', { placeholder: 'themes(可留空 = 传桶根)' });
field('公开前缀', 'publicBase', { placeholder: '留空 = 按 Endpoint 推导' });
field('Access Key', 'accessKey', { password: true });
field('Secret Key', 'secretKey', { password: true });
const chk = document.createElement('label');
chk.className = 'chk';
chk.style.margin = '2px 0 0 0';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!S.host.pathStyle;
cb.onchange = () => {
S.host.pathStyle = cb.checked;
refreshDerived();
saveDraft();
};
chk.append(cb, document.createTextNode('路径风格(MinIO / R2 / B2 勾选;AWS 与虚拟主机域名取消)'));
el.appendChild(chk);
el.appendChild(derived);
refreshDerived();
const acts = document.createElement('div');
acts.className = 's3acts';
const btnTest = document.createElement('button');
btnTest.textContent = S.s3.busy ? '请稍候…' : '测试连接';
const btnUp = document.createElement('button');
btnUp.className = 'primary';
btnUp.textContent = S.s3.busy ? '请稍候…' : '上传素材';
const can = S.s3.canUpload;
if (can === false){
btnTest.disabled = true;
btnUp.disabled = true;
btnTest.title = btnUp.title = '上传接口不可用';
} else if (S.s3.busy){
btnTest.disabled = btnUp.disabled = true;
}
btnTest.onclick = testS3;
btnUp.onclick = uploadAssets;
acts.append(btnTest, btnUp);
el.appendChild(acts);
const res = document.createElement('div');
res.id = 's3-result';
res.className = 's3stat' + (S.s3.kind ? ' ' + S.s3.kind : '');
res.style.display = S.s3.text ? '' : 'none';
res.textContent = S.s3.text || '';
el.appendChild(res);
// 远端清单放在状态框下面:先看「测试/上传结果」,再看「远端现在到底有什么」
el.appendChild(renderRemoteBlock());
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = can === false
? '当前是普通静态服务,没有上传接口。用 python dev/theme-builder/serve.py 启动生成器才能上传。'
: '密钥只存在当前标签页(sessionStorage):刷新还在,关掉标签页就没了,也不进工程文件与导出 zip。'
+ '想复用就别关标签页,或用脚本直传(见 dev/theme-tools/export_assets.py)。'
+ '上传成功后会自动把「素材前缀」填成上面的图片地址。';
el.appendChild(tip);
if (can === null) ensureUploadPing();
}
async function testS3(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
S.s3.busy = true; setS3State('', '正在测试…'); renderHostEditor();
try {
const j = await sbApi('/api/s3/test', { config: cfg });
if (!j.ok){
const lines = ['测试失败:' + (j.error || '未知错误')];
if (j.code) lines.push('错误码 ' + j.code + (j.status ? `(HTTP ${j.status})` : ''));
if (j.hint) lines.push(j.hint);
setS3State('bad', lines.join('\n'));
log('S3 测试失败:' + (j.error || ''), 'e');
} else {
const lines = (j.steps || []).map(s => `${s.warn ? '·' : '✓'} ${s.name}:${s.detail}`);
const warn = (j.steps || []).some(s => s.warn);
S.s3.lastOK = Date.now();
setS3State(warn ? 'warn' : 'ok', lines.join('\n'));
log('S3 连通性测试通过(' + j.key + ' 已清理)', warn ? 'w' : 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderHostEditor();
}
}
async function uploadAssets(onlyIds){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = currentSlug();
// usedAssetIds 返回 Set:它有 size 没有 length/map,先展开成数组再用。
// onlyIds 是「补齐差异」传进来的子集 —— 那时只该传缺的/变了的。
const ids = onlyIds ? onlyIds.slice() : [...usedAssetIds()];
if (!ids.length){ setS3State('bad', '还没有素材:先在「图片池」里加图片并摆到图层/背景上'); return; }
const files = ids.map(id => {
const a = assetById(id);
if (!a) return null;
return {
key: `${slug}/${id}.webp`,
name: `${id}.webp`,
contentType: 'image/webp',
data: (a.dataURL.split(',')[1] || ''),
};
}).filter(f => f && f.data);
if (!files.length){ setS3State('bad', '这些素材没有可上传的内容(dataURL 为空)'); return; }
const mb = files.reduce((n, f) => n + f.data.length, 0) * 0.75 / 1048576;
S.s3.busy = true;
setS3State('', `正在上传 ${files.length} 张(约 ${mb.toFixed(2)}MB)…` + '\n首次传较多时会慢一会儿,别关页面。');
renderHostEditor();
let done = false;
try {
const j = await sbApi('/api/s3/upload', { config: cfg, files });
if (!j.ok){
const bad = (j.files || []).filter(f => !f.ok).slice(0, 3)
.map(f => `· ${f.name}:${f.error || f.code || '失败'}`);
setS3State('bad', [`${j.failed || '?'} / ${j.total || files.length} 张失败:`].concat(bad).join('\n'));
log(`素材上传失败 ${j.failed} 张`, 'e');
} else {
const pub = publicBaseOf(S.host);
if (pub) S.assetBase = pub;
saveDraft();
done = true;
setS3State('ok', `已上传 ${j.total} 张到 ${cfg.prefix || '桶根'}/` + slug + '/\n'
+ '导出的 CSS 会写成 ' + (pub || '(未推导出公开前缀)') + '/' + slug + '/<id>.webp');
log(`素材已上传 ${j.total} 张;素材前缀已设为 ${pub || '(空)'}`, 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false; renderAll();
}
// 检测过远端就顺手重读:否则刚传完还显示「缺失 3」,等于让用户自己骗自己。
if (done && S.remote.at) await refreshRemote();
}
/* ---------------------- 远端素材(检测 / 覆盖 / 删除) ---------------------- */
/* 上传只解决「传上去」,解决不了「传上去之后对不对」:同一个 slug 传过三次、
换过 slug 旧图还留在桶里、重新导出后远端还是旧图 —— 这些在页面上一概看不出来。
所以再给一份远端清单,按对象键名(= <slug>/<id>.webp)跟本地比:
差在哪、怎么补、怎么删,都写进行里。 */
function currentSlug(){
return ($('slug').value || '').trim() || 'my-theme';
}
/** 本地引用素材 vs 远端对象的差异。用大小当内容指纹:同一 id 的图重编过,大小就会变。 */
function remoteDiff(){
const local = new Map();
[...usedAssetIds()].forEach(id => {
const a = assetById(id);
if (a && a.dataURL) local.set(id, dataURLToBytes(a.dataURL).length);
});
const rows = [];
const seen = new Set();
(S.remote.objects || []).forEach(o => {
const id = o.key.split('/').pop().replace(/\.webp$/i, '');
const bytes = local.get(id);
if (bytes == null){
rows.push({ kind: 'extra', id, o });
} else {
seen.add(id);
rows.push({ kind: bytes === o.size ? 'same' : 'changed', id, o, bytes });
}
});
const missing = [...local.keys()].filter(id => !seen.has(id));
return { rows, missing };
}
function fmtBytes(n){
if (n >= 1048576) return (n / 1048576).toFixed(1) + 'MB';
if (n >= 1024) return Math.round(n / 1024) + 'KB';
return n + 'B';
}
/** 列表返回的是完整对象键,删除接口要的是「相对路径前缀」的键(与上传同一个约定)。
不剥这一层,服务端会再拼一次前缀 → 删的是一个不存在的键,S3 还回 204,看起来像成功。 */
function relKey(key, prefix){
const p = String(prefix || '').replace(/^\/+|\/+$/g, '');
return p && key.startsWith(p + '/') ? key.slice(p.length + 1) : key;
}
async function refreshRemote(){
const cfg = s3Config();
const miss = s3Missing(cfg);
if (miss.length){ setS3State('bad', '还差:' + miss.join('、')); return; }
const slug = currentSlug();
S.s3.busy = true;
setS3State('', '正在读取远端清单…');
renderHostEditor();
try {
const j = await sbApi('/api/s3/list', { config: cfg, prefix: slug + '/' });
if (!j.ok){
setS3State('bad', '读取远端清单失败:' + (j.error || '未知错误') + (j.hint ? '\n' + j.hint : ''));
log('读取远端清单失败:' + (j.error || ''), 'e');
return;
}
S.remote = { objects: j.objects || [], at: Date.now(), slug, prefix: j.prefix };
const d = remoteDiff();
const same = d.rows.filter(r => r.kind === 'same').length;
const changed = d.rows.filter(r => r.kind === 'changed').length;
const extra = d.rows.filter(r => r.kind === 'extra').length;
const clean = !changed && !extra && !d.missing.length;
// 只说结论,数字交给下面的汇总行 —— 两处都列一遍只是噪声
setS3State(clean ? 'ok' : 'warn',
clean ? `远端与本地一致(${same} 个对象)。` : '有差异,见下方清单。');
log(`远端检测:${d.rows.length} 个对象(一致 ${same} / 大小不同 ${changed} / 多余 ${extra} / 缺失 ${d.missing.length})`,
clean ? 'ok' : 'w');
} catch (e){
setS3State('bad', '读取远端清单失败:' + e.message);
} finally {
S.s3.busy = false; renderHostEditor();
}
}
/** 补差异:只传「远端没有」和「大小不一致」的,一致的不重传。 */
async function uploadDiff(){
const d = remoteDiff();
const ids = d.missing.concat(d.rows.filter(r => r.kind === 'changed').map(r => r.id));
if (!ids.length){
setS3State('ok', '远端与本地一致,没有要补的。');
return;
}
await uploadAssets(ids);
}
async function deleteRemoteKeys(keys, why){
if (!keys.length) return false;
const preview = keys.slice(0, 6).join('\n') + (keys.length > 6 ? `\n…… 另有 ${keys.length - 6} 个` : '');
if (!confirm(`从对象存储删除 ${keys.length} 个对象?\n\n${preview}\n\n${why}\n\n只删远端,本地素材不受影响。`)) return false;
const cfg = s3Config();
const rel = keys.map(k => relKey(k, cfg.prefix));
S.s3.busy = true;
setS3State('', `正在删除 ${keys.length} 个对象…`);
renderHostEditor();
try {
const j = await sbApi('/api/s3/delete', { config: cfg, keys: rel });
if (!j.ok){
const bad = (j.files || []).filter(f => !f.ok).slice(0, 3)
.map(f => `· ${f.key}:${f.error || f.code || '失败'}`);
setS3State('bad', [`${j.failed} / ${j.total} 个删除失败:`].concat(bad).join('\n'));
log(`远端删除失败 ${j.failed} 个`, 'e');
} else {
log(`已从远端删除 ${j.total} 个对象`, 'ok');
}
} catch (e){
setS3State('bad', '请求本地服务失败:' + e.message);
} finally {
S.s3.busy = false;
}
await refreshRemote();
return true;
}
function deleteExtra(){
const d = remoteDiff();
return deleteRemoteKeys(d.rows.filter(r => r.kind === 'extra').map(r => r.o.key),
'这些对象在远端存在、但当前主题已经不再引用。');
}
function renderRemoteBlock(){
const wrap = document.createElement('div');
wrap.className = 'remote';
const head = document.createElement('div');
head.className = 'rhead';
const title = document.createElement('span');
title.textContent = '远端素材';
const btnCheck = document.createElement('button');
btnCheck.textContent = '检测';
btnCheck.title = '读取 <路径前缀>/<slug>/ 下的对象,跟本地比';
btnCheck.disabled = !!S.s3.busy;
btnCheck.onclick = refreshRemote;
head.append(title, btnCheck);
wrap.appendChild(head);
if (S.remote.at && S.remote.slug !== currentSlug()){
const p = document.createElement('p');
p.className = 'hint';
p.style.margin = '0';
p.textContent = `上次检测的是 ${S.remote.slug},slug 已改成 ${currentSlug()} —— 重新点「检测」。`;
wrap.appendChild(p);
return wrap;
}
if (!S.remote.at){
const p = document.createElement('p');
p.className = 'hint';
p.style.margin = '0';
p.textContent = '点「检测」读取远端已有对象:哪些一致、哪些是旧图(大小不同)、'
+ '哪些已经没用了(多余),都能在行里直接覆盖或删除。';
wrap.appendChild(p);
return wrap;
}
const d = remoteDiff();
const list = document.createElement('div');
list.className = 'rlist';
const show = d.rows.slice(0, 24);
show.forEach(r => {
const row = document.createElement('div');
row.className = 'rrow';
const nm = document.createElement('span');
nm.className = 'nm';
nm.textContent = r.o.key.split('/').pop();
nm.title = r.o.key;
const sz = document.createElement('span');
sz.className = 'sz';
sz.textContent = fmtBytes(r.o.size);
sz.title = '远端 ' + fmtBytes(r.o.size)
+ (r.bytes != null ? ` / 本地 ${fmtBytes(r.bytes)}` : '')
+ (r.o.modified ? '\n最后修改 ' + r.o.modified : '');
const bg = document.createElement('span');
bg.className = 'bg ' + r.kind;
bg.textContent = r.kind === 'same' ? '一致' : r.kind === 'changed' ? '旧图' : '多余';
bg.title = r.kind === 'same' ? '大小与本地一致'
: r.kind === 'changed' ? '远端与本地大小不同:多半重新导出过,点 ↻ 覆盖'
: '远端有这个对象,但当前主题不再引用';
const btnPush = document.createElement('button');
btnPush.textContent = '↻';
btnPush.title = '用本地这张覆盖远端';
btnPush.disabled = r.kind === 'extra' || S.s3.busy;
btnPush.onclick = () => uploadAssets([r.id]);
const btnDel = document.createElement('button');
btnDel.textContent = '✕';
btnDel.title = '从远端删除这个对象';
btnDel.disabled = !!S.s3.busy;
btnDel.onclick = () => deleteRemoteKeys([r.o.key], '这是你手动选中的对象。');
row.append(nm, sz, bg, btnPush, btnDel);
list.appendChild(row);
});
wrap.appendChild(list);
if (d.rows.length > show.length){
const more = document.createElement('p');
more.className = 'rsum';
more.textContent = `…… 另有 ${d.rows.length - show.length} 个未列出`;
wrap.appendChild(more);
}
const sum = document.createElement('div');
sum.className = 'rsum';
const same = d.rows.filter(r => r.kind === 'same').length;
const changed = d.rows.filter(r => r.kind === 'changed').length;
const extra = d.rows.filter(r => r.kind === 'extra').length;
sum.textContent = `一致 ${same} · 旧图 ${changed} · 多余 ${extra} · 缺失 ${d.missing.length}`
+ (d.missing.length ? `(${d.missing.slice(0, 4).join('、')}${d.missing.length > 4 ? '…' : ''})` : '');
wrap.appendChild(sum);
const acts = document.createElement('div');
acts.className = 'racts';
const btnFill = document.createElement('button');
btnFill.className = 'primary';
btnFill.textContent = `补齐差异${d.missing.length + changed ? ` (${d.missing.length + changed})` : ''}`;
btnFill.title = '上传远端缺失的、以及大小不一致的(一致的不重传)';
btnFill.disabled = S.s3.busy || !(d.missing.length + changed);
btnFill.onclick = uploadDiff;
const btnClean = document.createElement('button');
btnClean.textContent = `删除多余${extra ? ` (${extra})` : ''}`;
btnClean.title = '删除远端存在、但当前主题不再引用的对象';
btnClean.disabled = S.s3.busy || !extra;
btnClean.onclick = deleteExtra;
acts.append(btnFill, btnClean);
wrap.appendChild(acts);
return wrap;
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
async function applyQuality(what){
if (!S.assets.length){ renderAll(); return; }
@@ -1700,6 +2320,7 @@ function renderAll(){
renderLayers();
renderAssets();
renderExportEditor();
renderHostEditor();
renderNarrowEditor();
renderPalette();
applyTheme();
@@ -1725,6 +2346,9 @@ function toJSON(){
narrow: S.narrow,
export: S.export,
assetBase: S.assetBase,
// 托管配置进工程(换机器接着传),但 accessKey/secretKey 刻意不进去:
// 工程文件常被塞进仓库或发给别人,密钥一旦落进去就没法收回了。
host: S.host,
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
@@ -1755,6 +2379,8 @@ function fromJSON(o){
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {});
S.assetBase = typeof o.assetBase === 'string' ? o.assetBase : '';
S.host = Object.assign({}, D.host, o.host || {});
if (S.host.mode !== 's3') S.host.mode = 'local';
S.assets = (o.assets || []).map(a => {
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
const dataURL = a.dataURL || orig.dataURL;
@@ -1804,6 +2430,8 @@ function resetAll(){
S.narrow = Object.assign({}, D.narrow);
S.export = Object.assign({}, D.export);
S.assetBase = '';
// 托管配置回默认,但密钥保留(sessionStorage)—— 换主题往往还是传到同一个桶
S.host = Object.assign({}, D.host);
S.dark = Object.assign({}, D.dark);
S.scopes = {};
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
@@ -1975,7 +2603,13 @@ ${S.assetBase
素材托管在对象存储,CSS 写的是绝对地址:
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效`
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效
怎么传上去(两种都行):
a) 生成器左侧「素材托管 → S3 兼容存储」,填 Endpoint / Region / 桶名 / 密钥,
点「测试连接」通过后直接点「上传素材」,前缀会自动填对;
b) 命令行:aws s3 sync static/images/themes/${slug}/ s3://<桶名>/<路径前缀>/${slug}/ --endpoint-url <Endpoint>
(rclone / s5cmd 同理,任何 S3 兼容客户端都行)`
: `1) 解压(保持目录结构,覆盖即可)
static/css/themes/${slug}.css
static/images/themes/${slug}/*.webp`}
@@ -2040,13 +2674,9 @@ ${S.narrow.enabled
`;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
/** 本次主题真正引用到的素材 id(导出与上传共用同一份判定)。
没有被任何图层/背景引用的图不该进 zip,也不该传到对象存储上占位。 */
function usedAssetIds(){
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
@@ -2055,6 +2685,17 @@ function doExport(){
c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
};
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
return used;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = usedAssetIds();
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
let imgKB = 0, imgN = 0;
@@ -2701,7 +3342,7 @@ function boot(){
if (restored){
log('已恢复上次的草稿(换 slug / 项目时可点「打开工程」,或加 ?fresh=1 从空白开始)。', 'ok');
} else {
out('在项目根执行 python dev/theme-builder/serve.py,用 http://127.0.0.1:8777/dev/theme-builder/ 打开,才能预览真实页面。');
out('在项目根执行 python dev/theme-builder/serve.py,用 ' + HERE + ' 打开,才能预览真实页面。');
}
$('preview').addEventListener('load', onFrameLoad);
+184
View File
@@ -0,0 +1,184 @@
"""最小 S3 兼容假服务,只为调试生成器的「测试连接 / 上传素材」。
它不校验签名(签名正确性由 check_sigv4.py 与官方 SDK 对拍保证),只校验
请求形态与权限分支,所以能用来演练:桶名写错、密钥写错、前缀为空、
虚拟主机风格这些配置。
独立跑:python dev/theme-builder/mock_s3.py --port 9900
被复用:from mock_s3 import MockS3, BUCKET
"""
import argparse
import sys
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BUCKET = "tb-assets"
class MockS3(BaseHTTPRequestHandler):
store = {}
requests = []
def log_message(self, fmt, *args):
pass
# ---- helpers ----
def _split(self):
path = urllib.parse.unquote(self.path.split("?")[0]).lstrip("/")
bucket, _, key = path.partition("/")
return bucket, key
def _key_id(self):
# Authorization 形如
# AWS4-HMAC-SHA256 Credential=AKID/20261021/us-east-1/s3/aws4_request, SignedHeaders=...
# 第一个逗号前既有算法名又有 Credential,不能按逗号切完再比前缀。
auth = self.headers.get("Authorization") or ""
i = auth.find("Credential=")
if i < 0:
return ""
return auth[i + len("Credential="):].split("/")[0]
def _body(self):
n = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(n) if n else b""
def _xml(self, status, code, message):
body = ("<?xml version=\"1.0\"?><Error><Code>%s</Code><Message>%s</Message></Error>"
% (code, message)).encode()
self.send_response(status)
self.send_header("Content-Type", "application/xml")
if self.command != "HEAD":
# HEAD 不能有 body:真 S3 也只是返回状态行,客户端仍会按状态码报错。
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _check(self, bucket):
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if self._key_id() == "BADKEY":
self._xml(403, "SignatureDoesNotMatch",
"The request signature we calculated does not match")
return False
if bucket != BUCKET:
self._xml(404, "NoSuchBucket", "The specified bucket does not exist")
return False
if not (self.headers.get("x-amz-date") or "").endswith("Z"):
self._xml(403, "AccessDenied", "missing x-amz-date")
return False
return True
# ---- verbs ----
def do_HEAD(self):
bucket, _ = self._split()
if not self._check(bucket):
return
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def do_PUT(self):
bucket, key = self._split()
data = self._body()
if not self._check(bucket):
return
MockS3.store[key] = (data, self.headers.get("Content-Type"),
self.headers.get("Cache-Control"))
self.send_response(200)
self.send_header("ETag", '"mock"')
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
qs = urllib.parse.parse_qs(parsed.query)
bucket, key = self._split()
if not key and qs.get("list-type") == ["2"]:
if not self._check(bucket):
return
return self._list(qs.get("prefix", [""])[0],
int(qs.get("max-keys", ["1000"])[0] or 1000),
int(qs.get("continuation-token", ["0"])[0] or 0))
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if key not in MockS3.store:
self._xml(404, "NoSuchKey", "The specified key does not exist")
return
data, ctype, _ = MockS3.store[key]
self.send_response(200)
self.send_header("Content-Type", ctype or "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _list(self, prefix, max_keys, offset):
# 只实现 ListObjectsV2 里服务端用到的字段;分页用 offset 当 token,够测真实分页逻辑。
keys = sorted(k for k in MockS3.store if k.startswith(prefix))
page = keys[offset:offset + max_keys]
truncated = offset + len(page) < len(keys)
items = []
for k in page:
data = MockS3.store[k][0]
items.append(
"<Contents><Key>%s</Key><Size>%d</Size>"
"<LastModified>2026-10-21T00:00:00.000Z</LastModified><ETag>&quot;mock&quot;</ETag></Contents>"
% (k, len(data)))
token = ("<NextContinuationToken>%d</NextContinuationToken>" % (offset + len(page))) if truncated else ""
body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
"<Name>%s</Name><Prefix>%s</Prefix><KeyCount>%d</KeyCount>"
"<MaxKeys>%d</MaxKeys><IsTruncated>%s</IsTruncated>%s%s"
"</ListBucketResult>"
% (BUCKET, prefix, len(page), max_keys, "true" if truncated else "false",
token, "".join(items))
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
bucket, key = self._split()
if not self._check(bucket):
return
MockS3.store.pop(key, None)
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
def spin(port=0, host="127.0.0.1"):
"""起在后台线程,返回 (server, endpoint)。port=0 让系统挑空闲端口。
host 默认只给本机用;如果生成器是在别的设备上打开的(局域网访问),
要把这里改成 0.0.0.0,并在面板里把 Endpoint 填成本机 IP。
"""
srv = ThreadingHTTPServer((host, port), MockS3)
threading.Thread(target=srv.serve_forever, daemon=True).start()
shown = "127.0.0.1" if host in ("0.0.0.0", "::") else host
return srv, "http://%s:%d" % (shown, srv.server_address[1])
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器用的假 S3")
ap.add_argument("--port", type=int, default=9900)
ap.add_argument("--host", default="127.0.0.1",
help="监听地址,默认 127.0.0.1;生成器在别的设备上打开时用 0.0.0.0")
args = ap.parse_args()
srv, endpoint = spin(args.port, args.host)
print("假 S3 已启动:%s" % endpoint)
print(" 桶名 %s,Access Key 随意(写 BADKEY 会返回 SignatureDoesNotMatch)" % BUCKET)
if args.host in ("0.0.0.0", "::"):
print(" 局域网设备访问时,面板里的 Endpoint 填 http://<本机IP>:%d" % args.port)
print("Ctrl+C 停止")
try:
threading.Event().wait()
except KeyboardInterrupt:
srv.shutdown()
print("\n已停止")
if __name__ == "__main__":
sys.exit(main())
+152
View File
@@ -0,0 +1,152 @@
"""上传链路自测:起一个假 S3,跑一遍「测试连接 → 上传 → 读回」。
覆盖 serve.py 里真正容易错、又不能靠对拍发现的部分:
· 桶级 HEAD 与对象级 PUT/GET/DELETE 的路径拼接(path style / 虚拟主机)
· 前缀拼接、Content-Type、immutable 缓存头
· 4xx 时能否把 S3 的 <Code>/<Message> 还原成人话(含 hint)
签名数学由 check_sigv4.py 与官方 SDK 对拍保证,这里不重复。
用法(项目根):
python dev/theme-builder/selftest_upload.py
"""
import base64
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import serve # noqa: E402
from mock_s3 import BUCKET, MockS3 # noqa: E402
def spin():
import mock_s3
return mock_s3.spin()
FAILS = []
def check(desc, cond, extra=""):
print(("OK " if cond else "FAIL ") + desc + ((" ← " + extra) if extra and not cond else ""))
if not cond:
FAILS.append(desc)
def main():
srv, endpoint = spin()
base = dict(endpoint=endpoint, bucket=BUCKET, prefix="themes", region="us-east-1",
accessKey="AKID", secretKey="SECRET")
# 1) 测试连接:4 步应全过,探针最后被删掉
cfg = serve.S3Config(dict(base))
res = serve.test_connection(cfg)
check("测试连接返回 ok", res.get("ok") is True)
check("测试连接 4 步全过", all(s["ok"] for s in res["steps"]), str(res))
check("探针已清理", not any(k.startswith("themes/.tb-probe") for k in MockS3.store),
str(list(MockS3.store)))
# 2) 上传:前缀 / 内容 / 头都要对
f1 = b"\x52\x49\x46\x46fakewebp1"
f2 = b"\x52\x49\x46\x46fakewebp2"
up = serve.upload_files(cfg, [
{"key": "shiroko/asset1.webp", "contentType": "image/webp",
"data": base64.b64encode(f1).decode()},
{"key": "shiroko/asset2.webp", "contentType": "image/webp",
"data": base64.b64encode(f2).decode()},
])
check("上传全部成功", up["ok"] and up["total"] == 2 and up["failed"] == 0, str(up))
check("前缀拼进对象键", "themes/shiroko/asset1.webp" in MockS3.store, str(list(MockS3.store)))
stored, ctype, cache = MockS3.store.get("themes/shiroko/asset1.webp", (b"", None, None))
check("内容一致", stored == f1)
check("Content-Type = image/webp", ctype == "image/webp", str(ctype))
check("Cache-Control = immutable", "immutable" in (cache or ""), str(cache))
# 2b) 逐文件覆盖缓存头:README/清单这类「键不变、内容会变」的文件不能 immutable
serve.upload_files(cfg, [
{"key": "shiroko/INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(b"readme").decode()},
])
_, _, cc = MockS3.store.get("themes/shiroko/INSTALL.txt", (b"", None, None))
check("可覆盖 Cache-Control", cc == "public, max-age=60", str(cc))
# 这一步只为验头,别让它污染后面的对象计数
MockS3.store.pop("themes/shiroko/INSTALL.txt", None)
# 3) 错误密钥:HEAD 拿不到响应体(规范如此),只能按状态码归因;
# 真正能读到 S3 错误码的是带 body 的 PUT,所以用上传路径验证还原能力。
bad = serve.S3Config(dict(base, accessKey="BADKEY"))
try:
serve.test_connection(bad)
check("错误密钥应抛错", False)
except serve.S3Error as e:
check("HEAD 失败按状态码归因", e.status == 403 and e.code == "AccessDenied",
"%s %s" % (e.status, e.code))
badUp = serve.upload_files(bad, [{"key": "shiroko/x.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
codes = [f.get("code") for f in badUp["files"]]
check("PUT 失败能还原 SignatureDoesNotMatch", codes == ["SignatureDoesNotMatch"], str(codes))
check("该错误有中文提示", "签名不匹配" in serve.S3_HINTS.get("SignatureDoesNotMatch", ""))
# 4) 桶名写错:NoSuchBucket
wrong = serve.S3Config(dict(base, bucket="nope"))
try:
serve.test_connection(wrong)
check("错桶应抛错", False)
except serve.S3Error as e:
check("错桶 → NoSuchBucket", e.code == "NoSuchBucket", e.code)
# 5) 键里出现 .. 必须被拒(防止越权写到别的前缀)
try:
serve.full_key(cfg, "../etc/passwd")
check(".. 应被拒", False)
except ValueError:
check(".. 被拒绝", True)
# 6) 前缀留空 = 传桶根
plain = serve.S3Config(dict(base, prefix=""))
up2 = serve.upload_files(plain, [{"key": "root.webp", "contentType": "image/webp",
"data": base64.b64encode(b"x").decode()}])
check("空前缀传桶根", up2["ok"] and "root.webp" in MockS3.store, str(list(MockS3.store)))
# 7) 列远端:前缀过滤、大小、分页
serve.upload_files(cfg, [{"key": "shiroko/asset3.webp", "contentType": "image/webp",
"data": base64.b64encode(b"three").decode()}])
ls = serve.list_objects(cfg, "shiroko/")
keys = sorted(o["key"] for o in ls["objects"])
check("按 <前缀>/<slug>/ 列出 3 个对象",
keys == ["themes/shiroko/asset1.webp", "themes/shiroko/asset2.webp",
"themes/shiroko/asset3.webp"], str(keys))
size1 = [o["size"] for o in ls["objects"] if o["key"].endswith("asset1.webp")]
check("对象大小与上传字节一致", size1 == [len(f1)], str(size1))
paged = serve.list_objects(cfg, "shiroko/", max_keys=2)
check("分页把 3 个都取回来了", len(paged["objects"]) == 3, str(len(paged["objects"])))
everything = serve.list_objects(cfg, "")
# 空前缀 = 列「路径前缀」之下(跨主题),不扫整个桶:桶里还可能有头像等别的东西。
check("空前缀列出 <路径前缀>/ 下全部、且不越界",
all(o["key"].startswith("themes/") for o in everything["objects"])
and len(everything["objects"]) == 3,
str([o["key"] for o in everything["objects"]]))
# 8) 删除:幂等(不存在的键也当成功),且不影响别的前缀
del_res = serve.delete_objects(cfg, ["shiroko/asset3.webp", "shiroko/nonexistent.webp"])
check("删除含不存在的键仍算成功", del_res["ok"] and del_res["total"] == 2, str(del_res))
after = serve.list_objects(cfg, "shiroko/")
check("删完只剩 2 个", len(after["objects"]) == 2,
str([o["key"] for o in after["objects"]]))
check("桶根对象没被误删", "root.webp" in MockS3.store)
# 9) 虚拟主机风格的路径(不带桶名)
vh = serve.S3Config(dict(base, pathStyle=False))
check("虚拟主机路径不含桶名", vh.object_path("themes/a.webp") == "/themes/a.webp", vh.object_path("x"))
check("虚拟主机 host 带桶名", vh.host.startswith(BUCKET + "."), vh.host)
srv.shutdown()
print("\n%d 项失败" % len(FAILS))
return 1 if FAILS else 0
if __name__ == "__main__":
raise SystemExit(main())
+531 -10
View File
@@ -1,33 +1,441 @@
#!/usr/bin/env python3
"""主题生成器的本地预览服务。
"""主题生成器的本地预览服务 + 素材上传代理。
为什么必须起服务:生成器不再自己画一个"仿制页面",而是把项目真实渲染出来的
页面(outputs/preview-*.html)放进 iframe 当画板,再往里注入正在编辑的主题 CSS。
file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录),预览就只能退回示意图。
起一个本地 HTTP 服务后父子同源,拖拽、注入、实时改令牌都能正常工作。
用法(在项目根目录执行任意一种):
为什么上传也走这个服务:浏览器直连对象存储要配 CORS、还要把密钥交给页面里的
fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发,
生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。
python dev/theme-builder/serve.py
python -m http.server 8777
用法(在项目根目录执行):
然后打开 http://localhost:8777/dev/theme-builder/
python dev/theme-builder/serve.py # 默认 0.0.0.0:8777,同局域网可访问
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机(公共网络用这个)
python -m http.server 8777 # 只有静态预览,没有上传能力
启动时终端会列出本机与各网卡的地址,照着打开即可。
这个脚本只读文件,不写任何东西,也不改项目配置。
"""
import argparse
import base64
import datetime
import functools
import hashlib
import hmac
import http.server
import json
import os
import re
import socket
import socketserver
import sys
import urllib.error
import urllib.parse
import urllib.request
# dev/theme-builder/serve.py → 上两级就是项目根
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 单次请求体上限:一次上传十几张 1600px 的 WebP,base64 后约 1.4 倍体积,
# 64MB 足够,同时挡住误连到公网时的内存放大。
MAX_BODY_BYTES = 64 * 1024 * 1024
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
# ---------------------------------------------------------------------------
# SigV4 签名
# ---------------------------------------------------------------------------
# 不用 boto3:这个脚本的定位是「零依赖、拷走就能跑」,装一个 SDK 只为签四个请求
# 不划算。下面是 AWS 文档里 SigV4 for S3 的最小实现,覆盖 PUT / GET / DELETE / HEAD。
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _quote_key(key: str) -> str:
"""按 AWS 规则编码对象键:保留 `/`,其余非 unreserved 字符全部百分号编码。
用 quote(safe='/-_.~') 而不是逐段 quote:两者等价,但后者容易漏掉空段
(`a//b`)导致的路径塌缩。S3 的签名 URI 必须与请求行完全一致,差一个字符
就是 SignatureDoesNotMatch。
"""
return urllib.parse.quote(key, safe="/-_.~")
def _quote_query(s):
"""query 的编码规则比 path 更严:`/` 也要编码成 %2F,只有 A-Za-z0-9-_.~ 原样保留。"""
return urllib.parse.quote(str(s), safe="-_.~")
def canonical_query(params):
"""按 AWS 规则拼 canonical query:先按键排序,同名多值再按值排序,逐项编码。
ListObjectsV2 的 `list-type` / `prefix` / `continuation-token` 全走这里。
顺序错、或漏了编码都是 SignatureDoesNotMatch —— 而 S3 不会告诉你差在哪。
"""
pairs = []
for k, v in (params or {}).items():
for one in (v if isinstance(v, (list, tuple)) else [v]):
pairs.append((_quote_query(k), _quote_query(one)))
pairs.sort()
return "&".join("%s=%s" % kv for kv in pairs)
def sign_v4(cfg, method, canonical_path, payload_hash, amz_date, host, query=""):
region = cfg.region
date = amz_date[:8]
signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_headers = (
"host:" + host + "\n"
"x-amz-content-sha256:" + payload_hash + "\n"
"x-amz-date:" + amz_date + "\n"
)
# CanonicalHeaders 自身每行以 \n 结尾,与 SignedHeaders 之间因此有一个空行,
# 这是规范要求的形态,不是笔误。
canonical_request = "\n".join([
method, canonical_path, query or "", canonical_headers, signed_headers, payload_hash,
])
scope = date + "/" + region + "/s3/aws4_request"
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode("utf-8")),
])
k = _hmac(("AWS4" + cfg.secret_key).encode("utf-8"), date)
k = _hmac(k, region)
k = _hmac(k, "s3")
k = _hmac(k, "aws4_request")
signature = hmac.new(k, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
return (
"AWS4-HMAC-SHA256 Credential=" + cfg.access_key + "/" + scope
+ ", SignedHeaders=" + signed_headers + ", Signature=" + signature
)
class S3Error(Exception):
def __init__(self, step, status, code, message):
Exception.__init__(self, message or code or ("HTTP %s" % status))
self.step = step
self.status = status
self.code = code
self.message = message or ""
# 常见错误 → 人话。签名失败是最常见的,原因几乎总是密钥/region/时钟三者之一。
S3_HINTS = {
"SignatureDoesNotMatch": "签名不匹配:核对 Secret Key 是否复制完整、Region 是否与桶一致(R2 填 auto),以及本机时间偏差是否超过 15 分钟。",
"InvalidAccessKeyId": "Access Key 无效:核对 ID,或该密钥没有这个桶的权限。",
"AccessDenied": "权限不足:这个密钥没有写权限,或桶策略禁止了该操作。",
"NoSuchBucket": "桶不存在:核对桶名与 Endpoint 是否属于同一账号/区域。",
"PermanentRedirect": "Endpoint 与桶所在区域不匹配:换对该区域的 Endpoint(或改用虚拟主机风格)。",
"AuthorizationHeaderMalformed": "Region 写错了:填桶实际所在区域(R2 用 auto,MinIO 常为 us-east-1)。",
"InvalidBucketName": "桶名不合法:S3 桶名只允许小写字母、数字、点和中划线。",
"RequestTimeTooSkewed": "本机时间与服务器偏差过大:校准系统时间后重试。",
}
def _parse_s3_error(body):
text = body[:2000].decode("utf-8", "replace")
code = re.search(r"<Code>([^<]+)</Code>", text)
msg = re.search(r"<Message>([^<]+)</Message>", text)
return (code.group(1).strip() if code else ""), (msg.group(1).strip() if msg else text.strip()[:200])
class S3Config:
"""一次上传用到的全部参数。只活在内存里,不落盘。"""
def __init__(self, d):
ep = str(d.get("endpoint") or "").strip()
if not ep:
raise ValueError("Endpoint 没填")
if "://" not in ep:
ep = "https://" + ep
u = urllib.parse.urlsplit(ep)
if u.scheme not in ("http", "https"):
raise ValueError("Endpoint 只支持 http / https")
if not u.hostname:
raise ValueError("Endpoint 里没有主机名")
# 路径部分一律忽略:Endpoint 只表达「服务在哪」,桶和键由字段决定。
self.scheme = u.scheme
self.port = u.port
self.host_only = u.hostname
self.region = str(d.get("region") or "").strip() or "us-east-1"
self.bucket = str(d.get("bucket") or "").strip()
if not self.bucket:
raise ValueError("桶名没填")
self.prefix = str(d.get("prefix") or "").strip().strip("/")
self.access_key = str(d.get("accessKey") or "").strip()
self.secret_key = str(d.get("secretKey") or "").strip()
if not self.access_key or not self.secret_key:
raise ValueError("Access Key / Secret Key 没填")
self.path_style = bool(d.get("pathStyle", True))
@property
def host(self):
if self.path_style:
return self.host_only if not self.port else "%s:%d" % (self.host_only, self.port)
return "%s.%s" % (self.bucket, self.host_only) + (":%d" % self.port if self.port else "")
def object_path(self, key):
"""S3 的签名 URI 必须与请求行逐字一致,所以两者共用这一个函数。"""
path = ("/" + self.bucket + "/" + key) if self.path_style else ("/" + key)
return _quote_key(path)
def url(self, path):
return "%s://%s%s" % (self.scheme, self.host, path)
def _now():
return datetime.datetime.now(datetime.timezone.utc)
def s3_request(cfg, method, key="", body=b"", content_type=None, cache_control=None,
query=None, timeout=25):
"""发一个签名请求。key 为空表示桶级操作(HEAD bucket / ListObjectsV2)。
返回 (status, headers, body);4xx/5xx 不抛异常,交由调用方判断 —— 测试连接
需要把「403 是权限问题」和「连不上是网络问题」区分开讲。
"""
if key:
path = cfg.object_path(key)
else:
path = ("/" + cfg.bucket) if cfg.path_style else "/"
qs = canonical_query(query)
payload_hash = _sha256(body) if body else EMPTY_SHA256
amz_date = _now().strftime("%Y%m%dT%H%M%SZ")
auth = sign_v4(cfg, method, path, payload_hash, amz_date, cfg.host, qs)
url = cfg.url(path) + (("?" + qs) if qs else "")
req = urllib.request.Request(url, data=body if body else None, method=method)
req.add_header("x-amz-content-sha256", payload_hash)
req.add_header("x-amz-date", amz_date)
req.add_header("Authorization", auth)
if content_type:
req.add_header("Content-Type", content_type)
if cache_control:
req.add_header("Cache-Control", cache_control)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e: # URLError / timeout / SSL 错误
raise S3Error("connect", 0, "NetworkError", "%s(%s)" % (e, cfg.host))
def full_key(cfg, key):
key = str(key or "").strip().lstrip("/")
if not key:
raise ValueError("对象键为空")
if ".." in key.split("/") or "\\" in key:
raise ValueError("对象键不能包含 .. 或反斜杠:%s" % key)
return (cfg.prefix + "/" + key) if cfg.prefix else key
def probe_key(cfg):
stamp = _now().strftime("%Y%m%dT%H%M%S")
return full_key(cfg, ".tb-probe-%s.txt" % stamp)
def _public_read(url, timeout=10):
"""匿名 GET 一次,判断「传上去的图能不能直接被浏览器读到」。
这一步不是必须的(很多人会用自定义域/CDN),但它能把最常见的坑当场暴露:
桶是私有的,上传全成功、页面里全是 403。
"""
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status == 200
except urllib.error.HTTPError as e:
return e.code == 200
except Exception:
return False
def test_connection(cfg):
steps = []
status, _, body = s3_request(cfg, "HEAD")
if status >= 400:
code, msg = _parse_s3_error(body)
if not code:
# HEAD 响应按规范没有 body,S3 的错误码拿不到。只能按状态码归因,
# 否则前端只能显示一句「HTTP 403」,用户完全不知道该改哪。
code = {403: "AccessDenied", 404: "NoSuchBucket",
301: "PermanentRedirect", 307: "PermanentRedirect",
400: "BadRequest"}.get(status, "HTTP%d" % status)
msg = "桶级探测失败(HEAD 无响应体,按状态码判断)"
raise S3Error("HeadBucket", status, code, msg)
steps.append({"name": "连接与凭据", "ok": True, "detail": "桶可访问(HEAD %s)" % cfg.bucket})
key = probe_key(cfg)
payload = b"TamaBox theme builder probe\n"
status, _, body = s3_request(cfg, "PUT", key, payload, "text/plain; charset=utf-8", "no-store")
if status >= 400:
code, msg = _parse_s3_error(body)
raise S3Error("PutObject", status, code, msg)
steps.append({"name": "写入权限", "ok": True, "detail": "已写入探针 %s" % key})
status, _, body = s3_request(cfg, "GET", key)
ok = status == 200 and body == payload
steps.append({
"name": "读回校验", "ok": ok,
"detail": "读回一致" if ok else "读回失败(HTTP %s)" % status,
})
public = _public_read(cfg.url(cfg.object_path(key)))
steps.append({
"name": "匿名可读", "ok": True,
"detail": "该路径可直接匿名访问" if public
else "匿名访问被拒 —— 桶没开公开读时正常,靠自定义域或 CDN 分发即可",
"warn": not public,
})
s3_request(cfg, "DELETE", key)
return {"ok": True, "steps": steps, "key": key}
# ---------------------------------------------------------------------------
# 上传
# ---------------------------------------------------------------------------
def upload_files(cfg, files):
if not files:
raise ValueError("没有要上传的素材")
if len(files) > 200:
raise ValueError("一次最多 200 个文件")
results = []
for f in files:
name = str(f.get("name") or f.get("key") or "?")
try:
key = full_key(cfg, f.get("key") or f.get("name"))
data = base64.b64decode(f.get("data") or "", validate=False)
if not data:
raise ValueError("内容为空")
ctype = str(f.get("contentType") or "application/octet-stream")
# 素材键里带内容版本(导出时由 id 决定),内容不会原地变化 → immutable。
# 但清单/说明这类「键不变、内容会变」的文件不能 immutable,否则一年内看不到更新,
# 所以允许逐个文件覆盖。
cache = str(f.get("cacheControl") or "public, max-age=31536000, immutable")
status, _, body = s3_request(cfg, "PUT", key, data, ctype, cache)
if status >= 400:
code, msg = _parse_s3_error(body)
results.append({"key": key, "name": name, "ok": False,
"status": status, "code": code, "error": msg})
else:
results.append({"key": key, "name": name, "ok": True, "bytes": len(data)})
except Exception as e:
results.append({"key": name, "name": name, "ok": False, "error": str(e)})
failed = [r for r in results if not r["ok"]]
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
# ---------------------------------------------------------------------------
# 远端清单 / 删除
# ---------------------------------------------------------------------------
def _local(tag):
"""去掉 XML 命名空间。各家 S3 兼容实现都带 `http://s3.amazonaws.com/doc/2006-03-01/`,
个别(老 MinIO / 某些网关)又不带 —— 只比 localname 两边都能认。"""
return tag.rsplit("}", 1)[-1]
def parse_list_xml(body):
"""解析 ListObjectsV2 响应。返回 (对象数组, 下一页 token 或 None)。"""
import xml.etree.ElementTree as ET
root = ET.fromstring(body)
objects, token, truncated = [], None, False
for node in root:
name = _local(node.tag)
if name == "Contents":
row = {}
for f in node:
row[_local(f.tag)] = f.text
objects.append({
"key": row.get("Key") or "",
"size": int(row.get("Size") or 0),
"modified": row.get("LastModified") or "",
"etag": (row.get("ETag") or "").strip('"'),
})
elif name == "NextContinuationToken":
token = node.text
elif name == "IsTruncated":
truncated = (node.text or "").strip().lower() == "true"
return objects, (token if truncated else None)
def list_objects(cfg, prefix="", max_pages=20, max_keys=1000):
"""列 `<cfg.prefix>/<prefix>` 下所有对象。分页上限只是防呆,正常一个主题不到 100 个。"""
key_prefix = full_key(cfg, prefix) if str(prefix or "").strip() else (cfg.prefix + "/" if cfg.prefix else "")
objects, token, pages = [], None, 0
while pages < max_pages:
pages += 1
query = {"list-type": "2", "max-keys": str(max_keys)}
if key_prefix:
query["prefix"] = key_prefix
if token:
query["continuation-token"] = token
status, _, body = s3_request(cfg, "GET", query=query)
if status >= 400:
code, msg = _parse_s3_error(body)
raise S3Error("ListObjects", status, code or "HTTP%d" % status, msg)
page, token = parse_list_xml(body)
objects.extend(page)
if not token:
break
return {"ok": True, "prefix": key_prefix, "objects": objects, "truncated": bool(token)}
def delete_objects(cfg, keys):
"""逐个 DeleteObject。
没用批量 POST ?delete:那要额外算 Content-MD5、拼 XML body,而一次最多几十个键,
省下的往返抵不上多一条签名路径的风险。S3 的 DELETE 本身幂等(不存在的键也回 204)。
"""
if not keys:
raise ValueError("没有要删除的对象")
if len(keys) > 500:
raise ValueError("一次最多删 500 个对象")
results = []
for k in keys:
try:
key = full_key(cfg, k)
status, _, body = s3_request(cfg, "DELETE", key)
if status >= 400 and status != 404:
code, msg = _parse_s3_error(body)
results.append({"key": key, "ok": False, "status": status,
"code": code or "HTTP%d" % status, "error": msg})
else:
results.append({"key": key, "ok": True})
except Exception as e:
results.append({"key": str(k), "ok": False, "error": str(e)})
failed = [r for r in results if not r["ok"]]
return {"ok": not failed, "files": results, "failed": len(failed), "total": len(results)}
# ---------------------------------------------------------------------------
# HTTP 层
# ---------------------------------------------------------------------------
class Handler(http.server.SimpleHTTPRequestHandler):
server_version = "TamaBoxThemeBuilder/1"
def end_headers(self):
# 预览页是刚由 go test 生成的,禁止缓存,否则改了 CSS 看不到变化
self.send_header("Cache-Control", "no-store, must-revalidate")
@@ -39,11 +447,108 @@ class Handler(http.server.SimpleHTTPRequestHandler):
if str(status).startswith(("4", "5")):
sys.stderr.write(" %s\n" % (fmt % args))
def _json(self, payload, status=200):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
raise ValueError("请求体为空")
if n > MAX_BODY_BYTES:
raise ValueError("请求体过大(%d 字节),一次别传太多素材" % n)
return json.loads(self.rfile.read(n).decode("utf-8"))
def _same_origin(self):
"""写接口只接受「页面自己发来的」请求。
监听 0.0.0.0 之后,同网段里的另一个网页可以借访客浏览器向这里发写请求
(删对象、改前缀)。浏览器跨站请求一定带 Origin,比对它与 Host 是否同源
就能挡掉这类「借刀」调用。curl / 脚本不带 Origin,照常放行。
"""
origin = self.headers.get("Origin")
if not origin:
return True
host = (self.headers.get("Host") or "").strip().lower()
return urllib.parse.urlsplit(origin).netloc.lower() == host
def do_GET(self):
if self.path.split("?")[0] == "/api/s3/ping":
# 生成器靠它判断「当前是不是 serve.py 起的」——python -m http.server 没有这个端点。
return self._json({"ok": True, "upload": True})
return super().do_GET()
def do_POST(self):
route = self.path.split("?")[0]
if route not in ("/api/s3/test", "/api/s3/upload", "/api/s3/list", "/api/s3/delete"):
return self._json({"ok": False, "error": "未知接口 %s" % route}, 404)
if not self._same_origin():
return self._json({"ok": False, "error": "拒绝跨站写请求(Origin 与访问地址不一致)"}, 403)
try:
payload = self._read_json()
except Exception as e:
return self._json({"ok": False, "error": "请求解析失败:%s" % e})
try:
cfg = S3Config(payload.get("config") or {})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
try:
if route == "/api/s3/test":
return self._json(test_connection(cfg))
if route == "/api/s3/list":
return self._json(list_objects(cfg, payload.get("prefix") or ""))
if route == "/api/s3/delete":
return self._json(delete_objects(cfg, payload.get("keys") or []))
return self._json(upload_files(cfg, payload.get("files") or []))
except S3Error as e:
return self._json({
"ok": False, "step": e.step, "status": e.status, "code": e.code,
"error": e.message, "hint": S3_HINTS.get(e.code, ""),
})
except ValueError as e:
return self._json({"ok": False, "error": str(e)})
except Exception as e: # 兜底:别把本地服务弄崩,把异常原样告诉页面
return self._json({"ok": False, "error": "%s: %s" % (type(e).__name__, e)})
def local_addresses():
"""本机所有可用于访问的 IPv4(回环排最后)。
绑 0.0.0.0 时监听地址本身不能直接点开(浏览器访问 http://0.0.0.0 行为各家不一),
所以启动时把真实 IP 列出来,省得人去 ipconfig 里翻。
"""
out = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in out and not ip.startswith("127."):
out.append(ip)
except Exception:
pass
try:
# 不联网也会返回默认路由网卡的地址;这一步能补上 getaddrinfo 漏掉的情况
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if ip and not ip.startswith("127.") and ip not in out:
out.append(ip)
except Exception:
pass
return out
def main():
ap = argparse.ArgumentParser(description="TamaBox 主题生成器的本地预览服务")
ap.add_argument("--port", type=int, default=8777, help="端口,默认 8777")
ap.add_argument("--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1")
ap.add_argument("--host", default="0.0.0.0",
help="监听地址,默认 0.0.0.0(同一局域网都能打开);只给本机用就写 127.0.0.1")
args = ap.parse_args()
if not os.path.isdir(os.path.join(PROJECT_ROOT, "outputs")):
@@ -52,12 +557,28 @@ def main():
" TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n"
)
wildcard = args.host in ("0.0.0.0", "::")
if not wildcard and args.host not in ("127.0.0.1", "localhost", "::1"):
sys.stderr.write("⚠️ 只监听 %s,仅该地址可达。\n" % args.host)
handler = functools.partial(Handler, directory=PROJECT_ROOT)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
url = "http://%s:%d/dev/theme-builder/" % (args.host, args.port)
socketserver.ThreadingTCPServer.allow_reuse_address = True
# Threading:上传一张几 MB 的图会占住连接,单线程时 iframe 预览和其它请求都得排队。
with socketserver.ThreadingTCPServer((args.host, args.port), handler) as httpd:
httpd.daemon_threads = True
print("项目根:%s" % PROJECT_ROOT)
print("生成器:%s" % url)
if wildcard:
for ip in local_addresses():
print("生成器:http://%s:%d/dev/theme-builder/" % (ip, args.port))
print("生成器:http://127.0.0.1:%d/dev/theme-builder/ (本机)" % args.port)
print("提示:局域网里的其它设备用上面的 IP 地址打开;手机与电脑要在同一网段。")
sys.stderr.write(
"⚠️ 正在对局域网开放(%s:%d)。上传接口没有账号体系 —— 能打开这个端口的人,\n"
" 都能用你填的密钥往对象存储写 / 删文件。公共网络(咖啡馆、宿舍、公司网)\n"
" 请改用 --host 127.0.0.1。\n" % (args.host, args.port))
else:
print("生成器:http://%s:%d/dev/theme-builder/" % (args.host, args.port))
print("上传接口:/api/s3/test /api/s3/upload /api/s3/list /api/s3/delete")
print("Ctrl+C 停止")
try:
httpd.serve_forever()
+219
View File
@@ -0,0 +1,219 @@
"""把 new-sakura 的内联 SVG 导出成位图,并上传到对象存储。
为什么要这一步:主题本身是纯 CSS(SVG 内联在 CSS 里),线上不依赖任何外部素材。
但对象存储上留一份位图有它的用处:
· 桶开了公开域名后,可以把 CSS 里的 url() 换成绝对地址(注意:服务端有 gzip,
内联版总传输 3.9KB 比位图版的 51KB 更小,换位图不为省流量);
· 分享卡 / 其它非 CSS 场景只能用位图;
· 换人维护时不用去读那一长串 data URI。
用法(项目根):
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出,并把 CSS + 位图 + INSTALL.txt 传到桶里
"""
import argparse
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(ROOT, "dev", "theme-builder"))
import gen_new_sakura as G # noqa: E402
OUT_DIR = os.path.join(ROOT, "outputs", "sakura-assets")
# (文件名, svg 源, 导出像素宽, 高)
def assets():
p = G.LIGHT
return [
("branch-right", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False), 840, 640),
("branch-left", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True), 840, 640),
("blossom", G.blossom(p["light"], p["deep"], p["heart"]), 400, 400),
("petal", G.petal(p["light"]), 240, 336),
("pattern", G.pattern(p["pattern"]), 600, 600),
]
def render(svg, w, h, out_png):
"""用无头 Chrome 渲染成透明底 PNG。
走命令行而不是 Playwright:本机只有 node 版 playwright,python 侧没装;
而 `--default-background-color=00000000` 正好能给透明底。
⚠️ `--window-size` 不等于 CSS 视口(实测 1080 只有 985),所以窗口开大一圈
并让内容居中,再按目标尺寸从中心裁 —— 这样尺寸误差被吸收掉,不必去猜差多少。
"""
import subprocess
import tempfile
from PIL import Image
pad = 120
html = (
'<!doctype html><meta charset="utf-8">'
"<style>html,body{margin:0;background:transparent;height:100vh;"
"display:flex;align-items:center;justify-content:center}"
f"svg{{display:block;width:{w}px;height:{h}px}}</style>" + svg
)
fd, html_path = tempfile.mkstemp(suffix=".html")
os.close(fd)
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"
cmd = [
chrome, "--headless=old", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", "--default-background-color=00000000",
"--window-size=%d,%d" % (w + pad, h + pad),
"--screenshot=" + out_png,
"file:///" + html_path.replace("\\", "/"),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
os.remove(html_path)
img = Image.open(out_png).convert("RGBA")
cw, ch = img.size
left, top = max(0, (cw - w) // 2), max(0, (ch - h) // 2)
img.crop((left, top, left + w, top + h)).save(out_png)
def to_webp(png_path, webp_path, quality=92):
from PIL import Image
img = Image.open(png_path).convert("RGBA")
img.save(webp_path, "WEBP", quality=quality, method=6)
def read_r2(path):
cfg = {}
for line in open(path, encoding="utf-8"):
m = re.match(r'^\s*([a-z_]+)\s*=\s*"?([^"\n]*)"?\s*$', line.strip())
if m:
cfg[m.group(1)] = m.group(2).strip()
return cfg
def pack_readme(prefix, object_base, sizes):
"""生成包内 INSTALL.txt。域名与体积都写实数,避免 README 与实际脱节。"""
base = (object_base or "").rstrip("/")
total_kb = sum(v for k, v in sizes.items() if k.endswith(".webp")) / 1024
css_kb = sizes.get("new-sakura.css", 0) / 1024
urls = ""
if base:
urls = "\n公开地址(已验证可匿名 GET)\n--------------------------\n" + "\n".join(
" %s/%s" % (base, n) for n in ["new-sakura.css"] + [
"%s.webp" % k for k in ["branch-right", "branch-left", "blossom", "petal", "pattern"]])
return """new-sakura(新·樱花)主题包
============================
来源:TamaBox 仓库 static/css/themes/new-sakura.css(生成产物,勿手改)
生成:dev/theme-tools/gen_new_sakura.py(花形/位置/配色都在里面)
位图:dev/theme-tools/export_assets.py(从同一份 SVG 导出,不是另画的)
说明:docs/theme-new-sakura.md
对象前缀:%s/
文件
----
new-sakura.css 主题本体 %5.1f KB(纯 CSS,樱花是内联 SVG,零外部请求)
branch-right.webp 花枝 840x640 透明底(细端在右上)
branch-left.webp 同上镜像
blossom.webp 单朵樱花 400x400 透明底
petal.webp 单瓣 240x336 透明底
pattern.webp 平铺底纹 600x600 透明底
位图合计 %5.1f KB
怎么用
------
线上只需要 new-sakura.css:放进仓库 static/css/themes/,在 conf/app.ini 的
[theme] available 里加上 new-sakura,再重新 go build(主题 CSS 是编译期 embed,
只重启不重编不会生效)。
WebP 不是必需品 —— CSS 里的樱花是矢量,比位图清晰且没有额外请求。留着的用处:
1) 素材要单独复用:python dev/theme-tools/gen_new_sakura.py --asset-base %s
(CSS 从 %.1f KB 降到 10.6 KB,但多 5 个请求共 48.9 KB,总传输更大)
2) 非 CSS 场景(分享卡、海报)只能用位图
%s
注意
----
直连桶的 S3 endpoint(*.r2.cloudflarestorage.com)会 403,那里需要签名。
浏览器直读只能用自定义域 / r2.dev 或 CDN。
缓存:位图 1 天、CSS 5 分钟、本文件 60 秒 —— 对象键不带版本,所以**不用** immutable。
要一年长缓存,得把内容哈希写进文件名(届时 CSS 里的地址也要跟着换)。
""" % (prefix, css_kb, total_kb,
base or "https://<公开域名>/" + prefix.strip("/"), css_kb, urls)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--upload", action="store_true", help="导出后上传到 data/r2.txt 里的桶")
ap.add_argument("--prefix", default="themes/new-sakura", help="对象键前缀,默认 themes/new-sakura")
ap.add_argument("--public-base", default="",
help="公开访问的主机(如 https://r2.example.com,不带路径);"
"默认读 data/r2.txt 的 image_public_base 或 image_bucket_cdn_host,"
"都没有则在 INSTALL.txt 里写占位")
ap.add_argument("--r2", default=os.path.join(ROOT, "data", "r2.txt"))
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
files, sizes = [], {}
for name, svg, w, h in assets():
png = os.path.join(OUT_DIR, name + ".png")
webp = os.path.join(OUT_DIR, name + ".webp")
render(svg, w, h, png)
to_webp(png, webp)
os.remove(png)
size = os.path.getsize(webp)
sizes[name + ".webp"] = size
print("导出 %-13s %dx%d %.1f KB" % (name, w, h, size / 1024))
files.append((name + ".webp", webp))
if not args.upload:
print("\n只导出,未上传(加 --upload 才传)。目录:%s" % OUT_DIR)
return
import base64
import serve
raw = read_r2(args.r2)
# 与 conf/app.ini 同名:image_bucket_cdn_host 只是主机名(不带 scheme)
cdn_host = raw.get("image_bucket_cdn_host", "").strip().strip("/")
public_host = (args.public_base or raw.get("image_public_base")
or ("https://" + cdn_host if cdn_host else "")).rstrip("/")
object_base = ("%s/%s" % (public_host, args.prefix.strip("/"))) if public_host else ""
cfg = serve.S3Config(dict(
endpoint=raw["image_endpoint"], region="auto", bucket=raw["image_bucket"],
prefix=args.prefix, pathStyle=True,
accessKey=raw["image_access_id"], secretKey=raw["image_access_secret"],
))
# 键名不带版本,就不配 immutable —— 否则重新导出后一年内谁看到的都还是旧图。
payload = [{"key": name, "name": name, "contentType": "image/webp",
"cacheControl": "public, max-age=86400",
"data": base64.b64encode(open(path, "rb").read()).decode()}
for name, path in files]
css_path = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
css = open(css_path, "rb").read()
sizes["new-sakura.css"] = len(css)
payload.append({"key": "new-sakura.css", "contentType": "text/css; charset=utf-8",
"cacheControl": "public, max-age=300",
"data": base64.b64encode(css).decode()})
readme = pack_readme(args.prefix, object_base, sizes).encode("utf-8")
payload.append({"key": "INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(readme).decode()})
res = serve.upload_files(cfg, payload)
for r in res["files"]:
print(("OK " if r["ok"] else "FAIL ") + r["key"] + ("" if r["ok"] else " " + str(r.get("error"))))
print("\n上传 %d/%d" % (res["total"] - res["failed"], res["total"]))
if object_base:
print("公开地址:%s/" % object_base)
if __name__ == "__main__":
main()
+427
View File
@@ -0,0 +1,427 @@
"""生成 new-sakura 主题的 CSS。
为什么用脚本生成而不用生成器的图层功能:生成器的装饰层只认「图片素材」(导出时
打进 zip 或传对象存储),而这里的花瓣是**内联 SVG 的 data URI** —— 零外部请求、
任意缩放不糊、改配色只改一个色值。代价是 CSS 里会出现一长串百分号编码的 URI,
所以源 SVG 保留在本文件里,改完重跑:
python dev/theme-tools/gen_new_sakura.py
输出:static/css/themes/new-sakura.css
"""
import os
import urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
def uri(svg):
"""转成 CSS url()。safe='' 是刻意的:连逗号、分号、括号都编码掉 ——
`--tb-deco` 是按顶层逗号切分的(internal/theme 的 TestDecoSlotCount),
URI 里留一个裸逗号就会被当成多一层的装饰。"""
return 'url("data:image/svg+xml,' + urllib.parse.quote(svg, safe="") + '")'
# ---- 花与瓣 ----------------------------------------------------------------
# 五瓣樱花:五个椭圆绕中心旋 72°,中心一团深一点的蕊。不做花瓣缺口——
# 在 30~90px 的显示尺寸上缺口只剩 2px,反而像毛边;靠五瓣轮廓就够识别。
def blossom(light, deep, heart):
petals = "".join(
f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288)
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<g fill="{light}">{petals}</g>'
f'<circle cx="50" cy="50" r="7.5" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="3" fill="{heart}"/>'
"</svg>"
)
# 单瓣:顶端留一个 V 形缺口(樱花花瓣的标志),比整朵花更适合当"飘落"的单位。
def petal(fill):
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 56">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{fill}"/>'
"</svg>"
)
# 枝条:一根主枝 + 两根小杈 + 五朵花 + 三片落瓣。
# 花是重复内联而不是 <use href="#f">:data URI 里的片段引用在各浏览器上
# 行为不一致(Safari 曾整朵不渲染),多几百字节换确定性。
def branch(wood, light, deep, heart, flip=False):
fl = ' transform="translate(420,0) scale(-1,1)"' if flip else ""
# 花要比枝小得多、数量多几朵。早先 5 朵 scale≈1 的花配一根细枝,缩到 300px
# 显示时是一串糖葫芦;真实樱花枝是"小碎花沿枝密布",所以花收到 0.42~0.62。
# 第四项是旋转角:一朵朵都朝正上方会像贴纸糊上去的,真实花枝上朝向各异
blossoms = [
(302, 74, 0.62, 14), (256, 110, 0.55, -22), (214, 138, 0.60, 34), (172, 176, 0.58, -6),
(352, 42, 0.50, 48), (140, 214, 0.46, 20), (116, 246, 0.40, -32), (326, 128, 0.42, 28),
]
petals = [(272, 158, 22), (318, 150, -18), (196, 92, 40), (150, 196, -26)]
body = []
for x, y, s, rot in blossoms:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale({s}) translate(-50 -50)">'
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ f'</g><circle cx="50" cy="50" r="7" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="2.8" fill="{heart}"/></g>'
)
for x, y, rot in petals:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale(0.42) translate(-20 -28)">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{light}"/></g>'
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 320">'
f'<g{fl}>'
f'<path d="M2 318C74 276 112 212 176 184c62-28 102-72 146-118 26-28 52-46 82-58" '
f'fill="none" stroke="{wood}" stroke-width="4.2" stroke-linecap="round" opacity=".62"/>'
f'<path d="M176 184c-4-30 4-56 16-78M262 126c16-28 32-48 46-58" '
f'fill="none" stroke="{wood}" stroke-width="2.8" stroke-linecap="round" opacity=".5"/>'
+ "".join(body) + "</g></svg>"
)
# 平铺底纹:极淡的小花 + 落瓣。
# 密度是刻意的:最早一版放了 2 朵花 + 2 片瓣并且 260px 平铺,页面上读出来是
# 「整齐的包装纸」——因为每个元素都在固定格点上重复。现在只留 1 朵 + 2 片,
# 尺寸放大到 300px 且各自错位,平铺时才看不出周期。
def pattern(light, strong=False):
"""平铺底纹。strong 给窄屏用:那里装饰层全关,底纹是屏幕上唯一的樱花,
照桌面那套 0.18~0.34 的透明度在 390px 上基本看不出来(实测截图接近纯粉底)。"""
k = 1.9 if strong else 1.0
one = (
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ "</g>"
)
petal_path = ('<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5'
'c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="%s"/>')
o = lambda v: min(0.92, round(v * k, 2))
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">'
f'<g transform="translate(96 84) scale(0.34) translate(-50 -50)" opacity="{o(0.34)}">{one}</g>'
f'<g transform="translate(236 208) rotate(28) scale(0.26)" opacity="{o(0.24)}">'
+ (petal_path % light) +
"</g>"
f'<g transform="translate(188 52) rotate(-34) scale(0.2)" opacity="{o(0.18)}">'
+ (petal_path % light) +
"</g>"
"</svg>"
)
# ---- 一套素材(浅色 / 深色各一组色) ---------------------------------------
LIGHT = dict(
light="#f7bcd0", deep="#e989aa", heart="#fdf6f8",
wood="#9a7a70", pattern="#f2bfd0",
)
DARK = dict(
light="#f0a0b8", deep="#c96a8b", heart="#2c2227",
wood="#a8837a", pattern="#7a4e5f",
)
def uris(p, asset_base=""):
"""asset_base 非空 -> 引用对象存储上的位图(键名与 export_assets.py 的产物一致)。
内联 SVG 是默认:gzip 后约 3.9KB、零额外请求。位图版 CSS 只有 10.6KB,但要多个
5 个请求共 ~49KB,总传输反而更大 —— 它存在的理由是素材能被非 CSS 场景复用。
换之前先确认桶已对公网可读(自定义域或 r2.dev),否则主题会整片 403。
"""
if asset_base:
b = asset_base.rstrip("/")
return {
"blossom_l": 'url("%s/blossom.webp")' % b,
"petal_l": 'url("%s/petal.webp")' % b,
"branch_l": 'url("%s/branch-left.webp")' % b,
"branch_r": 'url("%s/branch-right.webp")' % b,
"pattern": 'url("%s/pattern.webp")' % b,
"pattern_narrow": 'url("%s/pattern.webp")' % b,
}
return {
"blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])),
"petal_l": uri(petal(p["light"])),
"branch_l": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True)),
"branch_r": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False)),
"pattern": uri(pattern(p["pattern"])),
"pattern_narrow": uri(pattern(p["pattern"], strong=True)),
}
NONE10 = "none, none, none, none, none, none, none, none, none, none"
AUTO10 = "auto, auto, auto, auto, auto, auto, auto, auto, auto, auto"
TOPLEFT10 = ("left top, left top, left top, left top, left top, left top, left top, "
"left top, left top, left top")
def deco(layers):
"""layers: [(var, size, position)],不足 10 槽补 none。"""
items, sizes, poss = [], [], []
for var, size, pos in layers:
items.append(var)
sizes.append(size)
poss.append(pos)
pad = 10 - len(layers)
items += ["none"] * pad
sizes += ["auto"] * pad
poss += ["left top"] * pad
return (", ".join(items), ", ".join(sizes), ", ".join(poss))
# ---- 每个页面一套位置 ------------------------------------------------------
# 位置一律两值(`关键字 ± 偏移`)。百分比相对「容器 − 图片」,写 calc 更可控;
# 这里用 left/right + % 与 px 偏移,保证换视口时花还贴在角落而不是飘到内容上。
SCOPES = {
"global": [
("var(--sk-branch-r)", "auto 280px", "right -16px bottom -22px"),
("var(--sk-blossom)", "auto 88px", "left 6% top 12%"),
("var(--sk-petal)", "32px", "left 14% top 24%"),
("var(--sk-petal)", "26px", "right 16% top 8%"),
],
"box": [
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
# 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题)
("var(--sk-branch-r)", "auto 300px", "right -24px top -16px"),
("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"),
("var(--sk-petal)", "34px", "left 14% bottom 32%"),
("var(--sk-petal)", "24px", "right 22% bottom 8%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 6%"),
],
"question": [
("var(--sk-branch-l)", "auto 300px", "left -34px bottom -24px"),
("var(--sk-blossom)", "auto 86px", "right 7% top 11%"),
("var(--sk-petal)", "30px", "right 17% top 24%"),
("var(--sk-petal)", "24px", "left 11% top 7%"),
],
"admin": [
# 列表页卡片几乎占满宽度,右侧的花会整个被卡片盖住 —— 只往左侧留白放
("var(--sk-branch-l)", "auto 260px", "left -28px top -24px"),
("var(--sk-blossom)", "auto 70px", "left 3% bottom 7%"),
("var(--sk-petal)", "26px", "left 11% bottom 15%"),
],
"profile": [
("var(--sk-branch-r)", "auto 280px", "right -28px bottom -24px"),
("var(--sk-blossom)", "auto 66px", "left 6% top 9%"),
("var(--sk-petal)", "28px", "left 15% top 19%"),
("var(--sk-petal)", "22px", "left 5% top 28%"),
],
"vote": [
("var(--sk-branch-l)", "auto 280px", "left -30px top -20px"),
("var(--sk-blossom)", "auto 78px", "right 8% top 16%"),
("var(--sk-petal)", "28px", "right 18% top 27%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 8%"),
],
# 窄屏:卡片占满宽度,左右只剩 ~16px 边距,花枝放哪儿都糊在字上。
# 实测底纹铺在 16px 边距里根本落不到花 —— 花朵在格子里的位置固定在 x≈58,
# 所以只在「顶栏与卡片之间」那条 30px 的横带里放几朵小的,其余交给加强底纹。
# 试过在「顶栏与卡片之间」那条 ~35px 的带子里放小樱花:box 页卡片从 y=122 起还行,
# question/admin 页卡片从 y=110 起就把它切掉半个,结果是贴在卡片边上的粉色碎块。
# 手机上没有可靠的留白,干脆不放装饰层 —— 观感交给配色与底纹(加强版 pattern)。
"narrow": [],
}
def scope_block(prefix, layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{prefix} {{\n"
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
"}"
)
def build(asset_base=""):
u = uris(LIGHT, asset_base)
d = uris(DARK, asset_base)
vars_light = "\n".join([
f" --sk-blossom: {u['blossom_l']};",
f" --sk-petal: {u['petal_l']};",
f" --sk-branch-l: {u['branch_l']};",
f" --sk-branch-r: {u['branch_r']};",
])
vars_dark = "\n".join([
f" --sk-blossom: {d['blossom_l']};",
f" --sk-petal: {d['petal_l']};",
f" --sk-branch-l: {d['branch_l']};",
f" --sk-branch-r: {d['branch_r']};",
])
light_block = f"""html[data-theme-style="new-sakura"] {{
--tb-bg: #fdf6f8;
--tb-surface: #ffffff;
--tb-surface-2: #fdf0f4;
--tb-surface-hover: #fff7fa;
--tb-navbar: #fdf6f8;
--tb-border: #f3dde5;
--tb-text: #3a2c31;
--tb-text-secondary: #6d555d;
/* 5.27:1(底色)/ 5.62:1(卡片),踩住 AA 下限 */
--tb-text-muted: #77626a;
/* #c14a72 在粉底上只有 4.39:1,同色相压深到 #b8436a = 4.87:1 */
--tb-primary: #b8436a;
--tb-primary-weak: rgba(184, 67, 106, 0.12);
--tb-on-primary: #ffffff;
--tb-shadow: 0 1px 2px rgba(150, 80, 105, 0.05), 0 12px 28px -18px rgba(150, 80, 105, 0.22);
--tb-shadow-hover: 0 1px 2px rgba(150, 80, 105, 0.07), 0 18px 40px -20px rgba(150, 80, 105, 0.30);
/* 花与瓣都是内联 SVG(data URI),没有外部素材 —— 所以这个主题不需要对象存储。
--sk-* 是花材的别名:同一个 URI 只在这里出现一次,下面各页面作用域引用变量,
否则 6 套位置会把 4 个 data URI 重复 20 多次,文件直接翻倍。 */
{vars_light}
/* 整页底纹:极淡的花与落瓣,260px 平铺。压在白底上只是一层纸纹,
不参与构图(真正的樱花由下面的装饰层负责)。 */
--tb-page-image: none;
--tb-page-pattern: {u['pattern']};
--tb-page-pattern-size: 300px 300px;
"""
# 全局默认位置:写在同一个块里(未单独配置的页面沿用这套)
body = light_block + deco_block_inline(SCOPES["global"]) + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
body += "\n" + scope_block(
f'html[data-theme-style="new-sakura"][data-page-scope="{key}"]', SCOPES[key]) + "\n"
dark_block = f"""
html[data-theme="dark"][data-theme-style="new-sakura"] {{
--tb-bg: #221a1e;
--tb-surface: #2c2227;
--tb-surface-2: #372a30;
--tb-surface-hover: rgba(255, 147, 180, 0.10);
--tb-navbar: #221a1e;
--tb-border: #47333b;
--tb-text: #f3e6ea;
--tb-text-secondary: #c9aeb8;
/* 5.82:1(底色)/ 5.26:1(卡片) */
--tb-text-muted: #ab9099;
--tb-primary: #ff93b4;
--tb-primary-weak: rgba(255, 147, 180, 0.16);
--tb-on-primary: #1b1014;
--tb-shadow: 0 1px 2px rgba(0, 0, 0, 0.34), 0 12px 28px -18px rgba(0, 0, 0, 0.58);
--tb-shadow-hover: 0 1px 2px rgba(0, 0, 0, 0.40), 0 18px 40px -20px rgba(0, 0, 0, 0.68);
{vars_dark}
/* 深色不铺底纹:浅粉花瓣压在深底上会变成一层噪点 */
--tb-page-image: linear-gradient(180deg, #251c21 0%, #221a1e 62%, #1e171b 100%);
--tb-page-pattern: none;
""" + deco_block_inline(SCOPES["global"], indent=" ") + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
dark_block += "\n" + scope_block(
f'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="{key}"]',
SCOPES[key]) + "\n"
# 作用域块的选择器是 html[data-theme-style][data-page-scope="box"](特异性 0,2,1),
# 比 html[data-theme-style] (0,1,1)高 —— 光写一条压不住,窄屏照样是桌面版装饰。
# 所以用同等特异性的通配属性选择器再写一遍,靠「在文件里更晚」取胜;
# 深色作用域块是 0,3,1,还得再补一条。
narrow_rules = ""
for sel in (
'html[data-theme-style="new-sakura"][data-page-scope]',
'html[data-theme="dark"][data-theme-style="new-sakura"]',
'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope]',
):
narrow_rules += " %s {\n%s }\n" % (
sel, deco_block_inline(SCOPES["narrow"], indent=" "))
narrow = (
"\n@media (max-width: 640px) {\n"
' html[data-theme-style="new-sakura"] {\n'
+ deco_block_inline(SCOPES["narrow"], indent=" ")
+ " /* 手机上装饰层整个收掉(卡片占满宽度),樱花只能由底纹承担:\n"
" 换成加强版 + 缩小格子,否则 390px 上看就是一片纯粉 */\n"
" --tb-page-pattern: %s;\n" % u["pattern_narrow"]
+ " --tb-page-pattern-size: 180px 180px;\n"
" }\n"
+ narrow_rules
+ "}\n"
)
return HEADER + body + dark_block + narrow + FOOTER
def deco_block_inline(layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
)
HEADER = """/* 新·樱花(new-sakura):纯 CSS 樱花,不依赖任何图片素材。
和已有的 sakura 主题的区别:sakura 是"粉底 + 网点"的静态配色;这里把樱花本身
做成了可摆放的装饰层 —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG
的 data URI,所以:换颜色只改一个色值、任意视口不糊、没有额外请求、不需要对象存储。
每个页面一套位置(--tb-deco / -size / -position 三组各 10 槽,必须等长):
global 未单独配置的页面 右下花枝 + 左上两瓣
box 箱主页 右上花枝垂下 + 左下花与落瓣
question 问题页 左下花枝 + 右上花与落瓣
admin 题目列表 左上花枝(只压顶部边距,不抢列表)
profile 设置页 右下花枝 + 左侧竖排小瓣
vote 投票页 左上花枝 + 右侧花与落瓣
≤640px 一律只留角落两朵小的 —— 卡片占满宽度后枝条会糊在字上
位置一律「两值 + 关键字」,不用三值语法(三值里混用 left/bottom 关键字是非法组合,
整条声明会作废,花瓣会静默回到左上角)。
生成:python dev/theme-tools/gen_new_sakura.py(改花形/位置请改那个脚本,别手改这里)
*/
"""
FOOTER = """
/* 组件微调(仅外观):顶栏与粉底融在一起后,UIkit 默认的 #999 链接色偏淡 */
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a {
color: var(--tb-text-secondary);
}
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a:hover {
color: var(--tb-primary);
}
/* 信息提示条:UIkit 的浅蓝在樱粉底上是唯一的冷色块,换成主色的弱化底 */
html[data-theme-style="new-sakura"] .uk-alert-primary {
background-color: var(--tb-primary-weak);
color: #9c3356;
}
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
color: #ffb3c8;
}
"""
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="生成 new-sakura 主题 CSS")
ap.add_argument("--asset-base", default="",
help="引用对象存储位图的前缀,如 https://r2.example.com/themes/new-sakura;"
"留空 = 内联 SVG(默认)")
ap.add_argument("--out", default=OUT, help="输出路径")
args = ap.parse_args()
css = build(args.asset_base)
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
f.write(css)
print("已生成 %s(%.1f KB)%s" % (args.out, len(css.encode()) / 1024,
" [位图版]" if args.asset_base else " [内联 SVG]"))
+40 -17
View File
@@ -1,4 +1,9 @@
# 把主题素材放到对象存储(R2 / OSS)
# 把主题素材放到对象存储(任意 S3 兼容)
这里的「对象存储」不特指某一家:Cloudflare R2、自建 MinIO、Backblaze B2、
阿里云 OSS 的 S3 兼容端点、腾讯云 COS 的 S3 兼容端点都走同一套 SigV4,
差别只在 Endpoint、Region、是否路径风格。**放本地还是放对象存储,由部署者自己决定** ——
主题生成器里「素材托管」就是这两选一。
现状:主题素材在 `static/images/themes/<slug>/`,通过 `static/fs.go` 的 `//go:embed images/themes`
随二进制发布。好处是部署即生效、不依赖外部服务;代价是**换一张图就要重新编译部署**,
@@ -12,32 +17,49 @@
---
## A. 生成器直接写绝对地址(已支持,推荐)
## A. 生成器直接写绝对地址 + 就近上传(已支持,推荐)
`dev/theme-builder` 的导出面板新增「**素材前缀**」:
`dev/theme-builder` 左侧「**素材托管**」有两个选项:本地(随二进制)/ S3 兼容存储。
选 S3 后填:
```
素材前缀 https://cdn.example.com/themes
Endpoint https://<账号>.r2.cloudflarestorage.com ← MinIO / B2 / OSS / COS 填各自的
Region auto ← R2 用 auto;MinIO 常为 us-east-1
桶名 my-theme-assets
路径前缀 themes
公开前缀 https://cdn.example.com/themes ← 留空 = 按 Endpoint 推导
Access Key / Secret Key
```
留空 = 沿用 `/static/images/themes/<slug>/`(图片随二进制发布,和以前一样)。
填了 = 导出的 CSS 里所有图片地址变成 `https://cdn.example.com/themes/<slug>/<id>.webp`。
**前缀里不要带 slug**,slug 由导出时自动拼在后面。
然后:
图片照旧打进 zip(`static/images/themes/<slug>/*.webp`),只是用途从「放进仓库」变成
「传到对象存储的 `<前缀>/<slug>/` 下」。INSTALL 文案会跟着变,直接写清楚往哪传。
1. **测试连接** —— `HEAD` 桶(连通+凭据)→ `PUT` 探针(写权限)→ `GET` 读回(内容一致)
→ 匿名 `GET`(能不能被浏览器直接读到)→ 删除探针。失败会把 S3 的 `<Code>` 翻成人话。
2. **上传素材** —— 逐张 PUT 本次主题引用到的图,键为 `<路径前缀>/<slug>/<id>.webp`,
带 `public, max-age=31536000, immutable`。成功后自动把导出用的「素材前缀」填好。
3. **检测远端** —— `ListObjectsV2` 列出 `<前缀>/<slug>/` 下所有对象,按对象键名跟本地比:
一致 / 旧图(同名但大小不同)/ 多余(当前主题已不引用)/ 缺失。
可以「补齐差异」(只传缺的与旧图)、「删除多余」,也可以单行覆盖或删除。
上传经本机 `serve.py` 代理(`/api/s3/test`、`/api/s3/upload`),浏览器不直连对象存储:
**不需要给桶配 CORS**,密钥只在当前标签页的 sessionStorage 与内存里,
不进草稿、不进工程 json、不进导出 zip。
不想用面板也行,导出的 zip 里图片照旧在 `static/images/themes/<slug>/*.webp`,
用任何 S3 客户端推上去即可:
```bash
aws s3 sync static/images/themes/shiroko/ s3://my-bucket/themes/shiroko/ \
--endpoint-url https://<你的 Endpoint> --cache-control "public, max-age=31536000, immutable"
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
static/css/themes/shiroko.css # 已有主题不想重导时,等价于「填前缀再导一次」
```
- 优点:零服务端改动、零跳转、缓存最优(CDN 直接命中);
- 缺点:域名烘焙进 CSS —— 换域名/换路径要**重新导出**(旧主题可以用 `sed` 批量替换);
- 仓库里可以删掉 `static/images/themes/<slug>/` 来缩小编译产物,**确认 CSS 已是绝对地址**再删。
命令行等价做法(已有主题不想重导):
```bash
sed -i 's#/static/images/themes/shiroko/#https://cdn.example.com/themes/shiroko/#g' \
static/css/themes/shiroko.css
```
---
## B. 应用内 301 跳转(配置驱动,CSS 一行都不用改)
@@ -107,6 +129,7 @@ func themeAssetRedirect() flamego.Handler {
## 建议
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器填前缀,最省事,缓存也最好)。
- 只是想让部署产物变小、换图不用重编译 → **A**(生成器里配好 S3、测通、一键上传,最省事,缓存也最好)。
- 想保留「素材可以随时换、CSS 不用重发」的灵活性 → **B**(配一个 `asset_base`,接受多一跳)。
- 站点已经在 Cloudflare 上、且愿意在边缘配规则 → **C**(最干净,但迁移成本在运维侧)。
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。
+171
View File
@@ -0,0 +1,171 @@
# new-sakura(新·樱花):纯 CSS 樱花主题
和已有的 `sakura` 的区别:`sakura` 是「粉底 + 网点」的静态配色;`new-sakura` 把樱花本身
做成了**可摆放的装饰层** —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG 的
`data:image/svg+xml` URI,写在主题 CSS 里。
因此这个主题:
- **不需要任何图片素材**,也不依赖对象存储(`static/images/themes/new-sakura/` 是空的);
- 任意视口缩放都不糊(矢量),DPI 无关;
- 换花色只改 `dev/theme-tools/gen_new_sakura.py` 里的一个色值,重跑即得;
- 没有额外 HTTP 请求 —— SVG 就在 CSS 里。
代价是 CSS 有 54KB(服务端有 gzip,实际传输约 4KB)。主题 CSS 走 `StaticVersion` 缓存,一次性开销。
## 结构
```
static/css/themes/new-sakura.css ← 生成产物,别手改
dev/theme-tools/gen_new_sakura.py ← 改这里,重跑
```
```bash
python dev/theme-tools/gen_new_sakura.py
```
生成器脚本里有三样东西:花形(`blossom` / `petal` / `branch` / `pattern`)、
每个页面作用域的位置表(`SCOPES`)、以及配色(`LIGHT` / `DARK`)。
## 每个页面的樱花位置
`--tb-deco` / `--tb-deco-size` / `--tb-deco-position` 三组各 10 槽,必须等长
(`TestDecoSlotCount` 会逐文件逐声明检查)。
| 作用域 | 选择器 | 构图 |
| --- | --- | --- |
| global | `html[data-theme-style="new-sakura"]` | 右下花枝 + 左上大花 + 两片落瓣 |
| box | `…[data-page-scope="box"]` | 右上花枝垂下(细端在外、粗端朝内)+ 左下花与落瓣 |
| question | `…[data-page-scope="question"]` | 左下花枝斜穿 + 右上大花 + 落瓣 |
| admin | `…[data-page-scope="admin"]` | 左上花枝 + 左侧底部小花(卡片占满宽度,右侧会被盖住) |
| profile | `…[data-page-scope="profile"]` | 右下花枝 + 左侧竖排小花与落瓣 |
| vote | `…[data-page-scope="vote"]` | 左上花枝 + 右侧花与落瓣 |
位置一律「关键字 + 偏移」两值写法(`right -24px top -16px`)。三值语法里混用
`left` / `bottom` 关键字是非法的,整条声明会作废、花瓣静默回到左上角。
### 窄屏(≤640px)
装饰层全部置 `none`:卡片占满宽度,左右只剩 ~16px 边距,枝条放哪儿都会糊在字上。
樱花改由底纹承担 —— `@media` 里换成**加强版 pattern**(透明度 ×1.9)并把格子收到 180px;
桌面那套 0.18~0.34 的透明度在 390px 上实测接近一片纯粉。
试过在「顶栏与卡片之间」那条 35px 的带子里放两朵小樱花:box 页卡片从 y=122 起还行,
question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片边上的粉色碎块。手机上没有
可靠的留白,就不放装饰层 —— 观感交给配色与底纹。
⚠️ **窄屏覆盖必须重新声明每个作用域选择器**:`@media` 不提升特异性。
作用域块是 `html[data-theme-style][data-page-scope="box"]`(0,2,1),而媒体查询里常见的
`html[data-theme-style]`(0,1,1)根本压不住它 —— 表现是「手机上装饰层没关掉,桌面版照样铺开」。
深色作用域块更是 0,3,1,还得再补一条 `[data-theme="dark"][data-theme-style][data-page-scope]`。
`TestNarrowMediaOverridesScopedDeco` 会在这里把关:外层每一处非 none 的 `--tb-deco`,
媒体查询里都要求有同属性名、属性数不少于它的选择器再声明一次。
### 深色
- 花瓣换成更亮一档的粉(`#f0a0b8`),枝条提亮到能看见;
- 底纹 `--tb-page-pattern: none`:浅粉花瓣压在深底上会变成一层噪点;
- 整页背景改深色渐变(不用纯色,避免大面积死黑)。
深色块必须写成 `html[data-theme="dark"][data-theme-style="new-sakura"]`(三重选择器),
只写浅色块会被同特异性的作用域块盖掉。
## 配色与对比度
| 令牌 | 亮色 | 深色 | 说明 |
| --- | --- | --- | --- |
| `--tb-bg` | `#fdf6f8` | `#221a1e` | 纯色,不用渐变(整页色感一致) |
| `--tb-surface` | `#ffffff` | `#2c2227` | 卡片 |
| `--tb-text` | `#3a2c31` (12.4:1) | `#f3e6ea` (14.0:1) | |
| `--tb-text-secondary` | `#6d555d` (6.3:1) | `#c9aeb8` (8.3:1) | |
| `--tb-text-muted` | `#77626a` (5.3:1) | `#ab9099` (5.8:1) | 踩 AA 下限 |
| `--tb-primary` | `#b8436a` (4.9:1) | `#ff93b4` (8.2:1) | 原候选 `#c14a72` 只有 4.39:1,压深 |
| `--tb-on-primary` | `#ffffff` (5.2:1) | `#1b1014` (8.9:1) | 主色按钮上的字 |
## 素材包(可选,已传到对象存储)
主题线上**不需要**任何素材:樱花是内联矢量。但同一个花形也导出成了位图,
放在对象存储上备用(`themes/new-sakura/`,共 7 个对象):
| 键 | 内容 |
| --- | --- |
| `new-sakura.css` | 主题本体(备份,便于分发) |
| `branch-right.webp` / `branch-left.webp` | 花枝 840×640 透明底(互为镜像) |
| `blossom.webp` | 单朵樱花 400×400 透明底 |
| `petal.webp` | 带缺口的花瓣 240×336 透明底 |
| `pattern.webp` | 平铺底纹 600×600 透明底 |
| `INSTALL.txt` | 包内说明 |
重新导出 / 上传(`--upload` 会一起传 CSS、位图和生成的 `INSTALL.txt`):
```bash
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出并上传,读 data/r2.txt
```
`data/r2.txt` 是部署凭据(`image_endpoint` / `image_access_id` / `image_access_secret` /
`image_bucket` / `image_bucket_cdn_host`),**不进版本库**(`.gitignore` 里的 `/data/`)。
`image_bucket_cdn_host` 与 `conf/app.ini` 同名同义:只写主机名,不带 `https://` 和路径;
没有它时 `INSTALL.txt` 里的公开地址会写成占位符。
缓存策略:位图 `max-age=86400`、CSS 镜像 `300`、`INSTALL.txt` `60`。
这些键**都不带内容版本**,所以刻意不用 `immutable` —— 否则重新导出后,浏览器与 CDN
一年内拿到的都还是旧图。想要一年长缓存,得把内容哈希写进文件名(CSS 里的地址跟着换)。
⚠️ 若某个键曾经以 `immutable` 传过(本主题第一版就是这样),CDN 会一直给旧副本,
改完必须去控制台按 URL 清一次缓存,光重传没用。
### 位图版(引用对象存储)
桶绑了自定义域 `r2.337845818.best`(实测匿名 GET 200、`image/webp`、大小一致),
所以可以把 CSS 从内联切到位图:
```bash
python dev/theme-tools/gen_new_sakura.py \
--asset-base https://r2.337845818.best/themes/new-sakura \
--out static/css/themes/new-sakura.css
```
| 版本 | CSS | gzip 后 | 额外请求 | 清晰度 |
| --- | --- | --- | --- | --- |
| 内联 SVG(默认) | 53.8KB | **4.0KB** | 0 | 矢量,任意 DPR |
| 位图 | 11.8KB | 2.8KB | 5 个 webp,共 48.9KB | 2 倍图 |
服务端挂了 gzip(`internal/route/gzip.go`),所以内联版**总传输 4.0KB**,比位图版的
2.8 + 48.9 = 51.7KB 小一个数量级 —— 换位图省不了流量,只是把开销从 CSS 挪到图片。
位图版唯一正当的理由是**素材要单独复用**(分享卡、海报、其它系统)。
⚠️ 直接连桶的 S3 endpoint(`*.r2.cloudflarestorage.com`)会 403,**只有自定义域/r2.dev 可匿名读**。
切之前先 `curl -I` 验一下前缀;切完 `go build` 部署,否则 embed 里还是旧的。
## 为什么不用主题生成器出图
生成器的装饰层只支持「图片素材」(导出时打进 zip 或传对象存储),没有内联 SVG 图层。
要把樱花做成可摆放的图层,只能在设计上二选一:
1. 生成图片素材(花瓣 PNG/WebP)→ 可以用生成器、可以放 S3,但会有位图缩放、额外请求;
2. 内联 SVG 写进 CSS → 零素材、任意缩放不糊,但生成器管不到。
`new-sakura` 选 2,因为「用 CSS 做樱花」本身就是这个主题的诉求。
生成器仍然可以用来**预览**它:`?demo=1&fresh=1` 起画板,把 `new-sakura.css` 注入即可。
## 怎么验证
```bash
go test ./internal/theme # 10 槽等长、深浅块成对、必需令牌齐全、窄屏覆盖特异性
```
视觉自检用项目真实页面快照:
```bash
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
python dev/theme-builder/serve.py
# 打开 outputs/preview-box-light.html,DevTools 里给 <html> 加
# data-theme-style="new-sakura" data-page-scope="box"
```
## 上线
1. `conf/app.ini` 的 `[theme] available` 加上 `new-sakura`(不改就「设置页看不到」,现象和部署没生效一样);
2. 重新 `go build` —— 主题 CSS 是编译期 embed,只重启不重编不会变;
3. 箱主设置页 → 账号 → 外观,选择「新·樱花」。
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/tamakyi/TamaBox
go 1.25.0
go 1.27.3
replace golang.org/x/image => golang.org/x/image v0.13.0
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 tamakyi. 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
import (
"strings"
"time"
"github.com/sirupsen/logrus"
)
// announcementConfig 站内公告([announcement] 段)。
//
// Content 留空即关闭公告;Start / End 留空表示该侧不限。
// 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失,
// 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。
type announcementConfig struct {
Content string `ini:"content"`
Start string `ini:"start"`
End string `ini:"end"`
// 解析后的时间点,零值表示该侧不限。
// ini tag 为 "-":既不参与 ini 解析,也不会被未知键检测登记为合法键名。
StartAt time.Time `ini:"-"`
EndAt time.Time `ini:"-"`
}
// 支持的时间格式。裸时间(不带时区偏移)按服务器本地时区解释。
var announcementTimeLayouts = []string{
"2006-01-02 15:04",
"2006-01-02 15:04:05",
"2006-01-02",
time.RFC3339,
}
// Active 判断给定时刻是否处于公告展示窗口,并返回公告正文。
// 窗口为左闭右开 [Start, End),两侧留空表示不限。
func (a announcementConfig) Active(now time.Time) (string, bool) {
if a.Content == "" {
return "", false
}
if !a.StartAt.IsZero() && now.Before(a.StartAt) {
return "", false
}
if !a.EndAt.IsZero() && !now.Before(a.EndAt) {
return "", false
}
return a.Content, true
}
// sanitizeAnnouncement 解析公告时间窗。
//
// 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站
// 502;但必须留下能定位的日志 —— 否则现象就是「配了却不显示」,和未知键被静默
// 忽略属于同一类坑(见 unknown.go)。
func sanitizeAnnouncement() {
Announcement.Content = strings.TrimSpace(Announcement.Content)
if Announcement.Content == "" {
return
}
disable := func(key, value, reason string) {
logrus.WithFields(logrus.Fields{
"section": "announcement",
"key": key,
"value": value,
"reason": reason,
}).Warn("公告配置无效,已关闭公告(站点其它功能不受影响)")
Announcement = announcementConfig{}
}
var ok bool
if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start); !ok {
disable("start", Announcement.Start, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
return
}
if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End); !ok {
disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339")
return
}
if !Announcement.StartAt.IsZero() && !Announcement.EndAt.IsZero() && !Announcement.EndAt.After(Announcement.StartAt) {
disable("end", Announcement.End, "end 必须晚于 start,否则公告永远不会展示")
return
}
_, activeNow := Announcement.Active(time.Now())
logrus.WithFields(logrus.Fields{
"start": announcementBoundLabel(Announcement.StartAt),
"end": announcementBoundLabel(Announcement.EndAt),
"zone": time.Local.String(),
"active_now": activeNow,
}).Info("站内公告已配置")
}
// parseAnnouncementTime 解析单个时间点;空串返回零值(表示不限)且视为合法。
func parseAnnouncementTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, true
}
for _, layout := range announcementTimeLayouts {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return t, true
}
}
return time.Time{}, false
}
// announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对
// 「我写的 10:00 到底被当成了几点」——裸时间按服务器本地时区解释,容器多为 UTC。
func announcementBoundLabel(t time.Time) string {
if t.IsZero() {
return "不限"
}
return t.Format("2006-01-02 15:04:05 -07:00")
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright 2026 tamakyi. 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
import (
"testing"
"time"
)
// setAnnouncement 在测试期间覆盖全局公告配置,结束后恢复。
func setAnnouncement(t *testing.T, content, start, end string) {
t.Helper()
old := Announcement
t.Cleanup(func() { Announcement = old })
Announcement = announcementConfig{Content: content, Start: start, End: end}
sanitizeAnnouncement()
}
func mustParse(t *testing.T, layout, value string) time.Time {
t.Helper()
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return parsed
}
func TestAnnouncementDisabledWhenContentEmpty(t *testing.T) {
setAnnouncement(t, " ", "2026-10-24 00:00", "2026-10-26 00:00")
if _, ok := Announcement.Active(time.Now()); ok {
t.Fatal("content 为空时公告应关闭")
}
}
func TestAnnouncementWindowIsHalfOpen(t *testing.T) {
setAnnouncement(t, "维护通知", "2026-10-24 00:00", "2026-10-26 00:00")
start := mustParse(t, "2006-01-02 15:04", "2026-10-24 00:00")
end := mustParse(t, "2006-01-02 15:04", "2026-10-26 00:00")
cases := []struct {
name string
now time.Time
want bool
}{
{"开始前一秒", start.Add(-time.Second), false},
{"开始时刻(闭区间)", start, true},
{"窗口内", start.Add(12 * time.Hour), true},
{"结束时刻(开区间)", end, false},
{"结束后", end.Add(time.Hour), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
content, ok := Announcement.Active(c.now)
if ok != c.want {
t.Fatalf("Active(%s) = %v, want %v", c.now, ok, c.want)
}
if ok && content != "维护通知" {
t.Fatalf("content = %q, want 维护通知", content)
}
})
}
}
// 只写一侧的边界必须保持「该侧不限」,不能因为另一侧为空就整体失效。
func TestAnnouncementOneSidedWindow(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.Local)
setAnnouncement(t, "只有开始", "2026-10-23 00:00", "")
if _, ok := Announcement.Active(now); ok {
t.Fatal("只写 start 且尚未到点时应不展示")
}
if _, ok := Announcement.Active(now.Add(48 * time.Hour)); !ok {
t.Fatal("只写 start 时到点后应一直展示")
}
setAnnouncement(t, "只有结束", "", "2026-10-23 00:00")
if _, ok := Announcement.Active(now); !ok {
t.Fatal("只写 end 且未到点时应立即展示")
}
if _, ok := Announcement.Active(now.Add(48 * time.Hour)); ok {
t.Fatal("只写 end 时过点后应下线")
}
setAnnouncement(t, "一直展示", "", "")
if _, ok := Announcement.Active(now.AddDate(5, 0, 0)); !ok {
t.Fatal("起止都留空时应一直展示")
}
}
func TestAnnouncementTimeFormats(t *testing.T) {
// 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层本地时区。
at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00")
if !ok {
t.Fatal("RFC3339 应可解析")
}
want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC)
if !at.Equal(want) {
t.Fatalf("解析结果 = %s, want %s", at.UTC(), want)
}
if at, ok := parseAnnouncementTime("2026-10-25"); !ok || at.Hour() != 0 {
t.Fatalf("纯日期应解析为当天 00:00,got %s ok=%v", at, ok)
}
if at, ok := parseAnnouncementTime("2026-10-25 02:00:59"); !ok || at.Second() != 59 {
t.Fatalf("秒级格式应可解析,got %s ok=%v", at, ok)
}
if _, ok := parseAnnouncementTime("2026/10/25 02:00"); ok {
t.Fatal("错误格式应解析失败")
}
if _, ok := parseAnnouncementTime("2026-13-01"); ok {
t.Fatal("非法月份应解析失败")
}
}
// 时间写错只关公告,不能让站点起不来;同时必须清空解析结果,避免残留时间窗。
func TestAnnouncementInvalidTimeDisables(t *testing.T) {
for _, c := range []struct{ name, start, end string }{
{"start 格式错", "2026/10/25 02:00", ""},
{"end 格式错", "", "10月26日"},
{"end 早于 start", "2026-10-26 00:00", "2026-10-25 00:00"},
{"end 等于 start", "2026-10-25 00:00", "2026-10-25 00:00"},
} {
t.Run(c.name, func(t *testing.T) {
setAnnouncement(t, "内容", c.start, c.end)
if Announcement.Content != "" {
t.Fatal("配置无效时公告应被关闭")
}
if !Announcement.StartAt.IsZero() || !Announcement.EndAt.IsZero() {
t.Fatal("配置无效时应清空已解析的时间点")
}
})
}
}
+5
View File
@@ -102,6 +102,11 @@ func Init() error {
return errors.Wrap(err, "map 'theme'")
}
if err := File.Section("announcement").MapTo(&Announcement); err != nil {
return errors.Wrap(err, "map 'announcement'")
}
sanitizeAnnouncement()
if err := File.Section("mail").MapTo(&Mail); err != nil {
return errors.Wrap(err, "map 'mail'")
}
+13 -12
View File
@@ -143,18 +143,19 @@ func legalKeysOf(v interface{}) map[string]bool {
// 枚举,为避免误报这里不登记(漏报优于误报)。
func sectionKeySets() map[string]map[string]bool {
return map[string]map[string]bool{
"app": legalKeysOf(&App),
"theme": legalKeysOf(&Theme),
"security": legalKeysOf(&Security),
"server": legalKeysOf(&Server),
"database": legalKeysOf(&Database),
"redis": legalKeysOf(&Redis),
"recaptcha": legalKeysOf(&Recaptcha),
"pixel": legalKeysOf(&Pixel),
"upload": legalKeysOf(&Upload),
"limits": legalKeysOf(&Limits),
"mail": legalKeysOf(&Mail),
"service": legalKeysOf(&Service),
"app": legalKeysOf(&App),
"theme": legalKeysOf(&Theme),
"announcement": legalKeysOf(&Announcement),
"security": legalKeysOf(&Security),
"server": legalKeysOf(&Server),
"database": legalKeysOf(&Database),
"redis": legalKeysOf(&Redis),
"recaptcha": legalKeysOf(&Recaptcha),
"pixel": legalKeysOf(&Pixel),
"upload": legalKeysOf(&Upload),
"limits": legalKeysOf(&Limits),
"mail": legalKeysOf(&Mail),
"service": legalKeysOf(&Service),
}
}
+4 -1
View File
@@ -10,7 +10,7 @@ var (
BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261021d"
StaticVersion = "20261022a"
)
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
@@ -47,6 +47,9 @@ var (
Available string `ini:"available"`
}
// Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。
Announcement announcementConfig
Security struct {
EnableTextCensor bool `ini:"enable_text_censor"`
IPBlacklist string `ini:"ip_blacklist"`
+1
View File
@@ -941,4 +941,5 @@ var enDict = map[string]string{
"theme.name.default": "Default look",
"theme.name.sakura": "Sakura",
"theme.name.shiroko": "Shiroko",
"theme.name.new-sakura": "New Sakura",
}
+1
View File
@@ -941,4 +941,5 @@ var jaDict = map[string]string{
"theme.name.default": "標準の外観",
"theme.name.sakura": "さくら",
"theme.name.shiroko": "シロコ",
"theme.name.new-sakura": "新さくら",
}
+1
View File
@@ -882,6 +882,7 @@ var zhCNDict = map[string]string{
"theme.name.default": "基础外观",
"theme.name.sakura": "樱花",
"theme.name.shiroko": "砂狼白子",
"theme.name.new-sakura": "新·樱花",
// 验证码 JSON 接口:前端消费(P2)
"captcha.json.badRequest": "请求格式错误",
+1
View File
@@ -941,4 +941,5 @@ var zhTWDict = map[string]string{
"theme.name.default": "基礎外觀",
"theme.name.sakura": "櫻花",
"theme.name.shiroko": "砂狼白子",
"theme.name.new-sakura": "新·櫻花",
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package template
import (
"html"
"regexp"
"strings"
htmltemplate "html/template"
)
// 公告正文里的 http(s) 地址自动变链接。
var announcementURLRe = regexp.MustCompile(`https?://[^\s<>"']+`)
// 句末标点不属于 URL:中文公告里「详见 https://example.com/x。」很常见,
// 不剥离就会把「。」一起链进去。
const announcementURLTailChars = ".,;:!?)]},。;:!?)】」”’"
// AnnouncementHTML 把配置里的纯文本公告渲染成安全 HTML。
//
// 按 URL 切段、逐段转义,而不是「先整体转义再正则」:转义后的 &#34; 会被 URL
// 字符类当成普通字符吞进链接。链接的 href 与可见文本共用同一份转义结果,
// 因此不存在属性逃逸;内容里的 HTML 标签一律按字面显示。
func AnnouncementHTML(raw string) htmltemplate.HTML {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, `\r\n`, "\n")
raw = strings.ReplaceAll(raw, `\n`, "\n")
var b strings.Builder
last := 0
for _, loc := range announcementURLRe.FindAllStringIndex(raw, -1) {
url := raw[loc[0]:loc[1]]
core := strings.TrimRight(url, announcementURLTailChars)
tail := url[len(core):]
b.WriteString(escapeAnnouncementText(raw[last:loc[0]]))
escaped := html.EscapeString(core)
b.WriteString(`<a href="` + escaped + `" target="_blank" rel="noopener noreferrer nofollow">` + escaped + `</a>`)
b.WriteString(escapeAnnouncementText(tail))
last = loc[1]
}
b.WriteString(escapeAnnouncementText(raw[last:]))
return htmltemplate.HTML(b.String())
}
func escapeAnnouncementText(s string) string {
return strings.ReplaceAll(html.EscapeString(s), "\n", "<br>")
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package template
import "testing"
func TestAnnouncementHTML(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"纯文本原样输出", "机房维护通知", "机房维护通知"},
{"真实换行转 br", "第一行\n第二行", "第一行<br>第二行"},
{"字面量 \\n 也转 br", `第一行\n第二行`, "第一行<br>第二行"},
{"crlf 不产生多余 br", "第一行\r\n第二行", "第一行<br>第二行"},
{
"标签按字面转义",
`<script>alert(1)</script>`,
`&lt;script&gt;alert(1)&lt;/script&gt;`,
},
{
"地址自动变链接",
"详见 https://status.example.com/incident 谢谢",
`详见 <a href="https://status.example.com/incident" target="_blank" rel="noopener noreferrer nofollow">https://status.example.com/incident</a> 谢谢`,
},
{
"查询串的 & 被转义",
"https://example.com/a?b=1&c=2",
`<a href="https://example.com/a?b=1&amp;c=2" target="_blank" rel="noopener noreferrer nofollow">https://example.com/a?b=1&amp;c=2</a>`,
},
{
"句末中文标点不进链接",
"见 https://example.com/x。",
`见 <a href="https://example.com/x" target="_blank" rel="noopener noreferrer nofollow">https://example.com/x</a>。`,
},
{
"句末英文标点不进链接",
"https://example.com/x, thanks",
`<a href="https://example.com/x" target="_blank" rel="noopener noreferrer nofollow">https://example.com/x</a>, thanks`,
},
{
"裸 scheme 不当链接",
"https:// 后面没写地址",
"https:// 后面没写地址",
},
{
"引号无法逃逸属性",
`https://example.com/"><img src=x onerror=alert(1)>`,
`<a href="https://example.com/" target="_blank" rel="noopener noreferrer nofollow">https://example.com/</a>&#34;&gt;&lt;img src=x onerror=alert(1)&gt;`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(AnnouncementHTML(c.in))
if got != c.want {
t.Fatalf("AnnouncementHTML(%q)\n got %q\n want %q", c.in, got, c.want)
}
})
}
}
+9
View File
@@ -65,6 +65,15 @@ func FuncMap() []template.FuncMap {
"SentryDSN": func() string {
return conf.App.SentryDSN
},
// announcement 返回当前应展示的公告(已转义的安全 HTML),窗口外返回空串。
// 时间窗判定放在服务端:不依赖访客时钟,也不会先渲染出来再被脚本抹掉。
"announcement": func() template.HTML {
content, ok := conf.Announcement.Active(time.Now())
if !ok {
return ""
}
return AnnouncementHTML(content)
},
"ParsePublicURLs": func(input datatypes.JSON) string {
urls := make(map[string]string)
if err := json.Unmarshal(input, &urls); err != nil {
+170
View File
@@ -0,0 +1,170 @@
package theme
import (
"io/fs"
"regexp"
"strings"
"testing"
"github.com/tamakyi/TamaBox/static"
)
var ruleRe = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`)
// TestNarrowMediaOverridesScopedDeco 守一个只会「静默失效」的坑:
// 窄屏媒体查询里写的装饰层覆盖,若特异性不如页面作用域块,就完全不生效。
//
// 作用域块是 html[data-theme-style][data-page-scope="box"](两个属性),
// 而 @media 里常见的写法是 html[data-theme-style](一个属性)——
// 媒体查询不会提升特异性,于是桌面的装饰层在手机上照旧铺开,
// 表现就是「加了素材,窄屏糊在卡片上」。深色作用域块有三个属性,更难压。
//
// 判定:外层每一处「不全是 none」的 --tb-deco 声明,窄屏媒体查询里必须有一处属性名集合
// 相同、属性个数不少于它、值相同或更宽泛的选择器再声明一次
// (媒体查询在文件更后,同特异性必胜)。本来就声明成 none 的(如深色直接关掉装饰层)不算。
func TestNarrowMediaOverridesScopedDeco(t *testing.T) {
checked := 0
for _, slug := range discover() {
name := themeCSSDir + slug + ".css"
b, err := fs.ReadFile(static.FS, name)
if err != nil {
t.Fatalf("读取 %s: %v", name, err)
}
css := commentRe.ReplaceAllString(string(b), "")
inner, outer := splitNarrow(css)
outerSels := decoSels(outer, true)
if len(outerSels) == 0 {
continue
}
narrowSels := decoSels(inner, false)
for _, s := range outerSels {
checked++
if !coveredBy(s, narrowSels) {
t.Errorf("%s: 窄屏 @media (max-width: 640px) 里没有能压住 `%s` 的装饰层声明"+
"(同属性名、属性数不少于它)—— 该作用域在手机上会沿用桌面装饰层", name, s.raw)
}
}
}
t.Logf("共校验 %d 处作用域装饰层声明", checked)
}
// splitNarrow 把 css 拆成「窄屏媒体查询内部」与「其余」两部分。
// 任何 max-width 断点都算(shiroko 用的是 900px,按内容宽度自己定更合理)。
func splitNarrow(css string) (inner, outer string) {
var rest strings.Builder
for {
i := strings.Index(css, "@media (max-width:")
if i < 0 {
rest.WriteString(css)
return inner, rest.String()
}
rest.WriteString(css[:i])
open := strings.IndexByte(css[i:], '{')
if open < 0 {
return inner, rest.String()
}
open += i
depth, end := 0, -1
for j := open; j < len(css); j++ {
switch css[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = j
}
}
if end >= 0 {
break
}
}
if end < 0 {
return inner, rest.String()
}
inner += css[open+1 : end]
css = css[end+1:]
}
}
type decoSel struct {
raw string
attrs map[string]string // 属性名 → 值(空值表示只检查属性存在)
}
// decoSels 取出所有声明了 --tb-deco 的规则里的选择器。
// skipNone 用于外层:值全是 none 的本来就没有装饰层可压,不必要求窄屏再声明一次。
// 窄屏侧不能跳(那里往往正是靠 none 来收掉桌面装饰层)。
func decoSels(css string, skipNone bool) []decoSel {
var out []decoSel
for _, m := range ruleRe.FindAllStringSubmatch(css, -1) {
value := declarationValue(m[2], "--tb-deco:")
if value == "" || (skipNone && allNone(value)) {
continue
}
for _, one := range strings.Split(m[1], ",") {
one = strings.TrimSpace(one)
if strings.HasPrefix(one, "@") {
continue
}
out = append(out, decoSel{raw: one, attrs: attrMap(one)})
}
}
return out
}
func declarationValue(body, key string) string {
i := strings.Index(body, key)
if i < 0 {
return ""
}
rest := body[i+len(key):]
if j := strings.IndexByte(rest, ';'); j >= 0 {
rest = rest[:j]
}
return strings.TrimSpace(rest)
}
func allNone(v string) bool {
layers := splitTopLevel(v)
if len(layers) == 0 {
return false
}
for _, l := range layers {
if l != "none" {
return false
}
}
return true
}
// attrMap 解析选择器里的 [name="value"],只保留属性名与值。
func attrMap(sel string) map[string]string {
out := map[string]string{}
for _, m := range regexp.MustCompile(`\[([a-zA-Z-]+)(?:="?([^"\]]*)"?)?\]`).FindAllStringSubmatch(sel, -1) {
out[m[1]] = m[2]
}
return out
}
// coveredBy 判断媒体查询里的某个选择器是否能在同特异性下压过 outer。
func coveredBy(outer decoSel, narrow []decoSel) bool {
for _, n := range narrow {
if len(n.attrs) != len(outer.attrs) {
continue
}
ok := true
for k, v := range outer.attrs {
nv, has := n.attrs[k]
if !has || (nv != "" && nv != v) {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
+32
View File
@@ -777,6 +777,38 @@ a.tb-chip:hover {
border-radius: var(--tb-radius-xs);
}
/* 站内公告:配置驱动(conf/app.ini [announcement]),位于导航栏下方、正文之上。
时间窗由服务端判定,窗口外模板整块不渲染,因此这里不需要任何隐藏状态类。 */
.tb-announce {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 0 16px;
padding: 10px 14px;
border: 1px solid var(--tb-border);
border-left: 3px solid var(--tb-primary);
border-radius: var(--tb-radius-sm);
background: var(--tb-primary-weak);
color: var(--tb-text);
font-size: var(--tb-fs-md);
line-height: 1.6;
/* 贴长链接时不要把卡片撑破 */
overflow-wrap: anywhere;
}
.tb-announce-icon {
flex: none;
display: inline-flex;
margin-top: 2px;
color: var(--tb-primary);
}
.tb-announce-body {
min-width: 0;
}
.tb-announce-body a {
color: var(--tb-primary);
text-decoration: underline;
}
/* 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 */
#tb-mobile-utils {
display: none;
File diff suppressed because one or more lines are too long
+11
View File
@@ -371,6 +371,17 @@ document.addEventListener("DOMContentLoaded", function() {
})();
</script>
{{ with announcement }}
<!-- 站内公告:内容与展示时间窗来自 conf/app.ini 的 [announcement] 段。
由服务端按当前时间判定,窗口外整块不渲染,不留空白占位。 -->
<div class="uk-container uk-container-xsmall">
<div class="tb-announce" role="status">
<span class="tb-announce-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1z"/><path d="M16 8.5a5 5 0 0 1 0 7"/><path d="M19 6a9 9 0 0 1 0 12"/></svg></span>
<div class="tb-announce-body">{{ . }}</div>
</div>
</div>
{{ end }}
{{ if .IsPixel }}
<div>
{{ else }}