diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7211ed..cfd48b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
### Added
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
+- Added production configuration validation for secrets, encryption keys, and the public HTTPS URL, plus an administrator-facing status check.
### Fixed
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 1c03108..e700442 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
+import { checkRuntimeConfig } from "@/lib/config";
export const dynamic = "force-dynamic";
@@ -12,6 +13,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const errors = db.prepare("SELECT scope,message,created_at FROM error_events ORDER BY id DESC LIMIT 30").all() as any[];
const audits = db.prepare("SELECT a.action,a.target_type,a.target_id,a.metadata_json,a.created_at,u.username FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.id DESC LIMIT 50").all() as any[];
const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[];
+ const config = checkRuntimeConfig();
return <>
管理員
{query.updated && 管理操作已完成。
}{query.error && 管理操作未完成。
}
待審核檢舉
{reports.length ? {reports.map((report) => - 貼文 #{report.post_id} · 檢舉者 @{report.username || "已刪除使用者"}
{report.reason}
{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")} )}
: 沒有待審核檢舉。
}
@@ -19,5 +21,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
最近系統錯誤
{errors.length ? {errors.map((error, index) => - {error.scope} · {error.message}
{new Date(error.created_at + "Z").toLocaleString("zh-TW")} )}
: 尚無記錄。
}
稽核紀錄
{audits.length ? {audits.map((item, index) => - {item.action} · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}
{new Date(item.created_at + "Z").toLocaleString("zh-TW")} )}
: 尚無稽核紀錄。
}
所有來源
{sources.map((source) => - {source.name} · {source.is_enabled ? source.sync_status : "disabled"} · 成員 {source.member_count}
#{source.id} · {source.base_url} · 上次同步:{source.last_synced_at || "尚未完成"} )}
+ 正式環境設定
{config.ok ? 設定檢查通過。
: {config.errors.map((error) => - {error}
)}
}
>;
}
diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts
new file mode 100644
index 0000000..0a77e93
--- /dev/null
+++ b/app/api/admin/config/route.ts
@@ -0,0 +1,4 @@
+import { NextResponse } from "next/server";
+import { requireUser } from "@/lib/auth";
+import { checkRuntimeConfig } from "@/lib/config";
+export async function GET() { try { const user = await requireUser(); if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json(checkRuntimeConfig()); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } }
diff --git a/lib/config.ts b/lib/config.ts
new file mode 100644
index 0000000..d5e1551
--- /dev/null
+++ b/lib/config.ts
@@ -0,0 +1,10 @@
+export type ConfigStatus = { ok: boolean; errors: string[] };
+export function checkRuntimeConfig(env: NodeJS.ProcessEnv = process.env): ConfigStatus {
+ if (env.HUB_BUILD === "1" || env.NODE_ENV !== "production") return { ok: true, errors: [] };
+ const errors: string[] = []; const session = env.SESSION_SECRET || ""; const encryption = env.TOKEN_ENCRYPTION_KEY || ""; const publicUrl = env.NEXT_PUBLIC_APP_URL || "";
+ if (session.length < 32 || session === "development-only-change-me" || session.includes("replace-with")) errors.push("SESSION_SECRET must be a non-default value of at least 32 characters");
+ if (!/^[0-9a-f]{64}$/i.test(encryption)) errors.push("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters");
+ try { if (new URL(publicUrl).protocol !== "https:") throw new Error(); } catch { errors.push("NEXT_PUBLIC_APP_URL must be an HTTPS URL in production"); }
+ return { ok: errors.length === 0, errors };
+}
+export function requireRuntimeConfig() { const status = checkRuntimeConfig(); if (!status.ok) throw new Error(`Invalid production configuration: ${status.errors.join("; ")}`); }
diff --git a/lib/db.ts b/lib/db.ts
index 5537ce6..0c75698 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -1,7 +1,9 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
+import { requireRuntimeConfig } from "@/lib/config";
+requireRuntimeConfig();
const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
export const db = new Database(path);
diff --git a/tests/config.test.ts b/tests/config.test.ts
new file mode 100644
index 0000000..552ee2c
--- /dev/null
+++ b/tests/config.test.ts
@@ -0,0 +1,10 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { checkRuntimeConfig } from "../lib/config";
+
+test("requires secure production secrets and a public HTTPS origin", () => {
+ assert.equal(checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "x".repeat(32), TOKEN_ENCRYPTION_KEY: "a".repeat(64), NEXT_PUBLIC_APP_URL: "https://hub.example.test" }).ok, true);
+ const invalid = checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "development-only-change-me", TOKEN_ENCRYPTION_KEY: "wrong", NEXT_PUBLIC_APP_URL: "http://localhost:8088" });
+ assert.equal(invalid.ok, false); assert.equal(invalid.errors.length, 3);
+ assert.equal(checkRuntimeConfig({ NODE_ENV: "test" }).ok, true);
+});