From dd619448e79266949fecf906e131970f04002ec1 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 26 Mar 2026 09:08:41 +0800 Subject: [PATCH] feat: harden storyforge runtime and repo boundary --- .env.example | 6 +- README.md | 21 +- android-app/README.md | 33 +- android-app/app/build.gradle.kts | 2 +- android-app/app/src/main/AndroidManifest.xml | 9 - .../java/com/aiglasses/app/data/ApiClient.kt | 3 +- .../app/storyforge/StoryForgeRepository.kt | 2 +- .../app/storyforge/StoryForgeScreen.kt | 11 +- .../app/storyforge/StoryForgeSessionStore.kt | 46 +- .../app/storyforge/StoryForgeViewModel.kt | 2 + .../main/res/xml/network_security_config.xml | 8 +- collector-service/app/core_main.py | 125 +++++- deploy/storyforge-collector.service.example | 4 + docker-compose.yml | 7 +- docs/LAN_E2E_GUIDE_2026-03-18.md | 22 +- docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md | 47 +++ n8n/README.md | 13 +- n8n/workflows/storyforge-ai-video.json | 4 +- n8n/workflows/storyforge-analysis.json | 4 +- .../storyforge-content-source-sync.json | 4 +- n8n/workflows/storyforge-real-cut.json | 4 +- scripts/check_repo_baseline.sh | 56 +++ scripts/douyin-browser-capture/README.md | 2 + .../douyin-browser-capture/control_panel.mjs | 82 +++- scripts/smoke_business.sh | 21 +- scripts/start_business.sh | 4 +- scripts/status_business.sh | 2 +- web/storyforge-web-v4/README.md | 6 +- web/storyforge-web-v4/assets/app.js | 394 ++++++++---------- .../assets/storyforge-api-client.js | 111 +++++ .../assets/storyforge-platform-runtime.js | 193 +++++++++ .../assets/storyforge-session-store.js | 93 +++++ web/storyforge-web-v4/index.html | 3 + 33 files changed, 1028 insertions(+), 316 deletions(-) create mode 100644 docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md create mode 100755 scripts/check_repo_baseline.sh create mode 100644 web/storyforge-web-v4/assets/storyforge-api-client.js create mode 100644 web/storyforge-web-v4/assets/storyforge-platform-runtime.js create mode 100644 web/storyforge-web-v4/assets/storyforge-session-store.js diff --git a/.env.example b/.env.example index 02b25c7..a7012e5 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,15 @@ LOCAL_OPENAI_API_KEY= N8N_BASE_URL=http://127.0.0.1:5670 # Dockerized collector should use the internal n8n service address. COLLECTOR_N8N_BASE_URL=http://n8n:5678 +BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin +BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__ +BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin N8N_ANALYSIS_WEBHOOK_PATH=/webhook/storyforge-analysis N8N_REAL_CUT_WEBHOOK_PATH=/webhook/storyforge-real-cut N8N_AI_VIDEO_WEBHOOK_PATH=/webhook/storyforge-ai-video N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH=/webhook/storyforge-content-source-sync -ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret +ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__ +STORYFORGE_INTERNAL_BASE_URL=http://collector:8081 CUTVIDEO_BASE_URL= CUTVIDEO_API_KEY= CUTVIDEO_BASE_CONFIG=example.job.yaml diff --git a/README.md b/README.md index 3204f0d..0124ed1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ StoryForge 现在拆成独立项目目录,和 `AI-glasses` 分开维护。 +仓库边界和维护约束见:[StoryForge 仓库边界说明](./docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md)。 + ## 目录 - `android-app/`:StoryForge Android 客户端 @@ -77,8 +79,8 @@ npx playwright install chromium cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture npm run capture -- \ --profile-url https://www.douyin.com/user/your_account \ - --storyforge-username kris \ - --storyforge-password 'Asd123456.' + --storyforge-username storyforge-admin \ + --storyforge-password 'your_admin_password' ``` 说明: @@ -107,6 +109,14 @@ cp .env.example .env docker compose up -d --build ``` +首次启动前,至少补齐这些配置: + +```bash +ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret +BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin +BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password +``` + 如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在: ```bash @@ -134,10 +144,9 @@ N8N_BASE_URL=http://127.0.0.1:5670 - `cli-proxy-api`:`http://127.0.0.1:8317` - 公网入口:`https://storyforge.hyzq.net/` -默认会创建最高权限账号: - -- `kris` -- `Asd123456.` +首次启动时,如果数据库里还没有 `super_admin`,`collector-service` 会按 +`BOOTSTRAP_SUPERADMIN_USERNAME / BOOTSTRAP_SUPERADMIN_PASSWORD / BOOTSTRAP_SUPERADMIN_DISPLAY_NAME` +创建最高权限账号。未配置时不会再自动写入默认口令账号。 ## 当前架构 diff --git a/android-app/README.md b/android-app/README.md index 40f308a..ea221ee 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -1,37 +1,28 @@ -# AI Glasses Android App +# StoryForge Android App -Demo Android client for backend API validation and BLE integration scaffold. +StoryForge Android client for the current workspace entry: authentication, content import, agent management, production tracking, and OTA install. -## What is implemented +## Current flow -- Backend API calls: - - `bind-confirm` - - `create session` - - `stop session` - - `device status` -- Compose UI for debug flow -- Hichips BLE protocol manager: - - service/char: `3D20(3D21/3D22/3D23)`, `5DC0(5DC1/5DC2/5DC3)` - - packet codec: `HICH + Command + Index + Length + CRC16 + Data + IPSE` - - handshake flow (`AG_CMD_HS_DEV_UUID` -> `AG_CMD_HS_APP_UUID` -> `AG_CMD_HS_DEV_INFO`) - - wake-up audio uplink (`ASR_*` commands, audio from `5DC2`) - - camera trigger (`AG_CMD_P_TAKE_START`) and thumbnail events -- New "开始对话(硬件)" button: - - BLE scan/connect -> handshake -> backend bind/create session - - start wake-up audio stream + periodic camera capture - - app reports aggregated audio/camera relay stats to backend events +- Compose-based StoryForge shell +- Secure session storage for base URL and token +- Backend API calls for login, project/content import, agent management, and update checks +- Local video picking for learning tasks +- OTA download and install from the "我的" tab ## Default backend -The app is hardcoded to: +The app defaults to: `https://storyforge.hyzq.net` +For local development, cleartext HTTP is only allowed for `localhost`, `127.0.0.1`, and `10.0.2.2`. + ## Build APK Open this folder in Android Studio: -`/Users/kris/code/AI-glasses/android-app` +`/Users/kris/code/StoryForge-gitea/android-app` Then run: diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index fb9a458..366c677 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -54,9 +54,9 @@ dependencies { androidTestImplementation(composeBom) implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.activity:activity-compose:1.10.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("com.google.android.material:material:1.12.0") implementation("androidx.compose.ui:ui") diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 3b38ddf..b6e1e6f 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -2,14 +2,6 @@ - - - - - - - - createService(baseUrl: String): T { val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE } val client = OkHttpClient.Builder() .protocols(listOf(Protocol.HTTP_1_1)) diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt index 9c75afb..e270801 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt @@ -259,7 +259,7 @@ class StoryForgeRepository(private val context: Context) { private fun buildClient(connection: StoryForgeConnectionInfo, token: String): OkHttpClient { val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE } return OkHttpClient.Builder() .protocols(listOf(Protocol.HTTP_1_1)) diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt index 9811ca0..a629e74 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt @@ -41,7 +41,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -185,7 +188,9 @@ private fun AuthScreen( onValueChange = vm::updatePassword, modifier = Modifier.fillMaxWidth(), label = { Text("密码") }, - singleLine = true + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) ) Button( onClick = { if (state.authMode == StoryForgeAuthMode.Login) vm.login() else vm.registerAccount() }, @@ -857,7 +862,9 @@ private fun MineTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onInstall onValueChange = vm::updateNewModelApiKey, modifier = Modifier.fillMaxWidth(), label = { Text("API Key") }, - minLines = 2 + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) ) Spacer(modifier = Modifier.height(12.dp)) Button(onClick = vm::createModelProfile) { diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt index cd95c4e..b716921 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeSessionStore.kt @@ -1,6 +1,8 @@ package com.aiglasses.app.storyforge import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import com.aiglasses.app.BuildConfig data class SavedStoryForgeSession( @@ -9,7 +11,13 @@ data class SavedStoryForgeSession( ) class StoryForgeSessionStore(context: Context) { - private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val appContext = context.applicationContext + private val legacyPrefs = appContext.getSharedPreferences(PREFS_NAME_LEGACY, Context.MODE_PRIVATE) + private val prefs: android.content.SharedPreferences by lazy { createSecurePrefs() } + + init { + migrateLegacySessionIfNeeded() + } fun load(): SavedStoryForgeSession = SavedStoryForgeSession( baseUrl = migrateBaseUrl(prefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty()), @@ -37,10 +45,12 @@ class StoryForgeSessionStore(context: Context) { fun clearAll() { prefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply() + legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply() } private companion object { - private const val PREFS_NAME = "storyforge_session" + private const val PREFS_NAME_LEGACY = "storyforge_session" + private const val PREFS_NAME_SECURE = "storyforge_session_secure" private const val KEY_BASE_URL = "base_url" private const val KEY_TOKEN = "token" private const val LEGACY_DOMAIN_URL = "http://test.hyzq.net:8081" @@ -48,6 +58,38 @@ class StoryForgeSessionStore(context: Context) { private const val LEGACY_IP_URL = "http://111.231.132.51:8081" } + private fun createSecurePrefs(): android.content.SharedPreferences { + return runCatching { + val masterKey = MasterKey.Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + appContext, + PREFS_NAME_SECURE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + }.getOrElse { + throw IllegalStateException("Unable to create secure session storage", it) + } + } + + private fun migrateLegacySessionIfNeeded() { + if (prefs.contains(KEY_BASE_URL) || prefs.contains(KEY_TOKEN)) { + return + } + if (!legacyPrefs.contains(KEY_BASE_URL) && !legacyPrefs.contains(KEY_TOKEN)) { + return + } + val baseUrl = migrateBaseUrl( + legacyPrefs.getString(KEY_BASE_URL, BuildConfig.DEFAULT_STORYFORGE_BASE_URL).orEmpty() + ) + val token = legacyPrefs.getString(KEY_TOKEN, "").orEmpty() + save(baseUrl, token) + legacyPrefs.edit().remove(KEY_BASE_URL).remove(KEY_TOKEN).apply() + } + private fun migrateBaseUrl(baseUrl: String): String { val trimmed = baseUrl.trim() return when { diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt index b2267bb..925bdb2 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt @@ -349,6 +349,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati appendTimeline("账号 ${account.username} 已注册,等待主管理员审批") _state.value = _state.value.copy( authMode = StoryForgeAuthMode.Login, + password = "", statusMessage = "注册成功,请等待主管理员审批", errorMessage = "" ) @@ -375,6 +376,7 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati isAuthenticated = true, isApproved = account.approval_status == "approved", account = account, + password = "", statusMessage = if (account.approval_status == "approved") "登录成功,正在同步工作台" else "账号待主管理员审批", errorMessage = "" ) diff --git a/android-app/app/src/main/res/xml/network_security_config.xml b/android-app/app/src/main/res/xml/network_security_config.xml index be5bdbc..e5a84f4 100644 --- a/android-app/app/src/main/res/xml/network_security_config.xml +++ b/android-app/app/src/main/res/xml/network_security_config.xml @@ -1,5 +1,9 @@ - + + + localhost + 127.0.0.1 + 10.0.2.2 + - diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py index 487596d..f41cd45 100644 --- a/collector-service/app/core_main.py +++ b/collector-service/app/core_main.py @@ -19,7 +19,7 @@ from urllib.parse import quote, urljoin, urlparse from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel, Field from .database import Database, utc_now @@ -50,6 +50,9 @@ N8N_REAL_CUT_WEBHOOK_PATH = os.getenv("N8N_REAL_CUT_WEBHOOK_PATH", "/webhook/sto N8N_AI_VIDEO_WEBHOOK_PATH = os.getenv("N8N_AI_VIDEO_WEBHOOK_PATH", "/webhook/storyforge-ai-video") N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH = os.getenv("N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH", "/webhook/storyforge-content-source-sync") ORCHESTRATOR_SHARED_SECRET = os.getenv("ORCHESTRATOR_SHARED_SECRET", "") +BOOTSTRAP_SUPERADMIN_USERNAME = os.getenv("BOOTSTRAP_SUPERADMIN_USERNAME", "") +BOOTSTRAP_SUPERADMIN_PASSWORD = os.getenv("BOOTSTRAP_SUPERADMIN_PASSWORD", "") +BOOTSTRAP_SUPERADMIN_DISPLAY_NAME = os.getenv("BOOTSTRAP_SUPERADMIN_DISPLAY_NAME", "StoryForge Admin") CUTVIDEO_BASE_URL = os.getenv("CUTVIDEO_BASE_URL", "http://192.168.31.18:7860") CUTVIDEO_API_KEY = os.getenv("CUTVIDEO_API_KEY", "") HUOBAO_BASE_URL = os.getenv("HUOBAO_BASE_URL", "http://127.0.0.1:5678") @@ -61,6 +64,16 @@ CUTVIDEO_UPLOAD_TIMEOUT_SEC = int(os.getenv("CUTVIDEO_UPLOAD_TIMEOUT_SEC", "1800 HUOBAO_POLL_INTERVAL_SEC = int(os.getenv("HUOBAO_POLL_INTERVAL_SEC", "10")) HUOBAO_MAX_WAIT_SEC = int(os.getenv("HUOBAO_MAX_WAIT_SEC", "900")) +INVALID_CONFIG_VALUES = { + "", + "__set_a_strong_shared_secret__", + "__set_me__", + "__change_me__", + "change-me", + "changeme", + "storyforge-local-secret", +} + for path in (DATA_DIR, DOWNLOADS_DIR, JOBS_DIR, MODELS_DIR): path.mkdir(parents=True, exist_ok=True) @@ -355,6 +368,37 @@ def mask_api_key(value: str) -> str: return f"{value[:4]}***{value[-4:]}" +def normalize_config_value(value: str | None) -> str: + return str(value or "").strip() + + +def is_placeholder_config(value: str | None) -> bool: + normalized = normalize_config_value(value) + lowered = normalized.lower() + if lowered in INVALID_CONFIG_VALUES: + return True + if lowered.startswith("__set_") and lowered.endswith("__"): + return True + return False + + +def orchestrator_secret_configured() -> bool: + return not is_placeholder_config(ORCHESTRATOR_SHARED_SECRET) + + +def bootstrap_superadmin_credentials() -> tuple[str, str, str]: + return ( + normalize_config_value(BOOTSTRAP_SUPERADMIN_USERNAME), + normalize_config_value(BOOTSTRAP_SUPERADMIN_PASSWORD), + normalize_config_value(BOOTSTRAP_SUPERADMIN_DISPLAY_NAME) or "StoryForge Admin", + ) + + +def bootstrap_superadmin_configured() -> bool: + username, password, _ = bootstrap_superadmin_credentials() + return bool(username) and not is_placeholder_config(password) + + def normalize_model_profile(row: dict[str, Any]) -> dict[str, Any]: return { "id": row["id"], @@ -1754,7 +1798,9 @@ def require_super_admin(account: dict[str, Any] = Depends(require_auth)) -> dict def require_orchestrator(x_orchestrator_secret: str | None = Header(default=None)) -> bool: - if ORCHESTRATOR_SHARED_SECRET and x_orchestrator_secret != ORCHESTRATOR_SHARED_SECRET: + if not orchestrator_secret_configured(): + raise HTTPException(status_code=503, detail="Orchestrator secret is not configured") + if x_orchestrator_secret != ORCHESTRATOR_SHARED_SECRET: raise HTTPException(status_code=401, detail="Invalid orchestrator secret") return True @@ -2478,7 +2524,7 @@ def create_job_record( async def wait_for_huobao_image(image_id: str | int) -> dict[str, Any]: - deadline = now_ts() + CUTVIDEO_MAX_WAIT_SEC + deadline = now_ts() + HUOBAO_MAX_WAIT_SEC last_payload: dict[str, Any] = {} while True: last_payload = await huobao_client.get_image(str(image_id)) @@ -2753,6 +2799,52 @@ def on_startup() -> None: seed_defaults() +def collect_readiness() -> dict[str, Any]: + checks: dict[str, Any] = {} + ready = True + + try: + db_ok = bool(db.fetch_one("SELECT 1 AS ok")) + checks["database"] = {"ok": db_ok} + ready = ready and db_ok + except Exception as exc: + checks["database"] = {"ok": False, "error": str(exc)} + ready = False + + required_tables = ["accounts", "projects", "jobs", "model_profiles"] + missing_tables = [name for name in required_tables if not db.table_exists(name)] + checks["schema"] = {"ok": not missing_tables, "missing_tables": missing_tables} + ready = ready and not missing_tables + + missing_dirs = [str(path) for path in (DATA_DIR, DOWNLOADS_DIR, JOBS_DIR, MODELS_DIR) if not path.exists()] + checks["storage"] = {"ok": not missing_dirs, "missing_dirs": missing_dirs} + ready = ready and not missing_dirs + + try: + row = db.fetch_one("SELECT COUNT(*) AS count FROM accounts WHERE role = ?", ("super_admin",)) + super_admin_count = int((row or {}).get("count") or 0) + except Exception as exc: + checks["super_admin"] = {"ok": False, "error": str(exc)} + ready = False + else: + checks["super_admin"] = { + "ok": super_admin_count > 0 or bootstrap_superadmin_configured(), + "present_count": super_admin_count, + "bootstrap_configured": bootstrap_superadmin_configured(), + } + ready = ready and checks["super_admin"]["ok"] + + checks["orchestrator_secret"] = {"ok": orchestrator_secret_configured()} + ready = ready and checks["orchestrator_secret"]["ok"] + + checks["n8n"] = { + "required_for_async_pipelines": bool(N8N_BASE_URL), + **probe_http(N8N_BASE_URL, "/healthz", timeout=3.0), + } + + return {"status": "ok" if ready else "degraded", "ready": ready, "checks": checks} + + def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]: if not url: return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""} @@ -2889,9 +2981,17 @@ def healthz() -> dict[str, Any]: "cutvideoUploadTimeoutSec": CUTVIDEO_UPLOAD_TIMEOUT_SEC, "huobaoBaseUrl": HUOBAO_BASE_URL, "liveRecorderBaseUrl": LIVE_RECORDER_BASE_URL, + "orchestratorSecretConfigured": orchestrator_secret_configured(), + "bootstrapSuperadminConfigured": bootstrap_superadmin_configured(), } +@app.get("/readyz") +def readyz() -> JSONResponse: + payload = collect_readiness() + return JSONResponse(status_code=200 if payload["ready"] else 503, content=payload) + + @app.get("/v2/integrations/health") def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]: _ = account @@ -3234,9 +3334,13 @@ def seed_defaults() -> None: now, ), ) - if not db.fetch_one("SELECT id FROM accounts WHERE username = ?", ("kris",)): + bootstrap_username, bootstrap_password, bootstrap_display_name = bootstrap_superadmin_credentials() + if not db.fetch_one("SELECT id FROM accounts WHERE role = 'super_admin' LIMIT 1") and bootstrap_username: + if is_placeholder_config(bootstrap_password): + print("StoryForge bootstrap skipped: BOOTSTRAP_SUPERADMIN_PASSWORD is not set to a non-placeholder value.") + return account_id = make_id("acct") - password_hash, password_salt = create_password_hash("Asd123456.") + password_hash, password_salt = create_password_hash(bootstrap_password) now = utc_now() model_row = db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1") db.execute( @@ -3249,10 +3353,10 @@ def seed_defaults() -> None: """, ( account_id, - "kris", + bootstrap_username, password_hash, password_salt, - "Kris", + bootstrap_display_name, "super_admin", "approved", account_id, @@ -3262,8 +3366,8 @@ def seed_defaults() -> None: now, ), ) - project = ensure_default_project(account_id, username="kris") - kb = ensure_user_kb(account_id, project["id"], username="kris") + project = ensure_default_project(account_id, username=bootstrap_username) + kb = ensure_user_kb(account_id, project["id"], username=bootstrap_username) assistant_id = make_id("assistant") db.execute( """ @@ -3287,6 +3391,7 @@ def seed_defaults() -> None: "INSERT INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)", (assistant_id, kb["id"]), ) + print(f"StoryForge bootstrap: created super_admin account '{bootstrap_username}'.") @app.post("/v2/auth/register") @@ -4463,7 +4568,7 @@ async def internal_real_cut_run( result={"cutvideo_submit": submit_result}, ) - deadline = now_ts() + HUOBAO_MAX_WAIT_SEC + deadline = now_ts() + CUTVIDEO_MAX_WAIT_SEC while True: run_fallback: dict[str, Any] | None = None try: diff --git a/deploy/storyforge-collector.service.example b/deploy/storyforge-collector.service.example index fd3840d..e9012f5 100644 --- a/deploy/storyforge-collector.service.example +++ b/deploy/storyforge-collector.service.example @@ -15,6 +15,10 @@ Environment=DEFAULT_EXTERNAL_BASE_URL=https://storyforge.hyzq.net Environment=LOCAL_OPENAI_BASE_URL=http://127.0.0.1:18317/v1 Environment=ASR_HTTP_BASE_URL=http://127.0.0.1:18088 Environment=N8N_BASE_URL=http://127.0.0.1:15670 +Environment=ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__ +Environment=BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin +Environment=BOOTSTRAP_SUPERADMIN_PASSWORD=__set_a_strong_password__ +Environment=BOOTSTRAP_SUPERADMIN_DISPLAY_NAME=StoryForge Admin Environment=HUOBAO_BASE_URL=http://127.0.0.1:15678 Environment=CUTVIDEO_BASE_URL=http://127.0.0.1:17860 Environment=LIVE_RECORDER_BASE_URL=http://127.0.0.1:19106 diff --git a/docker-compose.yml b/docker-compose.yml index c042683..14645ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: N8N_PORT: 5678 N8N_PROTOCOL: ${N8N_PROTOCOL:-http} WEBHOOK_URL: ${WEBHOOK_URL:-http://127.0.0.1:5670/} + STORYFORGE_INTERNAL_BASE_URL: ${STORYFORGE_INTERNAL_BASE_URL:-http://collector:8081} + STORYFORGE_ORCHESTRATOR_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__} GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai} N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false} @@ -37,7 +39,10 @@ services: N8N_REAL_CUT_WEBHOOK_PATH: ${N8N_REAL_CUT_WEBHOOK_PATH:-/webhook/storyforge-real-cut} N8N_AI_VIDEO_WEBHOOK_PATH: ${N8N_AI_VIDEO_WEBHOOK_PATH:-/webhook/storyforge-ai-video} N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH: ${N8N_CONTENT_SOURCE_SYNC_WEBHOOK_PATH:-/webhook/storyforge-content-source-sync} - ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret} + BOOTSTRAP_SUPERADMIN_USERNAME: ${BOOTSTRAP_SUPERADMIN_USERNAME:-} + BOOTSTRAP_SUPERADMIN_PASSWORD: ${BOOTSTRAP_SUPERADMIN_PASSWORD:-} + BOOTSTRAP_SUPERADMIN_DISPLAY_NAME: ${BOOTSTRAP_SUPERADMIN_DISPLAY_NAME:-StoryForge Admin} + ORCHESTRATOR_SHARED_SECRET: ${ORCHESTRATOR_SHARED_SECRET:-__set_a_strong_shared_secret__} CUTVIDEO_BASE_URL: ${CUTVIDEO_BASE_URL:-} CUTVIDEO_API_KEY: ${CUTVIDEO_API_KEY:-} CUTVIDEO_BASE_CONFIG: ${CUTVIDEO_BASE_CONFIG:-example.job.yaml} diff --git a/docs/LAN_E2E_GUIDE_2026-03-18.md b/docs/LAN_E2E_GUIDE_2026-03-18.md index 55c1966..3a847ea 100644 --- a/docs/LAN_E2E_GUIDE_2026-03-18.md +++ b/docs/LAN_E2E_GUIDE_2026-03-18.md @@ -15,7 +15,10 @@ cp .env.example .env - `N8N_BASE_URL=http://127.0.0.1:5670`,用于你在宿主机单独运行 `collector-service` - `COLLECTOR_N8N_BASE_URL=http://n8n:5678`,用于 Docker 里的 `collector` -- `ORCHESTRATOR_SHARED_SECRET=storyforge-local-secret` +- `ORCHESTRATOR_SHARED_SECRET=your_strong_shared_secret` +- `BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin` +- `BOOTSTRAP_SUPERADMIN_PASSWORD=your_strong_admin_password` +- `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081`,用于 Docker 内的 n8n 回调 `collector` - `CUTVIDEO_BASE_URL=http://:7860` - `CUTVIDEO_API_KEY=` 如果 Windows 服务启用了鉴权 - `HUOBAO_BASE_URL=http://127.0.0.1:5678` @@ -58,17 +61,18 @@ docker compose up -d --build 导入后: -- 检查每个 HTTP Request 节点的 `X-Orchestrator-Secret` -- 如果你改了 `.env` 的 secret,这里必须同步 +- 确认 n8n 运行环境里有 `STORYFORGE_INTERNAL_BASE_URL` +- 确认 n8n 运行环境里有 `STORYFORGE_ORCHESTRATOR_SECRET` +- 导入后的 HTTP Request 节点应从环境变量取值,不需要再逐个手改 secret ## 4. 登录与审批 -默认超级管理员: +首次启动前请先在 `.env` 或运行环境里设置 bootstrap 管理员: -- 用户名:`kris` -- 密码:`Asd123456.` +- 用户名:`BOOTSTRAP_SUPERADMIN_USERNAME` +- 密码:`BOOTSTRAP_SUPERADMIN_PASSWORD` -新用户注册后,需要用超级管理员审批。 +首次启动后,用这组账号登录;新用户注册后,仍然需要超级管理员审批。 ## 5. 内容分析链路验证 @@ -190,8 +194,8 @@ http://127.0.0.1:3618 cd /Users/kris/code/StoryForge-gitea/scripts/douyin-browser-capture npm run capture -- \ --profile-url https://www.douyin.com/user/your_account \ - --storyforge-username kris \ - --storyforge-password 'Asd123456.' + --storyforge-username storyforge-admin \ + --storyforge-password 'your_admin_password' ``` 说明: diff --git a/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md b/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md new file mode 100644 index 0000000..b93f0b1 --- /dev/null +++ b/docs/STORYFORGE_REPO_BOUNDARY_2026-03-26.md @@ -0,0 +1,47 @@ +# StoryForge 仓库边界说明 + +本文档用于固定 `StoryForge-gitea` 的维护边界,避免把 StoryForge 与 `AI Glasses` 误判成同一个项目。 + +## 基本原则 + +- `StoryForge` 与 `AI Glasses` 是两个独立项目,分别独立维护。 +- 当前仓库只负责 `StoryForge` 的产品、运行时、联调、部署与发布。 +- 后续在本仓库中看到的 `AI Glasses` 命名残留,应优先视为历史迁移残留或暂未完成的命名收口,不应直接推导为“需要删除 AI Glasses 项目代码”。 + +## 当前仓库内属于 StoryForge 的主维护范围 + +- `collector-service/`:StoryForge 后端与业务 API。 +- `web/storyforge-web-v4/`:StoryForge Web 工作台和前端壳。 +- `scripts/douyin-browser-capture/`:抖音浏览器辅助采集与工作台控制台。 +- `n8n/`:StoryForge 编排工作流导出与说明。 +- `android-app/`:当前 StoryForge Android 客户端入口。 +- `deploy/`:StoryForge 部署模板与网关配置。 +- `docs/`:StoryForge 审计、联调、实施与产品逻辑文档。 +- `docker-compose.yml`、`.env.example`、`scripts/start_business.sh`、`scripts/status_business.sh`、`scripts/smoke_business.sh`:当前 StoryForge 运行与联调基线。 + +## 需要特别注意的命名残留 + +以下内容说明 Android 客户端曾沿用旧命名空间,但当前业务入口已经是 StoryForge: + +- `android-app/app/src/main/java/com/aiglasses/app/`:Android 包名仍是 `com.aiglasses.app`。 +- `android-app/app/src/main/java/com/aiglasses/app/MainActivity.kt`:入口已经直接加载 `StoryForgeScreen` 与 `StoryForgeViewModel`。 +- `android-app/app/src/main/res/values/themes.xml`:主题名仍为 `Theme.AIGlasses`。 +- `android-app/app/build.gradle.kts`:构建命名空间仍与 `com.aiglasses.*` 保持一致。 + +这些文件目前应被视为 StoryForge Android 客户端的迁移残留,不属于“删除 AI Glasses 项目代码”的操作范围。若未来要统一命名,应作为独立重构任务推进,而不是在日常功能开发中顺手清除。 + +## 提交与同步边界 + +- 提交到 Gitea 时,只纳入与 StoryForge 独立维护直接相关的改动。 +- 原型、概念稿、临时预览图等目录只有在明确属于本轮 StoryForge 任务时才纳入提交。 +- 本轮同步明确排除以下无关本次目标的本地变更: + - `concepts/studio-workbench/README.md` + - `.tmp-previews-b/` + +## 本轮独立维护改动的收口范围 + +- 后端与部署安全收口:去掉默认超级管理员口令依赖,强化 orchestrator secret 校验,新增 `readyz`,修复 `huobao/cutvideo` 超时串线。 +- n8n 工作流收口:内部回调地址与 secret 改为环境变量注入。 +- Web 稳定性与结构收口:修账号切换竞态,收紧会话存储,引入平台能力 gate,并拆出首批运行时模块。 +- Android 安全收口:会话加密存储、明文流量白名单、敏感输入遮罩、日志级别收紧。 +- 基线验证:新增 `scripts/check_repo_baseline.sh` 作为统一回归入口。 diff --git a/n8n/README.md b/n8n/README.md index 67a72e0..a53439b 100644 --- a/n8n/README.md +++ b/n8n/README.md @@ -11,17 +11,20 @@ ## 约定 -- 当前这套 live 链路默认通过 `http://host.docker.internal:8081` 调用宿主机 collector-service -- 如果整套流程完全运行在 Docker 内部,再把工作流里的内部调用地址切回 `http://collector:8081` -- 内部调用头部使用 `X-Orchestrator-Secret: storyforge-local-secret` -- 如果你修改了 `.env` 里的 `ORCHESTRATOR_SHARED_SECRET`,导入工作流后需要同步更新对应 HTTP Request 节点 +- 工作流内部调用地址通过 `STORYFORGE_INTERNAL_BASE_URL` 注入 +- 工作流内部调用密钥通过 `STORYFORGE_ORCHESTRATOR_SECRET` 注入 +- Docker Compose 默认把它们映射成: + - `STORYFORGE_INTERNAL_BASE_URL=http://collector:8081` + - `STORYFORGE_ORCHESTRATOR_SECRET=$ORCHESTRATOR_SHARED_SECRET` +- 如果你的 n8n 跑在宿主机或云服务器,而 collector 跑在别处,只需要改这两个环境变量,不要再手改 workflow JSON ## 导入 1. 先执行 `docker compose up -d n8n collector` 2. 打开 `http://127.0.0.1:5670` 3. 从 UI 导入本目录下的 4 个 JSON -4. 激活工作流 +4. 确认 n8n 运行环境里已设置 `STORYFORGE_INTERNAL_BASE_URL` 和 `STORYFORGE_ORCHESTRATOR_SECRET` +5. 激活工作流 ## Webhook 路径 diff --git a/n8n/workflows/storyforge-ai-video.json b/n8n/workflows/storyforge-ai-video.json index 606b953..b06ac26 100644 --- a/n8n/workflows/storyforge-ai-video.json +++ b/n8n/workflows/storyforge-ai-video.json @@ -21,13 +21,13 @@ { "parameters": { "method": "POST", - "url": "={{'http://host.docker.internal:8081/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}", + "url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/ai-video/render?job_id=' + ($json.body.job_id || $json.body.jobId)}}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Orchestrator-Secret", - "value": "storyforge-local-secret" + "value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}" } ] }, diff --git a/n8n/workflows/storyforge-analysis.json b/n8n/workflows/storyforge-analysis.json index 75ce3ae..84cc2c5 100644 --- a/n8n/workflows/storyforge-analysis.json +++ b/n8n/workflows/storyforge-analysis.json @@ -21,13 +21,13 @@ { "parameters": { "method": "POST", - "url": "={{'http://host.docker.internal:8081/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}", + "url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/analyze?job_id=' + ($json.body.job_id || $json.body.jobId)}}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Orchestrator-Secret", - "value": "storyforge-local-secret" + "value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}" } ] }, diff --git a/n8n/workflows/storyforge-content-source-sync.json b/n8n/workflows/storyforge-content-source-sync.json index fc14f9d..0aa3c9b 100644 --- a/n8n/workflows/storyforge-content-source-sync.json +++ b/n8n/workflows/storyforge-content-source-sync.json @@ -21,13 +21,13 @@ { "parameters": { "method": "POST", - "url": "={{'http://host.docker.internal:8081/internal/jobs/steps/content-source-sync?job_id=' + ($json.body.job_id || $json.body.jobId)}}", + "url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/content-source-sync?job_id=' + ($json.body.job_id || $json.body.jobId)}}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Orchestrator-Secret", - "value": "storyforge-local-secret" + "value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}" } ] }, diff --git a/n8n/workflows/storyforge-real-cut.json b/n8n/workflows/storyforge-real-cut.json index 3b45dda..b8e8c7d 100644 --- a/n8n/workflows/storyforge-real-cut.json +++ b/n8n/workflows/storyforge-real-cut.json @@ -21,13 +21,13 @@ { "parameters": { "method": "POST", - "url": "={{'http://host.docker.internal:8081/internal/jobs/steps/real-cut/run?job_id=' + ($json.body.job_id || $json.body.jobId)}}", + "url": "={{($env.STORYFORGE_INTERNAL_BASE_URL || 'http://collector:8081').replace(/\\/$/, '') + '/internal/jobs/steps/real-cut/run?job_id=' + ($json.body.job_id || $json.body.jobId)}}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Orchestrator-Secret", - "value": "storyforge-local-secret" + "value": "={{$env.STORYFORGE_ORCHESTRATOR_SECRET || ''}}" } ] }, diff --git a/scripts/check_repo_baseline.sh b/scripts/check_repo_baseline.sh new file mode 100755 index 0000000..54510f2 --- /dev/null +++ b/scripts/check_repo_baseline.sh @@ -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" diff --git a/scripts/douyin-browser-capture/README.md b/scripts/douyin-browser-capture/README.md index cfe1f8f..dce5488 100644 --- a/scripts/douyin-browser-capture/README.md +++ b/scripts/douyin-browser-capture/README.md @@ -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 ` + +