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:
@@ -17,9 +17,11 @@
|
||||
"@bete/shared": "workspace:*",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"axios": "^1.16.1",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"pg": "^8.21.0",
|
||||
@@ -30,12 +32,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-rate-limit": "^6.0.2",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user