生成器默认监听 0.0.0.0,局域网/手机可直接打开

- serve.py:--host 默认改 0.0.0.0;启动时列出所有网卡地址(0.0.0.0 不能直接点开);
  --host 127.0.0.1 仍是「只给本机」的收紧方式,README 说明公共网络用它。
- 改单线程为 ThreadingTCPServer:上传几 MB 的图会占住连接,单线程时 iframe 预览
  和其它请求都得排队。
- 写接口加同源校验(Origin 与 Host 比对,无 Origin 放行):挡住同网段里「另一个网页
  借访客浏览器发写请求」,curl / 脚本不受影响。
- index.html:提示文案不再写死 127.0.0.1,改用 location.host —— 用局域网地址打开时
  不该反过来提示「请用 127.0.0.1 打开」。
- mock_s3.py 加 --host(默认仍回环),便于在别的设备上联调。
This commit is contained in:
tamakyi
2026-10-21 21:46:34 +08:00
parent 12c4f3e1be
commit 8daa5b0bec
5 changed files with 122 additions and 27 deletions
+18 -4
View File
@@ -16,13 +16,26 @@
TEST_PREVIEW=1 go test ./templates -run TestGenPreview
# 2) 起服务(只读,不改项目任何文件)
python dev/theme-builder/serve.py # 默认 8777
python dev/theme-builder/serve.py # 默认监听 0.0.0.0:8777,同局域网可访问
python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共网络请加这个)
# 3) 打开
http://127.0.0.1:8777/dev/theme-builder/
# 3) 打开终端里打印的地址
# 本机 http://127.0.0.1:8777/dev/theme-builder/
# 局域网 http://192.168.x.x:8777/dev/theme-builder/
```
不想用脚本也行:在项目根跑 `python -m http.server 8777`,效果一样。
默认绑 `0.0.0.0`,是为了能躺在床上用手机 / 平板调主题:启动时会把本机各网卡的地址都列出来,
照着点就行(手机与电脑要在同一网段)。第一次启动 Windows 会弹防火墙询问,要允许私有网络。
⚠️ 两个必须知道的点:
- **上传接口没有账号体系**:能打开这个端口的人,都能用你填的密钥往对象存储写 / 删文件。
公共网络(咖啡馆、宿舍、公司网)一律 `--host 127.0.0.1`。写接口还会校验 `Origin` 与
`Host` 同源,挡掉「别的网页借你浏览器发请求」,但挡不住同网段主动连过来的人。
- **换地址 = 换 origin**:`127.0.0.1` 与 `192.168.x.x` 在浏览器眼里是两个站点,
草稿(localStorage)与 S3 密钥(sessionStorage)不共享 —— 第一次用新地址要重填一次密钥。
不想用脚本也行:在项目根跑 `python -m http.server 8777`,但那就只有静态预览、没有上传能力。
## 它和线上是同一条链路
@@ -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)`。
+6 -3
View File
@@ -294,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>
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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);
+14 -5
View File
@@ -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()
+69 -15
View File
@@ -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()