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
+9 -1
View File
@@ -4,7 +4,15 @@
*/
import { describe, it, expect } from "vitest";
const BASE = process.env.API_BASE ?? "https://imphnen.asepharyana.my.id/api";
// Safety: never default to a production URL — forces explicit opt-in
// via: API_BASE=http://localhost:3001/api vitest run
const RAW = process.env.API_BASE;
if (!RAW) {
throw new Error(
"API_BASE is not set. Run with: API_BASE=http://localhost:3001/api vitest run",
);
}
const BASE = RAW;
async function api(path: string, init?: RequestInit) {
const res = await fetch(`${BASE}${path}`, {
+115 -9
View File
@@ -1,3 +1,4 @@
import cors from "cors";
import { createChildLogger } from "@bete/shared/logger";
import express, {
type Express,
@@ -6,6 +7,8 @@ import express, {
type Response,
} from "express";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { createAdminRouter } from "../modules/admin/admin.routes.js";
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
import { createAuthRouter } from "../modules/auth/auth.routes.js";
import { createConfigRouter } from "../modules/config/config.routes.js";
@@ -19,14 +22,47 @@ import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import {
sessionAuth,
errorHandler,
} from "../shared/middlewares/index.js";
import { config } from "../shared/config/index.js";
import { isDashboardPublic } from "../shared/config/runtime.js";
const ADMIN_PASSWORD = config.ADMIN_PASSWORD || "admin";
const ADMIN_PASSWORD = config.ADMIN_PASSWORD;
const logger = createChildLogger("http.app");
// Whitelist of GET endpoints allowed in public (unauthenticated) mode.
// All other GET requests require auth even when DASHBOARD_IS_PUBLIC is true.
const PUBLIC_GET_PATHS = [
"/api/dashboard/stats",
"/api/dashboard/users",
"/api/dashboard/channels",
"/api/ui-state",
"/api/media/status",
"/api/mascot/chat/history",
"/api/messages",
"/api/analysis",
"/api/recordings",
"/api/voice",
];
/**
* Dynamic auth guard — checks runtime DASHBOARD_IS_PUBLIC setting for every request.
* In public mode: only whitelisted GET paths pass through; everything else requires auth.
* In private mode: all routes require auth.
*/
function protectedRoute(req: Request, res: Response, next: NextFunction) {
if (req.method === "GET" && isDashboardPublic()) {
const matched = PUBLIC_GET_PATHS.some(
(path) => req.path === path || req.path.startsWith(path + "/"),
);
if (matched) {
return next();
}
}
return sessionAuth(ADMIN_PASSWORD)(req, res, next);
}
export function createHttpApp(): Express {
const app = express();
@@ -37,10 +73,71 @@ export function createHttpApp(): Express {
}),
);
// CORS — allow known frontend origins
// Security note: strict origin whitelist prevents unauthorized cross-origin
// access. In production, ensure only legitimate frontend domains are listed.
// Development: local Vite preview ports
// Production: nginx reverse-proxy serves both on the same domain,
// but we whitelist them for browser preflights too.
const allowedOrigins = [
"http://localhost:5173", // Vite dev server
"http://localhost:4173", // Vite preview server
"http://localhost:3000", // Vite preview (alternate)
"http://localhost:3001", // Backend direct (dev)
"https://imphnen.asepharyana.my.id",
"https://imphnen.asepharyana.tech",
"https://imphnen.asepharyana.web.id",
];
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Admin-Password"],
maxAge: 86400, // 24 hours — browser can cache preflight
}),
);
// CSRF TODO: state-changing endpoints (POST, PATCH, DELETE) should
// implement CSRF protection (e.g., double-submit cookie pattern or
// SameSite=Strict + custom header check) before deploying to production.
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Global rate limiter — pertahanan lapisan pertama terhadap abuse
// Endpoint login (/api/auth/login) punya rate limiter sendiri yang lebih ketat
// TODO: The global limiter is currently applied only at /api/ prefix (line below),
// which leaves non-/api/ paths unguarded. Consider applying a lighter limiter
// to all paths or ensure nginx handles upstream rate limiting in production.
const globalLimiter = rateLimit({
windowMs: 15 * 1000, // 15 seconds
max: 200,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Skip health checks and WebSocket upgrade requests
if (req.path === "/api/health" || req.path === "/health") return true;
if (req.headers.upgrade === "websocket") return true;
return false;
},
message: {
error: "TOO_MANY_REQUESTS",
message: "Too many requests, please slow down",
},
});
app.use("/api/", globalLimiter);
// Request logging
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith("/api/")) {
@@ -63,26 +160,35 @@ export function createHttpApp(): Express {
next();
});
// Health check (no auth required)
// Open endpoints (no auth required)
app.use("/api", createHealthRouter());
// Auth (no auth required)
app.use("/api", createAuthRouter());
// Public read-only endpoints
app.use("/api", createConfigRouter());
// Admin endpoints — always require auth (manage settings, etc.)
// NOTE: createAdminRouter() sudah punya sessionAuth middleware internal,
// jadi tidak perlu middleware terpisah di sini.
app.use("/api", createAdminRouter());
// Protected routes — guarded by runtime DASHBOARD_IS_PUBLIC setting
// Public mode: GET is read-only, mutations require admin password
// Private mode: everything requires admin password
app.use("/api/dashboard", protectedRoute);
app.use("/api", createDashboardRouter());
// Protected routes — all routes are now public
app.use("/api", protectedRoute);
app.use("/api", createMessagesRouter());
app.use("/api", createAnalysisRouter());
app.use("/api", createMascotChatRouter());
// These routers are already guarded by the protectedRoute above
app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter());
app.use("/api", createRecordingsRouter());
app.use("/api", createUiStateRouter());
// Guilds routes
// Guilds routes — always protected (even in public mode)
app.use("/api/guilds", sessionAuth(ADMIN_PASSWORD));
app.use("/api/guilds", createGuildsRouter());
// 404 handler
@@ -0,0 +1,51 @@
import type { Request, Response, Router } from "express";
import express from "express";
import {
getRuntimeSettings,
updateRuntimeSettings,
} from "../../shared/config/runtime.js";
import { config } from "../../shared/config/index.js";
import { sessionAuth, asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("admin.routes");
export function createAdminRouter(): Router {
const router = express.Router();
// All admin routes require session-based auth
router.use(sessionAuth(config.ADMIN_PASSWORD));
// GET /api/admin/settings — read current runtime settings
router.get(
"/admin/settings",
asyncHandler(async (_req: Request, res: Response) => {
const settings = getRuntimeSettings();
res.json({
...settings,
envDashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
}),
);
// PATCH /api/admin/settings — update runtime settings (live, no restart)
router.patch(
"/admin/settings",
asyncHandler(async (req: Request, res: Response) => {
const { dashboardIsPublic } = req.body as {
dashboardIsPublic?: boolean;
};
const patch: Record<string, unknown> = {};
if (typeof dashboardIsPublic === "boolean") {
patch.dashboardIsPublic = dashboardIsPublic;
}
const updated = updateRuntimeSettings(patch);
logger.info({ ...patch }, "Runtime settings updated");
res.json(updated);
}),
);
return router;
}
@@ -1,32 +1,87 @@
import { timingSafeEqual } from "node:crypto";
import { UnauthorizedError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express";
import express from "express";
import rateLimit from "express-rate-limit";
import { config } from "../../shared/config/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
asyncHandler,
createSessionToken,
incrementTokenVersion,
sessionAuth,
} from "../../shared/middlewares/index.js";
const logger = createChildLogger("auth.routes");
const adminPassword = config.ADMIN_PASSWORD || "admin";
const adminPassword = config.ADMIN_PASSWORD;
// Rate limiter: max 10 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false, // Disable `X-RateLimit-*` headers
message: {
error: "TOO_MANY_REQUESTS",
message: "Too many login attempts, please try again later",
},
});
export function createAuthRouter(): Router {
const router = express.Router();
// POST /api/auth/login
// POST /api/auth/login — rate limited to prevent brute force
router.post(
"/auth/login",
loginLimiter,
asyncHandler(async (req: Request, res: Response) => {
const { password } = req.body as { password?: string };
logger.debug("Auth login attempt");
if (!password || password !== adminPassword) {
if (!password) {
throw new UnauthorizedError("Invalid password");
}
// Constant-time comparison prevents timing attacks
const pwBuf = Buffer.from(password);
const adminBuf = Buffer.from(adminPassword);
const maxLen = Math.max(pwBuf.length, adminBuf.length);
const diff =
pwBuf.length !== adminBuf.length ||
!timingSafeEqual(
Buffer.concat([pwBuf, Buffer.alloc(maxLen - pwBuf.length)]),
Buffer.concat([adminBuf, Buffer.alloc(maxLen - adminBuf.length)]),
);
if (diff) {
throw new UnauthorizedError("Invalid password");
}
const token = createSessionToken(adminPassword);
res.json({ ok: true, token });
}),
);
// POST /api/auth/logout — revoke all sessions for admin
router.post(
"/auth/logout",
sessionAuth(adminPassword),
asyncHandler(async (_req: Request, res: Response) => {
incrementTokenVersion("admin");
logger.info("Admin logged out — all sessions revoked");
res.json({ ok: true });
}),
);
// GET /api/auth/whoami — check if token is valid
router.get(
"/auth/whoami",
sessionAuth(adminPassword),
asyncHandler(async (_req: Request, res: Response) => {
res.json({ ok: true, sub: "admin" });
}),
);
return router;
}
@@ -1,28 +1,54 @@
import { UnauthorizedError } from "@bete/shared/errors";
import type { Router } from "express";
import express from "express";
import { config } from "../../shared/config/index.js";
import { isDashboardPublic } from "../../shared/config/runtime.js";
import { sessionAuth } from "../../shared/middlewares/index.js";
export function createConfigRouter(): Router {
const router = express.Router();
// GET /api/config
router.get("/config", (_req, res) => {
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
});
// GET /api/config — protected by runtime public/private mode
// Public mode: no auth needed (frontend needs config to determine auth state)
// Private mode: requires session-based auth
router.get("/config", (req, res, next) => {
if (isDashboardPublic()) {
// Public mode — return config without auth
return sendConfig(res);
} else {
// Private mode — require auth, then return config
sessionAuth(config.ADMIN_PASSWORD)(req, res, () => sendConfig(res));
}
});
return router;
}
function sendConfig(res: express.Response): void {
if (isDashboardPublic()) {
// Public mode — only expose safe, non-sensitive fields
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
return;
}
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
});
}
@@ -8,7 +8,12 @@ export class DashboardRepository {
async getStats() {
const pool = getPool();
// Total messages and breakdown by ai_status
// Time-bounded aggregates — prevent full-table scan on large datasets
// Queries scope to last 90 days for performance, which covers the
// typical retention window anyway.
const BOUNDARY_DAYS = 90;
// Total messages and breakdown by ai_status (last 90 days)
const msgResult = await pool.query(
`
SELECT
@@ -24,32 +29,36 @@ export class DashboardRepository {
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
FROM messages
WHERE created_at >= $3
`,
[Date.now() - 86400000, Date.now() - 86400000],
[Date.now() - 86400000, Date.now() - 86400000, Date.now() - BOUNDARY_DAYS * 86400000],
);
const msgRow = msgResult.rows[0];
// Total voice recordings
const voiceResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM voice_recordings
`);
// Total voice recordings (last 90 days — bounded by retention window)
const voiceResult = await pool.query(
`SELECT COUNT(*)::int AS count FROM voice_recordings
WHERE created_at >= $1`,
[Date.now() - BOUNDARY_DAYS * 86400000],
);
// Total AI user profiles
const profileResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM user_profiles
`);
// Top channels by message count
// Top channels by message count (last 90 days)
const topChannels = await pool.query(`
SELECT channel_id,
(metadata::jsonb -> 'channel' ->> 'channelName') AS channel_name,
COUNT(*)::int AS message_count
FROM messages
WHERE created_at >= $1
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
ORDER BY COUNT(*) DESC
LIMIT 10
`);
`, [Date.now() - BOUNDARY_DAYS * 86400000]);
return {
total_messages: msgRow?.total_messages ?? 0,
@@ -23,7 +23,7 @@ export function createDashboardRouter(): Router {
router.get(
"/dashboard/users",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const cursor =
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const search =
@@ -52,16 +52,19 @@ export function createDashboardRouter(): Router {
router.get(
"/dashboard/channels",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const search =
typeof req.query.search === "string" ? req.query.search : undefined;
const guildId =
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
const cursor =
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
const result = await dashboardService.listChannels({
limit,
search,
guildId,
cursor,
});
res.json(result);
}),
@@ -29,6 +29,7 @@ export class DashboardService {
limit: number;
search?: string;
guildId?: string;
cursor?: string;
}) {
logger.debug({ query }, "Listing dashboard channels");
return dashboardRepository.listChannels(query);
@@ -1,6 +1,7 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response } from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { chatRequestSchema } from "./mascot-chat.schema.js";
import { mascotChatService } from "./mascot-chat.service.js";
const logger = createChildLogger("mascot-chat.controller");
@@ -11,15 +12,18 @@ interface AuthenticatedRequest extends Request {
export const handleMascotChat = asyncHandler(
async (req: Request, res: Response) => {
const { message, context } = req.body;
if (!message || typeof message !== "string") {
// Validate request body against schema
const parsed = chatRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "INVALID_INPUT",
message: "Message is required and must be a string",
message: "Invalid request body",
details: parsed.error.flatten().fieldErrors,
});
}
const { message, context } = parsed.data;
// Get user ID from auth middleware (if available)
const userId = (req as AuthenticatedRequest).userId || "anonymous";
@@ -6,6 +6,15 @@ import { uiStateService } from "./ui-state.service.js";
const logger = createChildLogger("ui-state.routes");
// Allowed UI state keys — reject any update that does not match these.
const ALLOWED_KEYS = new Set([
"activeTab",
"selectedVoiceGuild",
"selectedVoiceChannel",
"selectedTextChannel",
"sidebarCollapsed",
]);
export function createUiStateRouter(): Router {
const router = express.Router();
@@ -25,7 +34,14 @@ export function createUiStateRouter(): Router {
asyncHandler(async (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
const result = await uiStateService.updateState(updates);
// Filter to only allow known safe keys
const filtered: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
if (ALLOWED_KEYS.has(key)) {
filtered[key] = updates[key];
}
}
const result = await uiStateService.updateState(filtered);
res.json(result);
}),
);
@@ -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);
}
+83 -19
View File
@@ -66,9 +66,91 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
];
let subscriber: Redis | null = null;
let _redisHealthy = false;
function isRedisAvailable(): boolean {
return _redisHealthy;
}
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
function scheduleReconnect(): void {
if (_reconnectTimer) return; // already scheduled
_reconnectAttempts++;
if (_reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
logger.error("Redis subscriber max reconnect attempts reached");
_reconnectAttempts = 0;
return;
}
const delay = Math.min(1000 * Math.pow(2, _reconnectAttempts), 30_000);
logger.warn(
{ attempt: _reconnectAttempts, delayMs: delay },
"Redis subscriber reconnection scheduled",
);
_reconnectTimer = setTimeout(() => {
_reconnectTimer = null;
if (subscriber) {
subscriber
.connect()
.then(() => {
_redisHealthy = true;
_reconnectAttempts = 0;
logger.info("Redis subscriber reconnected");
// Re-subscribe after reconnect
const channels = SUBSCRIPTIONS.map((m) => m.channel);
return subscriber?.subscribe(...channels);
})
.catch((err: Error) => {
logger.error({ err }, "Redis subscriber reconnect failed");
scheduleReconnect();
});
}
}, delay);
}
function createSubscriber(): Redis {
return new Redis(config.REDIS_URL, { keyPrefix: "" });
const redis = new Redis(config.REDIS_URL, {
keyPrefix: "",
lazyConnect: true,
retryStrategy: (times) => {
// We handle reconnection ourselves
if (times > 3) return null;
return Math.min(times * 500, 2000);
},
});
redis.on("error", (err: Error) => {
const wasHealthy = _redisHealthy;
_redisHealthy = false;
if (wasHealthy) {
logger.warn({ err }, "Redis subscriber: connection lost");
} else {
logger.debug({ err }, "Redis subscriber error (not yet connected)");
}
});
redis.on("connect", () => {
_redisHealthy = true;
_reconnectAttempts = 0;
logger.info("Redis subscriber connected");
});
redis.on("close", () => {
_redisHealthy = false;
logger.warn("Redis subscriber connection closed");
// Schedule reconnection for lazy-connect mode
if (!_reconnectTimer) scheduleReconnect();
});
redis.on("reconnecting", () => {
logger.warn("Redis subscriber reconnecting…");
});
redis.on("message", handleSubscriptionMessage);
return redis;
}
function handleSubscriptionMessage(channel: string, message: string): void {
@@ -141,24 +223,6 @@ export async function startRedisBridge(): Promise<void> {
try {
subscriber = createSubscriber();
subscriber.on("error", (err: Error) => {
logger.error({ err }, "Redis subscriber error");
});
subscriber.on("connect", () => {
logger.info("Redis subscriber connected");
});
subscriber.on("reconnecting", () => {
logger.warn("Redis subscriber reconnecting…");
});
subscriber.on("close", () => {
logger.warn("Redis subscriber connection closed");
});
subscriber.on("message", handleSubscriptionMessage);
await subscriber.ping();
logger.info("Redis ping OK");
+194 -81
View File
@@ -7,6 +7,11 @@ import { setBroadcastFunctions } from "./broadcast.js";
const logger = createChildLogger("ws.server");
// Per-client sliding window rate limiter: max 30 messages per 5-second window
const RATE_LIMIT_WINDOW_MS = 5000;
const RATE_LIMIT_MAX_MSGS = 30;
const messageTimestamps = new WeakMap<WebSocket, number[]>();
interface BroadcastEvent {
type: string;
data: unknown;
@@ -71,18 +76,29 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const wss = new WebSocketServer({ server, path: "/ws" });
_wss = wss;
wss.on("connection", (ws: WebSocket, req) => {
// Parse auth token from query string
wss.on("connection", async (ws: WebSocket, req) => {
// Max connection limit — prevent resource exhaustion
const totalClients = frontendClients.size + gatewayClients.size;
const MAX_CONNECTIONS = 100;
if (totalClients >= MAX_CONNECTIONS) {
logger.warn({ totalClients }, "Max connections reached, rejecting new client");
ws.close(4003, "Server at capacity");
return;
}
// Gateway uses token in query string (internal-only connection, not in logs)
// Frontend uses auth message pattern to avoid token exposure in access logs
const rawUrl = req.url ?? "/";
let isGateway = false;
let queryToken: string | null = null;
try {
const url = new URL(rawUrl, "http://localhost");
const token = url.searchParams.get("token");
queryToken = url.searchParams.get("token");
isGateway =
token !== null &&
queryToken !== null &&
config.BACKEND_WS_TOKEN !== "" &&
token === config.BACKEND_WS_TOKEN;
queryToken === config.BACKEND_WS_TOKEN;
} catch {
// Malformed URL — treat as frontend
}
@@ -90,25 +106,120 @@ export function createWebSocketServer(server: Server): WebSocketServer {
if (isGateway) {
gatewayClients.add(ws);
logger.info("Discord gateway WebSocket client authenticated");
// Gateway doesn't need initial states
} else {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui, media) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
// Gateway only sends binary PCM — forward to frontend clients
ws.on("message", (data: Buffer) => {
if (Buffer.isBuffer(data)) {
broadcastBinaryToFrontend(data);
}
});
ws.on("close", () => {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
});
ws.on("error", (err: Error) => {
logger.error({ err }, "Gateway WebSocket error");
gatewayClients.delete(ws);
});
return;
}
ws.on("message", (data: Buffer) => {
// Gateway PCM forward — broadcast raw binary to frontend clients only
if (isGateway && Buffer.isBuffer(data)) {
broadcastBinaryToFrontend(data);
// ── Frontend client: auth message pattern ──────────────────────────
// Token is NEVER accepted in query string for frontend connections.
// Frontend must send { type: "auth", token: "..." } as first message.
// ────────────────────────────────────────────────────────────────────
// Origin check for frontend WebSocket connections
const origin = req.headers.origin;
if (origin) {
const allowedWsOrigins = [
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:3000",
"http://localhost:3001",
"https://imphnen.asepharyana.my.id",
"https://imphnen.asepharyana.tech",
"https://imphnen.asepharyana.web.id",
];
if (!allowedWsOrigins.includes(origin)) {
logger.warn({ origin }, "WebSocket connection rejected: origin not allowed");
ws.close(4002, "Origin not allowed");
return;
}
}
// Handle binary PCM from browser (FE→Discord transmit)
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
let authenticated = false;
let authTimer: ReturnType<typeof setTimeout> | null = null;
const { isDashboardPublic } = await import("../shared/config/runtime.js");
const isPublic = isDashboardPublic();
if (!isPublic) {
authTimer = setTimeout(() => {
if (!authenticated) {
ws.close(4001, "Authentication timeout");
logger.warn("Frontend WS connection timed out waiting for auth");
}
}, 5000);
} else {
authenticated = true;
frontendClients.add(ws);
}
function processFrontendMessage(data: Buffer): void {
// Validate auth before processing messages
if (!authenticated) {
try {
const msg = JSON.parse(data.toString());
if (
msg.type !== "auth" ||
typeof msg.token !== "string"
) {
return; // wait for valid auth
}
if (!isPublic) {
const { verifySessionToken } = require("../shared/middlewares/index.js");
verifySessionToken(msg.token, config.ADMIN_PASSWORD);
}
authenticated = true;
if (authTimer) {
clearTimeout(authTimer);
authTimer = null;
}
frontendClients.add(ws);
logger.info(`Frontend client authenticated (${frontendClients.size} total)`);
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
return;
} catch {
return; // invalid auth, wait for next message
}
}
// Per-client rate limiting — authenticated-only, max 30 msg / 5s sliding window
if (authenticated) {
const now = Date.now();
let timestamps = messageTimestamps.get(ws);
if (!timestamps) {
timestamps = [];
messageTimestamps.set(ws, timestamps);
}
// Prune timestamps outside the window
const cutoff = now - RATE_LIMIT_WINDOW_MS;
while (timestamps.length > 0 && timestamps[0]! < cutoff) {
timestamps.shift();
}
if (timestamps.length >= RATE_LIMIT_MAX_MSGS) {
logger.warn("Frontend client rate-limited (closing)");
ws.close(4006, "Rate limit exceeded");
return;
}
timestamps.push(now);
}
// Handle voice transmit binary
if (
Buffer.isBuffer(data) &&
data.length > 4 &&
@@ -148,78 +259,74 @@ export function createWebSocketServer(server: Server): WebSocketServer {
) {
try {
const message = JSON.parse(data.toString());
if (message.type === "voice_transmit" && message.buffer) {
// Legacy: Forward PCM data to Redis for discord-gateway
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: message.buffer,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
},
);
} else if (message.type === "voice_command" && message.command) {
// Forward voice commands to discord-gateway with payload
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
publisher
.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice command to Redis",
);
});
},
);
}
handleFrontendJsonMessage(message);
} catch (err) {
logger.debug({ err }, "Failed to parse WebSocket message as JSON");
}
}
});
}
ws.on("close", () => {
if (isGateway) {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
} else {
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
function handleFrontendJsonMessage(message: Record<string, unknown>): void {
if (message.type === "voice_transmit" && message.buffer) {
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: message.buffer,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
},
);
} else if (message.type === "voice_command" && message.command) {
import("../shared/redis/index.js").then(
({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
publisher
.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice command to Redis",
);
});
},
);
}
}
ws.on("message", (data: Buffer) => processFrontendMessage(data));
ws.on("close", () => {
if (authTimer) clearTimeout(authTimer);
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
});
ws.on("error", (err: Error) => {
logger.error({ err }, "WebSocket client error");
if (isGateway) {
gatewayClients.delete(ws);
} else {
frontendClients.delete(ws);
}
logger.error({ err }, "Frontend WebSocket error");
if (authTimer) clearTimeout(authTimer);
frontendClients.delete(ws);
});
});
@@ -240,6 +347,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
function broadcastBinaryToFrontend(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(data);
} catch (err) {
@@ -260,6 +369,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
});
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(payload);
} catch (err) {
@@ -272,6 +383,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
function broadcastBinary(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure check: skip slow clients to prevent OOM
if (client.bufferedAmount > 64 * 1024) continue;
try {
client.send(data);
} catch (err) {