feat: harden storyforge runtime and repo boundary

This commit is contained in:
kris
2026-03-26 09:08:41 +08:00
parent fa9d6dda09
commit dd619448e7
33 changed files with 1028 additions and 316 deletions

56
scripts/check_repo_baseline.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
need_cmd python3
need_cmd docker
need_cmd node
cd "$ROOT"
echo "[1/5] compile collector-service"
python3 -m compileall collector-service/app >/dev/null
echo "[2/5] validate docker compose"
docker compose config >/dev/null
echo "[3/5] validate n8n workflows"
python3 - <<'PY'
import json
import pathlib
for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")):
with path.open() as handle:
json.load(handle)
print(f"workflow ok: {path.name}")
PY
echo "[4/5] validate web scripts"
for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do
node --check "$file"
done
node --check scripts/douyin-browser-capture/control_panel.mjs
if [ "${STORYFORGE_SKIP_ANDROID:-0}" = "1" ]; then
echo "[5/5] skip android compile (STORYFORGE_SKIP_ANDROID=1)"
else
if command -v java >/dev/null 2>&1; then
echo "[5/5] compile android debug kotlin"
(
cd android-app
./gradlew :app:compileDebugKotlin >/dev/null
)
else
echo "[5/5] skip android compile (java not installed)"
fi
fi
echo "baseline checks passed"

View File

@@ -43,6 +43,8 @@ The control panel stores each run under:
`/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel`
The StoryForge token field is session-scoped in the browser and is not written back into the saved form values, so it will not be refilled from localStorage on the next launch.
## What it captures
- current profile page JSON blobs extracted from `<script>` tags

View File

@@ -1157,6 +1157,7 @@ function renderPage(mode = "full") {
const form = document.getElementById("capture-form");
const storageKey = "storyforge-douyin-control-panel";
const sessionStorageKey = "storyforge-douyin-workbench-session";
const tokenStorageKey = "storyforge-douyin-control-panel-token";
const workbenchSessionEl = document.getElementById("workbench-session");
const tokenDetailsEl = document.getElementById("token-details");
const tokenInputEl = document.getElementById("token");
@@ -1208,7 +1209,8 @@ function renderPage(mode = "full") {
selectedSnapshotDetail: null,
similarSearchDetail: null,
loadingAccountId: "",
lastAnalysisMessage: ""
lastAnalysisMessage: "",
accountSelectionToken: 0
};
function escapeHtml(value) {
@@ -1272,7 +1274,7 @@ function renderPage(mode = "full") {
const backendUrl = normalizeBackendUrl(document.getElementById("backend-url").value);
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
const token = tokenInputEl.value.trim();
const token = tokenInputEl.value.trim() || getStoredCaptureToken();
return {
backendUrl,
username,
@@ -1281,9 +1283,32 @@ function renderPage(mode = "full") {
};
}
function getStoredCaptureToken() {
try {
return sessionStorage.getItem(tokenStorageKey) || "";
} catch {
return "";
}
}
function persistCaptureToken(token) {
try {
if (token) {
sessionStorage.setItem(tokenStorageKey, token);
} else {
sessionStorage.removeItem(tokenStorageKey);
}
} catch {}
}
function updateTokenUi() {
const token = tokenInputEl.value.trim();
tokenSummaryEl.textContent = token ? "已填写 Token" : "未填写 Token";
const storedToken = getStoredCaptureToken();
tokenSummaryEl.textContent = token
? "已填写 Token"
: storedToken
? "Token 已保存到当前会话"
: "未填写 Token";
if (!token && document.activeElement !== tokenInputEl) {
tokenDetailsEl.open = false;
}
@@ -1291,7 +1316,8 @@ function renderPage(mode = "full") {
function persistWorkbenchSession(session) {
workbenchState.session = session;
localStorage.setItem(sessionStorageKey, JSON.stringify(session));
sessionStorage.setItem(sessionStorageKey, JSON.stringify(session));
localStorage.removeItem(sessionStorageKey);
renderWorkbenchSession();
}
@@ -1310,7 +1336,9 @@ function renderPage(mode = "full") {
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
workbenchState.lastAnalysisMessage = "";
sessionStorage.removeItem(sessionStorageKey);
localStorage.removeItem(sessionStorageKey);
persistCaptureToken("");
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
@@ -1318,9 +1346,21 @@ function renderPage(mode = "full") {
function loadWorkbenchSession() {
try {
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || "null");
const sessionRaw = sessionStorage.getItem(sessionStorageKey);
if (sessionRaw) {
const saved = JSON.parse(sessionRaw);
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
}
return;
}
const legacyRaw = localStorage.getItem(sessionStorageKey);
if (!legacyRaw) return;
const saved = JSON.parse(legacyRaw);
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
sessionStorage.setItem(sessionStorageKey, JSON.stringify(saved));
localStorage.removeItem(sessionStorageKey);
}
} catch {}
}
@@ -1838,6 +1878,8 @@ function renderPage(mode = "full") {
return;
}
const preserveFeedback = options.preserveFeedback === true;
const selectionToken = (workbenchState.accountSelectionToken || 0) + 1;
workbenchState.accountSelectionToken = selectionToken;
workbenchState.selectedAccountId = accountId;
workbenchState.loadingAccountId = accountId;
if (!preserveFeedback) {
@@ -1851,6 +1893,9 @@ function renderPage(mode = "full") {
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => []),
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=1000").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
]);
if (selectionToken !== workbenchState.accountSelectionToken) {
return;
}
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
workbenchState.videoItems = safeArray(results[2]?.items);
@@ -1871,10 +1916,15 @@ function renderPage(mode = "full") {
}
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage || "工作台已加载。";
} catch (error) {
if (selectionToken !== workbenchState.accountSelectionToken) {
return;
}
analysisFeedbackEl.textContent = "加载工作台失败: " + error.message;
} finally {
workbenchState.loadingAccountId = "";
renderAccountList();
if (selectionToken === workbenchState.accountSelectionToken) {
workbenchState.loadingAccountId = "";
renderAccountList();
}
}
}
@@ -2001,7 +2051,11 @@ function renderPage(mode = "full") {
if (saved.profileUrl) document.getElementById("profile-url").value = saved.profileUrl;
if (saved.backendUrl) document.getElementById("backend-url").value = saved.backendUrl;
if (saved.username) document.getElementById("username").value = saved.username;
if (saved.token) tokenInputEl.value = saved.token;
if (saved.token) {
persistCaptureToken(saved.token);
delete saved.token;
localStorage.setItem(storageKey, JSON.stringify(saved));
}
if (saved.note) document.getElementById("note").value = saved.note;
if (saved.maxVideos !== undefined) document.getElementById("max-videos").value = saved.maxVideos;
if (saved.syncEnabled !== undefined) document.getElementById("sync-enabled").checked = Boolean(saved.syncEnabled);
@@ -2017,7 +2071,6 @@ function renderPage(mode = "full") {
profileUrl: payload.profileUrl,
backendUrl: payload.backendUrl,
username: payload.username,
token: payload.token,
note: payload.note,
maxVideos: payload.maxVideos,
syncEnabled: payload.syncEnabled,
@@ -2026,6 +2079,7 @@ function renderPage(mode = "full") {
allowCreatorCenterFallback: payload.allowCreatorCenterFallback
};
localStorage.setItem(storageKey, JSON.stringify(saved));
persistCaptureToken(payload.token || "");
}
form.addEventListener("submit", async (event) => {
@@ -2037,8 +2091,8 @@ function renderPage(mode = "full") {
password: document.getElementById("password").value,
storyforgeUsername: document.getElementById("username").value.trim(),
storyforgePassword: document.getElementById("password").value,
token: tokenInputEl.value.trim(),
storyforgeToken: tokenInputEl.value.trim(),
token: tokenInputEl.value.trim() || getStoredCaptureToken(),
storyforgeToken: tokenInputEl.value.trim() || getStoredCaptureToken(),
note: document.getElementById("note").value.trim(),
maxVideos: document.getElementById("max-videos").value,
syncEnabled: document.getElementById("sync-enabled").checked,
@@ -2074,7 +2128,11 @@ function renderPage(mode = "full") {
tokenInputEl.addEventListener("input", updateTokenUi);
tokenDetailsEl.addEventListener("toggle", () => {
if (tokenDetailsEl.open) {
tokenSummaryEl.textContent = tokenInputEl.value.trim() ? "已填写 Token" : "填写后可跳过密码";
tokenSummaryEl.textContent = tokenInputEl.value.trim()
? "已填写 Token"
: getStoredCaptureToken()
? "Token 已保存到当前会话"
: "填写后可跳过密码";
} else {
updateTokenUi();
}

View File

@@ -2,20 +2,33 @@
set -eu
BASE_URL="${STORYFORGE_BASE_URL:-http://127.0.0.1:8081}"
USERNAME="${STORYFORGE_USERNAME:-kris}"
PASSWORD="${STORYFORGE_PASSWORD:-Asd123456.}"
USERNAME="${STORYFORGE_USERNAME:-storyforge-admin}"
PASSWORD="${STORYFORGE_PASSWORD:-}"
ACCOUNT_ID="${STORYFORGE_SMOKE_ACCOUNT_ID:-dyacct_c2b62842b228406cb48f05fac16fdfdf}"
if [ -z "$PASSWORD" ]; then
echo "STORYFORGE_PASSWORD is required. Export the bootstrap super-admin password before running smoke_business.sh." >&2
exit 1
fi
python3 - <<'PY'
import json
import os
import urllib.request
base = os.environ.get("BASE_URL", "http://127.0.0.1:8081").rstrip("/")
username = os.environ.get("USERNAME", "kris")
password = os.environ.get("PASSWORD", "Asd123456.")
username = os.environ.get("USERNAME", "storyforge-admin")
password = os.environ.get("PASSWORD", "")
account_id = os.environ.get("ACCOUNT_ID", "dyacct_c2b62842b228406cb48f05fac16fdfdf")
if not password:
raise SystemExit("STORYFORGE_PASSWORD is required")
with urllib.request.urlopen(base + "/readyz", timeout=20) as resp:
ready = json.load(resp)
if not ready.get("ready"):
raise SystemExit("collector readyz is not healthy")
login_req = urllib.request.Request(
base + "/v2/auth/login",
data=json.dumps({"username": username, "password": password}).encode(),

View File

@@ -19,7 +19,7 @@ import time
import urllib.request
checks = [
("collector", "http://127.0.0.1:8081/healthz"),
("collector", "http://127.0.0.1:8081/readyz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]
@@ -43,6 +43,6 @@ if pending:
PY
echo "business started"
echo "collector: http://127.0.0.1:8081/healthz"
echo "collector: http://127.0.0.1:8081/readyz"
echo "n8n: http://127.0.0.1:5670/healthz"
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"

View File

@@ -11,7 +11,7 @@ python3 - <<'PY'
import urllib.request
for name, url in [
("collector", "http://127.0.0.1:8081/healthz"),
("collector", "http://127.0.0.1:8081/readyz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]: