diff --git a/web/Dockerfile b/web/Dockerfile index ea4e2ff..7372fed 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -17,10 +17,11 @@ COPY ir_core ./ir_core COPY ir_dictionary.json ./ir_dictionary.json COPY web ./web -# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets(只读) +# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets(只读)。 +# 注意:镜像里不埋任何口令默认值。STORY_WEB_USERS(名字:口令,…)必须运行时注入, +# 未配置时 app 启动即退出(拒绝弱口令裸奔)。 ENV STORY_DB_PATH=/data/story_events.db \ - STORY_POINTSETS_DIR=/pointsets \ - STORY_WEB_PASSWORD=story + STORY_POINTSETS_DIR=/pointsets RUN mkdir -p /data /pointsets EXPOSE 8787 diff --git a/web/README.md b/web/README.md index d75c116..5a8f46e 100644 --- a/web/README.md +++ b/web/README.md @@ -3,7 +3,7 @@ 设计:`docs/plans/2026-06-06-story-event-pipeline-design.md`(§5.1/§6, D1–D4/D8) 计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md` -少数人凭共享口令在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译 +少数人各凭专属口令(口令即身份,改动自动署名)在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译 所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI (`ir_compile.py`)逐字节同口径。 @@ -12,12 +12,14 @@ ```bash cd tools/event_authoring/web pip install -r requirements.txt -# Windows PowerShell: $env:STORY_WEB_PASSWORD="your-pass" -set STORY_WEB_PASSWORD=your-pass # 默认 story +# Windows PowerShell: $env:STORY_WEB_USERS="bia:口令A,ljl:口令B" +set STORY_WEB_USERS=bia:口令A,ljl:口令B # 必填;未配置则拒绝启动 uvicorn app:app --host 0.0.0.0 --port 8787 ``` -浏览器打开 `http://:8787`,输入共享口令进入。 +浏览器打开 `http://:8787`,输入自己的专属口令进入(服务端按口令认人, +`updated_by` 自动署名;口令要求 ≥8 位且互不相同,cookie 只存随机会话 token 不存口令; +从 `STORY_WEB_USERS` 移除某人即吊销其所有会话)。 ## 用法 @@ -43,17 +45,18 @@ uvicorn app:app --host 0.0.0.0 --port 8787 ```bash cd tools/event_authoring/web -STORY_WEB_PASSWORD=your-pass docker compose up -d --build +STORY_WEB_USERS="bia:口令A,ljl:口令B" docker compose up -d --build # 或不用 compose: # docker build -f web/Dockerfile -t story-event-web .. -# docker run -d -p 8787:8787 -e STORY_WEB_PASSWORD=your-pass \ +# docker run -d -p 8787:8787 -e STORY_WEB_USERS="bia:口令A,ljl:口令B" \ # -v "$PWD/web/data:/data" \ # -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web ``` - **卷**:`./data:/data`(SQLite 持久化,容器重建不丢事件,**勿删**); `…/PointSets:/pointsets:ro`(开发侧点位集只读;缺失时坐标校验降级为警告)。 -- **环境变量**:`STORY_WEB_PASSWORD`(口令)、`STORY_WEB_PORT`(宿主端口,默认 8787)、 +- **环境变量**:`STORY_WEB_USERS`(必填,`名字1:口令1,名字2:口令2`,未配置拒绝启动)、 + `STORY_WEB_PORT`(宿主端口,默认 8787)、 `STORY_DB_PATH`(默认 `/data/story_events.db`)、`STORY_POINTSETS_DIR`(默认 `/pointsets`)、 可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。 - **NAS + VPS**:NAS 跑容器,VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需 diff --git a/web/app.py b/web/app.py index 21e7309..5c40fa9 100644 --- a/web/app.py +++ b/web/app.py @@ -1,18 +1,21 @@ # -*- coding: utf-8 -*- """M5 协作 Web 编辑器后端(FastAPI 单文件)。 -少数人 + 共享口令;事件存 SQLite;校验/编译走 ir_core(与 CLI 同口径)。 +少数人,每人一把专属口令(口令即身份);事件存 SQLite;校验/编译走 ir_core(与 CLI 同口径)。 起服务: pip install -r requirements.txt - set STORY_WEB_PASSWORD=your-pass (默认 story) + set STORY_WEB_USERS=bia:口令A,ljl:口令B (未配置则拒绝启动) uvicorn app:app --host 0.0.0.0 --port 8787 浏览器打开 http://:8787 。 """ +import asyncio import datetime import io import json import os +import secrets import sys +import time import zipfile from fastapi import FastAPI, Request, Response @@ -33,10 +36,49 @@ _POINTSETS_DIR = os.environ.get("STORY_POINTSETS_DIR") or \ os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets") _STATIC_DIR = os.path.join(_HERE, "static") -PASSWORD = os.environ.get("STORY_WEB_PASSWORD", "story") COOKIE = "story_auth" +SESSION_DAYS = 30 + + +def _load_users(): + """解析 STORY_WEB_USERS="名字1:口令1,名字2:口令2"。返回 {口令: 名字}。 + + 未配置/格式错/口令过短/口令重复 → 直接拒绝启动(宁可起不来,不可弱口令裸奔)。 + 口令即身份:登录只输口令,服务端按口令认人,updated_by 由服务端填写。 + """ + raw = (os.environ.get("STORY_WEB_USERS") or "").strip() + if not raw: + sys.exit('[story-web] 未配置 STORY_WEB_USERS,拒绝启动。' + '格式: STORY_WEB_USERS="bia:口令A,ljl:口令B"(每人口令≥8位且互不相同;' + '旧的 STORY_WEB_PASSWORD 已废弃)') + users = {} + names = set() + for part in raw.split(","): + part = part.strip() + if not part: + continue + if ":" not in part: + sys.exit('[story-web] STORY_WEB_USERS 条目格式错误(应为 名字:口令): %r' % part) + name, pw = part.split(":", 1) + name, pw = name.strip(), pw.strip() + if not name or not pw: + sys.exit('[story-web] STORY_WEB_USERS 条目名字/口令为空: %r' % part) + if len(pw) < 8: + sys.exit('[story-web] 用户 %s 的口令不足 8 位,拒绝启动' % name) + if pw in users: + sys.exit('[story-web] 用户 %s 与 %s 口令相同(口令即身份,必须唯一)' + % (name, users[pw])) + if name in names: + sys.exit('[story-web] 用户名重复: %s' % name) + users[pw] = name + names.add(name) + return users + + +USERS = _load_users() db.init_db() +db.purge_sessions(time.time()) app = FastAPI(title="Story Event Web Editor (M5)") @@ -45,29 +87,60 @@ def _now(): # ---------- 鉴权中间件 ---------- +def _session_user(request): + """cookie 里的随机 token → 会话用户名;过期/不存在/已被移出口令表 → None。""" + tok = request.cookies.get(COOKIE) + if not tok: + return None + user = db.get_session_user(tok, time.time()) + if user is not None and user not in USERS.values(): + return None # 从 STORY_WEB_USERS 删掉的人,旧 token 立即失效(按人吊销) + return user + + @app.middleware("http") async def auth_guard(request: Request, call_next): path = request.url.path # 放行登录、静态资源、根 if path.startswith("/api/") and path != "/api/login": - if request.cookies.get(COOKIE) != PASSWORD: + user = _session_user(request) + if user is None: return JSONResponse({"error": "未授权"}, status_code=401) + request.state.user = user return await call_next(request) # ---------- 鉴权 ---------- +_fail_count = 0 # 连续失败计数(全局节流;内部小工具,无需按 IP 细分) + + @app.post("/api/login") async def login(request: Request): + global _fail_count body = await request.json() - if body.get("password") != PASSWORD: + pw = str(body.get("password") or "") + user = None + for p, n in USERS.items(): + if secrets.compare_digest(pw, p): # 常量时间比较 + user = n + if user is None: + _fail_count += 1 + await asyncio.sleep(min(_fail_count, 5)) # 爆破节流:连错越多等越久 return JSONResponse({"error": "口令错误"}, status_code=403) - resp = JSONResponse({"ok": True}) - resp.set_cookie(COOKIE, PASSWORD, httponly=True, samesite="lax", max_age=30 * 86400) + _fail_count = 0 + token = secrets.token_urlsafe(32) + db.create_session(token, user, time.time() + SESSION_DAYS * 86400) + resp = JSONResponse({"ok": True, "user": user}) + resp.set_cookie(COOKIE, token, httponly=True, samesite="lax", + max_age=SESSION_DAYS * 86400) return resp @app.post("/api/logout") -async def logout(): +async def logout(request: Request): + tok = request.cookies.get(COOKIE) + if tok: + db.delete_session(tok) resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE) return resp @@ -116,7 +189,7 @@ async def event_detail(group: str): @app.post("/api/import") async def import_events(request: Request): body = await request.json() - by = body.get("by", "匿名") + by = request.state.user # 改动者=会话身份,不信前端自报 items = body.get("events", []) if isinstance(items, dict): # 容错:单个 IR items = [items] @@ -136,14 +209,14 @@ async def update_event(group: str, request: Request): ir = body.get("ir") if not ir or ir.get("id") != group: return JSONResponse({"error": "ir.id 与 group 不一致"}, status_code=400) - db.upsert_event(ir, body.get("by", "匿名"), _now(), notes=body.get("notes")) + db.upsert_event(ir, request.state.user, _now(), notes=body.get("notes")) return {"ok": True, "updated_at": _now()} @app.post("/api/events/{group}/status") async def change_status(group: str, request: Request): body = await request.json() - ok = db.set_status(group, body.get("status"), body.get("by", "匿名"), _now()) + ok = db.set_status(group, body.get("status"), request.state.user, _now()) if not ok: return JSONResponse({"error": "事件不存在"}, status_code=404) return {"ok": True} diff --git a/web/db.py b/web/db.py index 8889753..e0b5ce4 100644 --- a/web/db.py +++ b/web/db.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- """M5 Web 编辑器的 SQLite 存储层。 -单表 events:group(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。 +表 events:group(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。 末次写入生效(设计接受),不做锁。 +表 sessions:token(PK)/user/expires_at——登录会话(cookie 只存随机 token,不存口令)。 """ import json import os @@ -38,6 +39,43 @@ def init_db(path=None): notes TEXT )""" ) + c.execute( + """CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user TEXT NOT NULL, + expires_at REAL NOT NULL + )""" + ) + + +# ---------- 会话 ---------- +def create_session(token, user, expires_at, path=None): + with _conn(path) as c: + c.execute("INSERT OR REPLACE INTO sessions (token, user, expires_at) " + "VALUES (?,?,?)", (token, user, expires_at)) + + +def get_session_user(token, now, path=None): + """token 有效返回用户名;过期则顺手删除并返回 None。""" + with _conn(path) as c: + r = c.execute("SELECT user, expires_at FROM sessions WHERE token=?", + (token,)).fetchone() + if not r: + return None + if r["expires_at"] < now: + c.execute("DELETE FROM sessions WHERE token=?", (token,)) + return None + return r["user"] + + +def delete_session(token, path=None): + with _conn(path) as c: + c.execute("DELETE FROM sessions WHERE token=?", (token,)) + + +def purge_sessions(now, path=None): + with _conn(path) as c: + c.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) def list_events(status=None, path=None): diff --git a/web/docker-compose.nas.yml b/web/docker-compose.nas.yml index e3688d4..d75fec7 100644 --- a/web/docker-compose.nas.yml +++ b/web/docker-compose.nas.yml @@ -1,6 +1,6 @@ # 极空间(x86/amd64) 部署用:导入 story-event-web.tar 后直接引用镜像起服务(不构建)。 # 在极空间 Docker 的「Compose」里新建项目,粘贴本文件内容即可。 -# 改两处:STORY_WEB_PASSWORD(口令)、按需放开点位集卷。 +# 改两处:STORY_WEB_USERS(每人口令)、按需放开点位集卷。 services: story-web: image: story-event-web:latest # 由 docker save 导出的 tar 导入而来 @@ -8,7 +8,9 @@ services: ports: - "8787:8787" # 宿主8787 -> 容器8787;frpc/反代再对外 environment: - STORY_WEB_PASSWORD: "change-me" # ← 改成你的共享口令 + # ← 改成实际口令(每人一把,口令即身份;≥8位且互不相同)。 + # 未配置/口令过短/重复时容器启动即退出(拒绝弱口令裸奔)。 + STORY_WEB_USERS: "bia:把我改成口令A,ljl:把我改成口令B" TZ: "Asia/Shanghai" # 否则 updated_at 按 UTC 显示 volumes: - ./data:/data # SQLite 持久化(事件数据,勿删) diff --git a/web/docker-compose.yml b/web/docker-compose.yml index 14acdff..ba64b28 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -1,6 +1,6 @@ # Story 事件协作 Web 编辑器(M6)。NAS 跑容器,VPS 端口映射到此。 # cd tools/event_authoring/web -# STORY_WEB_PASSWORD=your-pass docker compose up -d --build +# STORY_WEB_USERS="bia:口令A,ljl:口令B" docker compose up -d --build services: story-web: build: @@ -15,7 +15,8 @@ services: ports: - "${STORY_WEB_PORT:-8787}:8787" environment: - STORY_WEB_PASSWORD: "${STORY_WEB_PASSWORD:-story}" + # 必须显式提供(每人一把口令,口令即身份);未设置时 compose 直接报错 + STORY_WEB_USERS: "${STORY_WEB_USERS:?必须设置 STORY_WEB_USERS=名字1:口令1,名字2:口令2}" volumes: # SQLite 持久化(事件数据;勿删) - ./data:/data diff --git a/web/static/app.js b/web/static/app.js index b17227a..1d1dd9b 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -25,10 +25,11 @@ function hideLogin() { $("login").style.display = "none"; } $("login-btn").onclick = async () => { - const pass = $("login-pass").value, name = $("login-name").value.trim(); + const pass = $("login-pass").value; const r = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pass }) }); if (!r.ok) { $("login-err").textContent = "口令错误"; return; } - if (name) { App.by = name; localStorage.setItem("story_by", name); } + const d = await r.json(); // 身份由服务端按口令认定(口令即身份) + App.by = d.user || "?"; localStorage.setItem("story_by", App.by); hideLogin(); init(); }; $("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); }; diff --git a/web/static/index.html b/web/static/index.html index 132eb64..22bcc1c 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -13,9 +13,8 @@
diff --git a/web/static/playtest.js b/web/static/playtest.js index f0a4159..50e2acf 100644 --- a/web/static/playtest.js +++ b/web/static/playtest.js @@ -90,10 +90,10 @@ const k = n.kind; if (k === "narration") { add("spk", "" + esc(nameOf(n.speaker || "P1")) + ":" + esc(n.text)); loc = advance(loc); } else if (k === "dialogue") { add("spk", "" + esc(nameOf(n.speaker)) + ":" + esc(n.text)); loc = advance(loc); } - else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); loc = advance(loc); } - else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); loc = advance(loc); } + else if (k === "move") { add("sys", "〔走位〕" + esc(nameOf(n.actor)) + " → " + esc(n.to || "")); loc = advance(loc); } + else if (k === "anim") { add("sys", "〔动画〕" + esc(nameOf(n.actor)) + " " + esc(n.ani || "")); loc = advance(loc); } else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); } - else if (k === "out_ref") { add("sys", "〔进入子序列 " + n.ref + "〕"); loc = enterSeq(n.ref, n.next); } + else if (k === "out_ref") { add("sys", "〔进入子序列 " + esc(n.ref) + "〕"); loc = enterSeq(n.ref, n.next); } else if (k === "choice" || k === "choice_once") { renderChoice(n); return; } else if (k === "random") { renderRandom(n); return; } else if (k === "fight") { renderFight(n); return; }