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:
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user