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

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

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

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

Shared:
- extend database schema, add new config fields
This commit is contained in:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
@@ -0,0 +1,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);
}),
);