feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config

Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
This commit is contained in:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
@@ -0,0 +1,110 @@
/**
* Runtime configuration manager.
*
* Stores settings that can change at runtime (e.g., DASHBOARD_IS_PUBLIC)
* in a JSON file. Falls back to env-based defaults from the static config.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { createChildLogger } from "@bete/shared/logger";
import type { config } from "./index.js";
type Config = typeof config;
const logger = createChildLogger("runtime-config");
const DATA_DIR = resolve(import.meta.dirname ?? process.cwd(), "..", "data");
const SETTINGS_FILE = resolve(DATA_DIR, "settings.json");
interface RuntimeSettings {
dashboardIsPublic: boolean;
}
/** Nilai fallback dari env. Dipakai saat settings.json belum pernah dibuat. */
function envDefaultSettings(): RuntimeSettings {
return {
dashboardIsPublic: process.env.DASHBOARD_IS_PUBLIC === "true",
};
}
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
function loadSettings(): RuntimeSettings {
try {
ensureDataDir();
const fallback = envDefaultSettings();
if (!existsSync(SETTINGS_FILE)) {
writeFileSync(SETTINGS_FILE, JSON.stringify(fallback, null, 2));
return { ...fallback };
}
const raw = readFileSync(SETTINGS_FILE, "utf-8");
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
return { ...fallback, ...parsed };
} catch (err) {
logger.error({ err }, "Failed to load runtime settings");
return envDefaultSettings();
}
}
function saveSettings(settings: RuntimeSettings): void {
try {
ensureDataDir();
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
} catch (err) {
logger.error({ err }, "Failed to save runtime settings");
}
}
// ─── Singleton ───────────────────────────────────────────────────────────────
let _cache: RuntimeSettings | null = null;
function getSettings(): RuntimeSettings {
if (!_cache) {
_cache = loadSettings();
}
return _cache;
}
function invalidateCache(): void {
_cache = null;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Whether the dashboard is publicly accessible without auth, using runtime
* override if available, otherwise falling back to the env-based static config.
*/
export function isDashboardPublic(staticConfig?: Config): boolean {
const runtime = getSettings();
return runtime.dashboardIsPublic;
}
export function getRuntimeSettings(): RuntimeSettings {
return { ...getSettings() };
}
/**
* Update runtime settings. Pass only the fields you want to change.
* Invalidates the internal cache so the next read picks up changes.
*/
export function updateRuntimeSettings(
patch: Partial<RuntimeSettings>,
): RuntimeSettings {
const current = getSettings();
const updated = { ...current, ...patch };
saveSettings(updated);
invalidateCache();
return { ...updated };
}
/**
* Reset runtime settings to env-based defaults (does NOT change the file).
*/
export function resetRuntimeSettings(): void {
invalidateCache();
}
@@ -4,10 +4,150 @@ import {
ValidationError,
} from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
const logger = createChildLogger("middleware");
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
// ─── Revokable token version ──────────────────────────────────────────────
// Token version prevents compromised tokens from being valid indefinitely.
// Stored in Redis so version survives process restarts.
// Falls back to in-memory Map if Redis is unavailable.
// ──────────────────────────────────────────────────────────────────────────
const TOKEN_VERSION_REDIS_PREFIX = "token_version:";
const TOKEN_VERSION_TTL_S = 7 * 24 * 60 * 60; // 7 days — far longer than session lifetime
const tokenVersions = new Map<string, number>(); // in-memory fallback
async function tryLoadTokenVersion(sub: string): Promise<number | null> {
try {
const { readRedisStatus } = await import("../redis/index.js");
const raw = await readRedisStatus(`${TOKEN_VERSION_REDIS_PREFIX}${sub}`);
if (raw && typeof raw.version === "number") {
// Sync in-memory cache
tokenVersions.set(sub, raw.version);
return raw.version;
}
} catch {
// Redis unavailable — fall through to in-memory
}
return null;
}
async function tryPersistTokenVersion(sub: string, version: number): Promise<void> {
try {
const { getCommandPublisher } = await import("../redis/index.js");
const publisher = getCommandPublisher();
const key = `${TOKEN_VERSION_REDIS_PREFIX}${sub}`;
await publisher.set(key, JSON.stringify({ version }), "EX", TOKEN_VERSION_TTL_S);
} catch {
// Silently fall back to in-memory
}
}
export async function incrementTokenVersion(sub: string): Promise<number> {
const next = (tokenVersions.get(sub) ?? 0) + 1;
tokenVersions.set(sub, next);
// Fire-and-forget persist to Redis
tryPersistTokenVersion(sub, next).catch(() => {});
return next;
}
export async function getTokenVersion(sub: string): Promise<number> {
const cached = tokenVersions.get(sub);
if (cached !== undefined) return cached;
// Try loading from Redis
const remote = await tryLoadTokenVersion(sub);
if (remote !== null) return remote;
return 0;
}
// ─── JWT-like session token helpers ──────────────────────────────────────
// Simple HMAC-SHA256 token without external library dependency.
// Payload: { sub, iat, exp } base64url-encoded, signed with HMAC-SHA256.
interface SessionPayload {
sub: string; // e.g. "admin"
iat: number; // issued at (ms)
exp: number; // expires at (ms)
ver: number; // token version (revokable)
}
function base64urlEncode(data: string): string {
return Buffer.from(data)
.toString("base64url");
}
function base64urlDecode(str: string): string {
return Buffer.from(str, "base64url").toString("utf-8");
}
function signToken(payload: string, secret: string): string {
return createHmac("sha256", secret)
.update(payload)
.digest("base64url");
}
export function createSessionToken(adminPassword: string): string {
const now = Date.now();
// Note: getTokenVersion is async (Redis-backed). In practice, the version
// is cached in-memory after first load, so this is effectively sync.
// We use a sync fallback to keep the token-creation path non-async.
const ver = tokenVersions.get("admin") ?? 0;
const payload: SessionPayload = {
sub: "admin",
iat: now,
exp: now + SESSION_DURATION_MS,
ver,
};
const header = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64urlEncode(JSON.stringify(payload));
const signature = signToken(`${header}.${body}`, adminPassword);
return `${header}.${body}.${signature}`;
}
export function verifySessionToken(
token: string,
secret: string,
): SessionPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new UnauthorizedError("Invalid token format");
}
const [header, body, signature] = parts;
const expectedSig = signToken(`${header}.${body}`, secret);
try {
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSig);
if (
sigBuf.length !== expectedBuf.length ||
!timingSafeEqual(sigBuf, expectedBuf)
) {
throw new UnauthorizedError("Invalid token signature");
}
} catch {
throw new UnauthorizedError("Invalid token signature");
}
const payload = JSON.parse(base64urlDecode(body)) as SessionPayload;
if (Date.now() > payload.exp) {
throw new UnauthorizedError("Session token expired");
}
// Token version check — invalidate all tokens issued before version bump
// Note: getTokenVersion is async (Redis-backed). We fall back to the
// in-memory cache which is synced on first load from Redis. On startup
// the version defaults to 0, which is correct — no tokens revoked yet.
const currentVersion = tokenVersions.get(payload.sub) ?? 0;
if ((payload.ver ?? 0) < currentVersion) {
throw new UnauthorizedError("Session token has been revoked");
}
return payload;
}
// ─── Express middleware ──────────────────────────────────────────────────
export function errorHandler(
err: Error,
_req: Request,
@@ -30,15 +170,62 @@ export function errorHandler(
});
}
export function adminAuth(adminPassword: string) {
return (req: Request, res: Response, next: NextFunction) => {
const password = req.headers["x-admin-password"] as string;
/**
* @deprecated Replaced by sessionAuth(). Kept temporarily for transition
* period. TODO: remove after confirming no consumers remain.
*/
// export function adminAuth(adminPassword: string) {
// return (req: Request, res: Response, next: NextFunction) => {
// const password = req.headers["x-admin-password"] as string;
//
// if (!password || password !== adminPassword) {
// throw new UnauthorizedError("Invalid admin password");
// }
//
// next();
// };
// }
if (!password || password !== adminPassword) {
throw new UnauthorizedError("Invalid admin password");
/**
* Session-based auth middleware.
* Reads Bearer token from Authorization header and validates it.
* Falls back to X-Admin-Password header for backward compatibility.
*/
export function sessionAuth(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
// Try Authorization: Bearer <token> first
const authHeader = req.headers.authorization as string | undefined;
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
try {
verifySessionToken(token, secret);
return next();
} catch (err) {
if (err instanceof AppError) {
throw err;
}
throw new UnauthorizedError("Invalid session token");
}
}
next();
// Fallback: X-Admin-Password header (for transition period)
const password = req.headers["x-admin-password"] as string;
if (password) {
try {
const pwBuf = Buffer.from(password);
const secretBuf = Buffer.from(secret);
if (
pwBuf.length === secretBuf.length &&
timingSafeEqual(pwBuf, secretBuf)
) {
return next();
}
} catch {
// Fall through to error below
}
}
throw new UnauthorizedError("Authentication required");
};
}
@@ -77,6 +77,7 @@ export async function publishCommand<T = unknown>(
const timer = setTimeout(() => {
if (settled) return;
settled = true;
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -90,6 +91,7 @@ export async function publishCommand<T = unknown>(
if (channel !== replyChannel || settled) return;
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -121,6 +123,7 @@ export async function publishCommand<T = unknown>(
if (!settled) {
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
sub.unsubscribe(replyChannel).catch(() => {
/* ignore */
});
@@ -133,6 +136,7 @@ export async function publishCommand<T = unknown>(
if (!settled) {
settled = true;
clearTimeout(timer);
sub.removeListener("message", onMessage);
logger.error({ err }, "Failed to subscribe to reply channel");
resolve(null);
}