Compare commits
8
Commits
734aa7b8c3
...
33fa643465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33fa643465 | ||
|
|
e1e840f895 | ||
|
|
c9f22cb81b | ||
|
|
5c0de805c6 | ||
|
|
48a1938fb2 | ||
|
|
8daa5b0bec | ||
|
|
12c4f3e1be | ||
|
|
9c961b3c05 |
@@ -37,3 +37,7 @@ go.work.sum
|
||||
# 默认目录由 [security] honeypot_cred_dir 决定(缺省 data/honeypot-creds),
|
||||
# 若改成别的盘外路径则不受这条影响(那本来也不在仓库里)。
|
||||
/data/
|
||||
|
||||
# 主题生成器的自测脚本是 Python:跑一次就会留 __pycache__,别提交。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 包链接,不能「先转义再正则」否则 " 被吞进 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(亮/暗都看过)。
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 参数」重编码全部素材,并报出省了多少 —— 与图片处理同一条非破坏原则,
|
||||
来回试不会叠加压缩。
|
||||
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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);
|
||||
|
||||
@@ -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>"mock"</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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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**(最干净,但迁移成本在运维侧)。
|
||||
- 就用本地目录、不想依赖任何外部服务 → **什么都不用做**(默认行为,素材随二进制发布)。
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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("配置无效时应清空已解析的时间点")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -941,4 +941,5 @@ var jaDict = map[string]string{
|
||||
"theme.name.default": "標準の外観",
|
||||
"theme.name.sakura": "さくら",
|
||||
"theme.name.shiroko": "シロコ",
|
||||
"theme.name.new-sakura": "新さくら",
|
||||
}
|
||||
|
||||
@@ -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": "请求格式错误",
|
||||
|
||||
@@ -941,4 +941,5 @@ var zhTWDict = map[string]string{
|
||||
"theme.name.default": "基礎外觀",
|
||||
"theme.name.sakura": "櫻花",
|
||||
"theme.name.shiroko": "砂狼白子",
|
||||
"theme.name.new-sakura": "新·櫻花",
|
||||
}
|
||||
|
||||
@@ -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 切段、逐段转义,而不是「先整体转义再正则」:转义后的 " 会被 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>")
|
||||
}
|
||||
@@ -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>`,
|
||||
`<script>alert(1)</script>`,
|
||||
},
|
||||
{
|
||||
"地址自动变链接",
|
||||
"详见 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&c=2" target="_blank" rel="noopener noreferrer nofollow">https://example.com/a?b=1&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>"><img src=x onerror=alert(1)>`,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user