security: 每人一把口令(口令即身份) + 随机会话token + 无配置拒绝启动 + 爆破节流
- STORY_WEB_PASSWORD(默认story) 废弃 → STORY_WEB_USERS=名字1:口令1,名字2:口令2; 未配置/口令<8位/口令或用户名重复 → 启动即退出,杜绝弱默认口令裸奔 - cookie 不再存口令原文:登录发 secrets.token_urlsafe(32) 随机token, 会话存 SQLite sessions 表(30天);登出删token;从 USERS 移除某人=吊销其全部会话 - updated_by 改由服务端按会话身份填写,前端自报 by 不再可信;登录框去掉昵称字段 - 登录失败全局递增节流(最多sleep 5s),口令比较用 secrets.compare_digest - Dockerfile/compose 移除一切口令默认值;compose 未设 STORY_WEB_USERS 直接报错 - 顺手修 playtest.js 走位/动画/out_ref 行未转义的存储型XSS(esc补齐)
This commit is contained in:
@ -17,10 +17,11 @@ COPY ir_core ./ir_core
|
|||||||
COPY ir_dictionary.json ./ir_dictionary.json
|
COPY ir_dictionary.json ./ir_dictionary.json
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets(只读)
|
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets(只读)。
|
||||||
|
# 注意:镜像里不埋任何口令默认值。STORY_WEB_USERS(名字:口令,…)必须运行时注入,
|
||||||
|
# 未配置时 app 启动即退出(拒绝弱口令裸奔)。
|
||||||
ENV STORY_DB_PATH=/data/story_events.db \
|
ENV STORY_DB_PATH=/data/story_events.db \
|
||||||
STORY_POINTSETS_DIR=/pointsets \
|
STORY_POINTSETS_DIR=/pointsets
|
||||||
STORY_WEB_PASSWORD=story
|
|
||||||
RUN mkdir -p /data /pointsets
|
RUN mkdir -p /data /pointsets
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
设计:`docs/plans/2026-06-06-story-event-pipeline-design.md`(§5.1/§6, D1–D4/D8)
|
设计:`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`
|
计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md`
|
||||||
|
|
||||||
少数人凭共享口令在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
|
少数人各凭专属口令(口令即身份,改动自动署名)在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
|
||||||
所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI
|
所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI
|
||||||
(`ir_compile.py`)逐字节同口径。
|
(`ir_compile.py`)逐字节同口径。
|
||||||
|
|
||||||
@ -12,12 +12,14 @@
|
|||||||
```bash
|
```bash
|
||||||
cd tools/event_authoring/web
|
cd tools/event_authoring/web
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# Windows PowerShell: $env:STORY_WEB_PASSWORD="your-pass"
|
# Windows PowerShell: $env:STORY_WEB_USERS="bia:口令A,ljl:口令B"
|
||||||
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
|
uvicorn app:app --host 0.0.0.0 --port 8787
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 `http://<host>:8787`,输入共享口令进入。
|
浏览器打开 `http://<host>:8787`,输入自己的专属口令进入(服务端按口令认人,
|
||||||
|
`updated_by` 自动署名;口令要求 ≥8 位且互不相同,cookie 只存随机会话 token 不存口令;
|
||||||
|
从 `STORY_WEB_USERS` 移除某人即吊销其所有会话)。
|
||||||
|
|
||||||
## 用法
|
## 用法
|
||||||
|
|
||||||
@ -43,17 +45,18 @@ uvicorn app:app --host 0.0.0.0 --port 8787
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools/event_authoring/web
|
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:
|
# 或不用 compose:
|
||||||
# docker build -f web/Dockerfile -t story-event-web ..
|
# 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/web/data:/data" \
|
||||||
# -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web
|
# -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web
|
||||||
```
|
```
|
||||||
|
|
||||||
- **卷**:`./data:/data`(SQLite 持久化,容器重建不丢事件,**勿删**);
|
- **卷**:`./data:/data`(SQLite 持久化,容器重建不丢事件,**勿删**);
|
||||||
`…/PointSets:/pointsets:ro`(开发侧点位集只读;缺失时坐标校验降级为警告)。
|
`…/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`)、
|
`STORY_DB_PATH`(默认 `/data/story_events.db`)、`STORY_POINTSETS_DIR`(默认 `/pointsets`)、
|
||||||
可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。
|
可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。
|
||||||
- **NAS + VPS**:NAS 跑容器,VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需
|
- **NAS + VPS**:NAS 跑容器,VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需
|
||||||
|
|||||||
95
web/app.py
95
web/app.py
@ -1,18 +1,21 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""M5 协作 Web 编辑器后端(FastAPI 单文件)。
|
"""M5 协作 Web 编辑器后端(FastAPI 单文件)。
|
||||||
|
|
||||||
少数人 + 共享口令;事件存 SQLite;校验/编译走 ir_core(与 CLI 同口径)。
|
少数人,每人一把专属口令(口令即身份);事件存 SQLite;校验/编译走 ir_core(与 CLI 同口径)。
|
||||||
起服务:
|
起服务:
|
||||||
pip install -r requirements.txt
|
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
|
uvicorn app:app --host 0.0.0.0 --port 8787
|
||||||
浏览器打开 http://<host>:8787 。
|
浏览器打开 http://<host>:8787 。
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
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")
|
os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets")
|
||||||
_STATIC_DIR = os.path.join(_HERE, "static")
|
_STATIC_DIR = os.path.join(_HERE, "static")
|
||||||
|
|
||||||
PASSWORD = os.environ.get("STORY_WEB_PASSWORD", "story")
|
|
||||||
COOKIE = "story_auth"
|
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.init_db()
|
||||||
|
db.purge_sessions(time.time())
|
||||||
app = FastAPI(title="Story Event Web Editor (M5)")
|
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")
|
@app.middleware("http")
|
||||||
async def auth_guard(request: Request, call_next):
|
async def auth_guard(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
# 放行登录、静态资源、根
|
# 放行登录、静态资源、根
|
||||||
if path.startswith("/api/") and path != "/api/login":
|
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)
|
return JSONResponse({"error": "未授权"}, status_code=401)
|
||||||
|
request.state.user = user
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 鉴权 ----------
|
# ---------- 鉴权 ----------
|
||||||
|
_fail_count = 0 # 连续失败计数(全局节流;内部小工具,无需按 IP 细分)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/login")
|
@app.post("/api/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
|
global _fail_count
|
||||||
body = await request.json()
|
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)
|
return JSONResponse({"error": "口令错误"}, status_code=403)
|
||||||
resp = JSONResponse({"ok": True})
|
_fail_count = 0
|
||||||
resp.set_cookie(COOKIE, PASSWORD, httponly=True, samesite="lax", max_age=30 * 86400)
|
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
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@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 = JSONResponse({"ok": True})
|
||||||
resp.delete_cookie(COOKIE)
|
resp.delete_cookie(COOKIE)
|
||||||
return resp
|
return resp
|
||||||
@ -116,7 +189,7 @@ async def event_detail(group: str):
|
|||||||
@app.post("/api/import")
|
@app.post("/api/import")
|
||||||
async def import_events(request: Request):
|
async def import_events(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
by = body.get("by", "匿名")
|
by = request.state.user # 改动者=会话身份,不信前端自报
|
||||||
items = body.get("events", [])
|
items = body.get("events", [])
|
||||||
if isinstance(items, dict): # 容错:单个 IR
|
if isinstance(items, dict): # 容错:单个 IR
|
||||||
items = [items]
|
items = [items]
|
||||||
@ -136,14 +209,14 @@ async def update_event(group: str, request: Request):
|
|||||||
ir = body.get("ir")
|
ir = body.get("ir")
|
||||||
if not ir or ir.get("id") != group:
|
if not ir or ir.get("id") != group:
|
||||||
return JSONResponse({"error": "ir.id 与 group 不一致"}, status_code=400)
|
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()}
|
return {"ok": True, "updated_at": _now()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/events/{group}/status")
|
@app.post("/api/events/{group}/status")
|
||||||
async def change_status(group: str, request: Request):
|
async def change_status(group: str, request: Request):
|
||||||
body = await request.json()
|
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:
|
if not ok:
|
||||||
return JSONResponse({"error": "事件不存在"}, status_code=404)
|
return JSONResponse({"error": "事件不存在"}, status_code=404)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
40
web/db.py
40
web/db.py
@ -1,8 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""M5 Web 编辑器的 SQLite 存储层。
|
"""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 json
|
||||||
import os
|
import os
|
||||||
@ -38,6 +39,43 @@ def init_db(path=None):
|
|||||||
notes TEXT
|
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):
|
def list_events(status=None, path=None):
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 极空间(x86/amd64) 部署用:导入 story-event-web.tar 后直接引用镜像起服务(不构建)。
|
# 极空间(x86/amd64) 部署用:导入 story-event-web.tar 后直接引用镜像起服务(不构建)。
|
||||||
# 在极空间 Docker 的「Compose」里新建项目,粘贴本文件内容即可。
|
# 在极空间 Docker 的「Compose」里新建项目,粘贴本文件内容即可。
|
||||||
# 改两处:STORY_WEB_PASSWORD(口令)、按需放开点位集卷。
|
# 改两处:STORY_WEB_USERS(每人口令)、按需放开点位集卷。
|
||||||
services:
|
services:
|
||||||
story-web:
|
story-web:
|
||||||
image: story-event-web:latest # 由 docker save 导出的 tar 导入而来
|
image: story-event-web:latest # 由 docker save 导出的 tar 导入而来
|
||||||
@ -8,7 +8,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8787:8787" # 宿主8787 -> 容器8787;frpc/反代再对外
|
- "8787:8787" # 宿主8787 -> 容器8787;frpc/反代再对外
|
||||||
environment:
|
environment:
|
||||||
STORY_WEB_PASSWORD: "change-me" # ← 改成你的共享口令
|
# ← 改成实际口令(每人一把,口令即身份;≥8位且互不相同)。
|
||||||
|
# 未配置/口令过短/重复时容器启动即退出(拒绝弱口令裸奔)。
|
||||||
|
STORY_WEB_USERS: "bia:把我改成口令A,ljl:把我改成口令B"
|
||||||
TZ: "Asia/Shanghai" # 否则 updated_at 按 UTC 显示
|
TZ: "Asia/Shanghai" # 否则 updated_at 按 UTC 显示
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data # SQLite 持久化(事件数据,勿删)
|
- ./data:/data # SQLite 持久化(事件数据,勿删)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Story 事件协作 Web 编辑器(M6)。NAS 跑容器,VPS 端口映射到此。
|
# Story 事件协作 Web 编辑器(M6)。NAS 跑容器,VPS 端口映射到此。
|
||||||
# cd tools/event_authoring/web
|
# 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:
|
services:
|
||||||
story-web:
|
story-web:
|
||||||
build:
|
build:
|
||||||
@ -15,7 +15,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${STORY_WEB_PORT:-8787}:8787"
|
- "${STORY_WEB_PORT:-8787}:8787"
|
||||||
environment:
|
environment:
|
||||||
STORY_WEB_PASSWORD: "${STORY_WEB_PASSWORD:-story}"
|
# 必须显式提供(每人一把口令,口令即身份);未设置时 compose 直接报错
|
||||||
|
STORY_WEB_USERS: "${STORY_WEB_USERS:?必须设置 STORY_WEB_USERS=名字1:口令1,名字2:口令2}"
|
||||||
volumes:
|
volumes:
|
||||||
# SQLite 持久化(事件数据;勿删)
|
# SQLite 持久化(事件数据;勿删)
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|||||||
@ -25,10 +25,11 @@
|
|||||||
function hideLogin() { $("login").style.display = "none"; }
|
function hideLogin() { $("login").style.display = "none"; }
|
||||||
|
|
||||||
$("login-btn").onclick = async () => {
|
$("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 }) });
|
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 (!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();
|
hideLogin(); init();
|
||||||
};
|
};
|
||||||
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };
|
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };
|
||||||
|
|||||||
@ -13,9 +13,8 @@
|
|||||||
<div id="login" class="overlay">
|
<div id="login" class="overlay">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<h2>剧情事件协作编辑器</h2>
|
<h2>剧情事件协作编辑器</h2>
|
||||||
<p class="hint">输入共享口令进入</p>
|
<p class="hint">输入你的专属口令进入(口令即身份,改动记录自动署名)</p>
|
||||||
<input id="login-pass" type="password" placeholder="共享口令" autocomplete="off">
|
<input id="login-pass" type="password" placeholder="专属口令" autocomplete="off">
|
||||||
<input id="login-name" type="text" placeholder="你的昵称(用于记录改动者)">
|
|
||||||
<button id="login-btn">进入</button>
|
<button id="login-btn">进入</button>
|
||||||
<div id="login-err" class="err"></div>
|
<div id="login-err" class="err"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -90,10 +90,10 @@
|
|||||||
const k = n.kind;
|
const k = n.kind;
|
||||||
if (k === "narration") { add("spk", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
if (k === "narration") { add("spk", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||||
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||||
else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); 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", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); 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 === "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 === "choice" || k === "choice_once") { renderChoice(n); return; }
|
||||||
else if (k === "random") { renderRandom(n); return; }
|
else if (k === "random") { renderRandom(n); return; }
|
||||||
else if (k === "fight") { renderFight(n); return; }
|
else if (k === "fight") { renderFight(n); return; }
|
||||||
|
|||||||
Reference in New Issue
Block a user