feat: validate production runtime config

This commit is contained in:
2026-07-19 12:47:42 +08:00
parent bc58981de2
commit 6a10ddb6e5
6 changed files with 30 additions and 0 deletions
+1
View File
@@ -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
+3
View File
@@ -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 <><h1></h1>{query.updated && <p></p>}{query.error && <p className="error"></p>}
<section className="card"><h2></h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong> #{report.post_id}</strong> · @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger"></button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button></button></form></div></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2>使</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button></button></form></div></li>)}</ul></section>
@@ -19,5 +21,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<section className="card"><h2></h2>{errors.length ? <ul className="job-list">{errors.map((error, index) => <li key={index}><strong>{error.scope}</strong> · <span className="error">{error.message}</span><br /><span className="meta">{new Date(error.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2></h2>{audits.length ? <ul className="job-list">{audits.map((item, index) => <li key={index}><strong>{item.action}</strong> · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}<br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2></h2><ul className="job-list">{sources.map((source) => <li key={source.id}><strong>{source.name}</strong> · <span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span> · {source.member_count}<br /><span className="meta">#{source.id} · {source.base_url} · {source.last_synced_at || "尚未完成"}</span></li>)}</ul></section>
<section className="card"><h2></h2>{config.ok ? <p></p> : <ul>{config.errors.map((error) => <li className="error" key={error}>{error}</li>)}</ul>}</section>
</>;
}
+4
View File
@@ -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 }); } }
+10
View File
@@ -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("; ")}`); }
+2
View File
@@ -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);
+10
View File
@@ -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);
});