diff --git a/.workbuddy/memory/2026-10-21.md b/.workbuddy/memory/2026-10-21.md index 961c55c..a8607bf 100644 --- a/.workbuddy/memory/2026-10-21.md +++ b/.workbuddy/memory/2026-10-21.md @@ -600,3 +600,18 @@ label 要 `flex:0 0 auto; white-space:nowrap`,宽度给 84px 才够(68px 仍 表现为「显示删除成功但东西还在」。加 `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。 diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index 8635adc..6d8f3ca 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -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`,但那就只有静态预览、没有上传能力。 ## 它和线上是同一条链路 @@ -261,6 +274,7 @@ AWS S3 或已绑定的桶级自定义域可以取消「路径风格」(走 `< 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)`。 diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html index 512e7fd..13367d1 100644 --- a/dev/theme-builder/index.html +++ b/dev/theme-builder/index.html @@ -294,7 +294,7 @@ 需要本地服务才能预览真实页面 在项目根执行: python dev/theme-builder/serve.py - 然后打开 http://127.0.0.1:8777/dev/theme-builder/ + 然后打开 serve.py 启动时打印的地址(本机 127.0.0.1,局域网用本机 IP)
@@ -433,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)); @@ -879,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'; @@ -3338,7 +3341,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); diff --git a/dev/theme-builder/mock_s3.py b/dev/theme-builder/mock_s3.py index 7db288c..6b4fee0 100644 --- a/dev/theme-builder/mock_s3.py +++ b/dev/theme-builder/mock_s3.py @@ -149,20 +149,29 @@ class MockS3(BaseHTTPRequestHandler): self.end_headers() -def spin(port=0): - """起在后台线程,返回 (server, endpoint)。port=0 让系统挑空闲端口。""" - srv = ThreadingHTTPServer(("127.0.0.1", port), MockS3) +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() - return srv, "http://127.0.0.1:%d" % srv.server_address[1] + 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) + 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() diff --git a/dev/theme-builder/serve.py b/dev/theme-builder/serve.py index 0aa184d..a173a42 100644 --- a/dev/theme-builder/serve.py +++ b/dev/theme-builder/serve.py @@ -10,12 +10,13 @@ file:// 下浏览器不允许父页面访问 iframe 的 DOM(即使同目录) fetch,各家 S3 兼容实现的 CORS 配置界面又都不一样。放到本机 Python 里代发, 生成器只跟 127.0.0.1 说话:不碰 CORS,密钥不落盘、不进工程文件、不进导出 zip。 -用法(在项目根目录执行任意一种): +用法(在项目根目录执行): - python dev/theme-builder/serve.py - python -m http.server 8777 # 只有预览,没有上传能力 + 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 # 只有静态预览,没有上传能力 -然后打开 http://localhost:8777/dev/theme-builder/ +启动时终端会列出本机与各网卡的地址,照着打开即可。 这个脚本只读文件,不写任何东西,也不改项目配置。 """ @@ -30,6 +31,7 @@ import http.server import json import os import re +import socket import socketserver import sys import urllib.error @@ -459,6 +461,19 @@ class Handler(http.server.SimpleHTTPRequestHandler): 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 没有这个端点。 @@ -469,6 +484,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): 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: @@ -498,10 +515,38 @@ class Handler(http.server.SimpleHTTPRequestHandler): 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")): @@ -510,19 +555,28 @@ def main(): " TEST_PREVIEW=1 go test ./templates -run TestGenPreview\n" ) - if args.host not in ("127.0.0.1", "localhost", "::1"): - sys.stderr.write( - "⚠️ 监听在 %s:上传接口没有鉴权,会代任何能访问到它的人向对象存储写文件。\n" - " 只在完全可信的内网这么做。\n" % args.host - ) + 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) - print("上传接口:/api/s3/test /api/s3/upload(密钥只在内存,不落盘)") + 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()