2026-07-02 00:02:41 +07:00
|
|
|
import cors from "cors";
|
2026-06-09 10:16:04 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-06-01 21:44:29 +07:00
|
|
|
import express, {
|
|
|
|
|
type Express,
|
|
|
|
|
type NextFunction,
|
|
|
|
|
type Request,
|
|
|
|
|
type Response,
|
|
|
|
|
} from "express";
|
|
|
|
|
import helmet from "helmet";
|
2026-07-02 00:02:41 +07:00
|
|
|
import rateLimit from "express-rate-limit";
|
|
|
|
|
import { createAdminRouter } from "../modules/admin/admin.routes.js";
|
2026-06-02 00:11:29 +07:00
|
|
|
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
|
|
|
|
|
import { createAuthRouter } from "../modules/auth/auth.routes.js";
|
|
|
|
|
import { createConfigRouter } from "../modules/config/config.routes.js";
|
2026-06-13 11:24:42 +07:00
|
|
|
import { createDashboardRouter } from "../modules/dashboard/dashboard.routes.js";
|
2026-06-01 21:53:08 +07:00
|
|
|
import { createHealthRouter } from "../modules/health/health.routes.js";
|
2026-06-03 15:01:34 +07:00
|
|
|
import { createMascotChatRouter } from "../modules/mascot-chat/mascot-chat.routes.js";
|
2026-06-01 21:53:08 +07:00
|
|
|
import { createMediaRouter } from "../modules/media/media.routes.js";
|
|
|
|
|
import { createMessagesRouter } from "../modules/messages/messages.routes.js";
|
2026-06-02 00:11:29 +07:00
|
|
|
import { createRecordingsRouter } from "../modules/recordings/recordings.routes.js";
|
|
|
|
|
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
|
|
|
|
|
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
2026-06-09 10:16:04 +07:00
|
|
|
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
2026-06-13 14:10:34 +07:00
|
|
|
import {
|
2026-07-02 00:02:41 +07:00
|
|
|
sessionAuth,
|
2026-06-13 14:10:34 +07:00
|
|
|
errorHandler,
|
|
|
|
|
} from "../shared/middlewares/index.js";
|
|
|
|
|
import { config } from "../shared/config/index.js";
|
2026-07-02 00:02:41 +07:00
|
|
|
import { isDashboardPublic } from "../shared/config/runtime.js";
|
2026-06-13 14:10:34 +07:00
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
const ADMIN_PASSWORD = config.ADMIN_PASSWORD;
|
2026-06-01 21:44:29 +07:00
|
|
|
const logger = createChildLogger("http.app");
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
export function createHttpApp(): Express {
|
|
|
|
|
const app = express();
|
|
|
|
|
|
|
|
|
|
// Security middleware
|
|
|
|
|
app.use(
|
|
|
|
|
helmet({
|
|
|
|
|
contentSecurityPolicy: false,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// 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.
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
// Body parsing
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// 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);
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
// Request logging
|
|
|
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
|
|
|
if (req.path.startsWith("/api/")) {
|
|
|
|
|
res.set("Cache-Control", "no-store");
|
|
|
|
|
}
|
|
|
|
|
res.on("finish", () => {
|
|
|
|
|
if (req.originalUrl.startsWith("/.well-known/")) return;
|
|
|
|
|
if (req.originalUrl === "/favicon.ico") return;
|
|
|
|
|
if (res.statusCode >= 400) {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{
|
|
|
|
|
method: req.method,
|
|
|
|
|
url: req.originalUrl,
|
|
|
|
|
statusCode: res.statusCode,
|
|
|
|
|
},
|
|
|
|
|
"HTTP request failed",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// Open endpoints (no auth required)
|
2026-06-01 21:44:29 +07:00
|
|
|
app.use("/api", createHealthRouter());
|
2026-06-02 00:11:29 +07:00
|
|
|
app.use("/api", createAuthRouter());
|
|
|
|
|
app.use("/api", createConfigRouter());
|
2026-07-02 00:02:41 +07:00
|
|
|
|
|
|
|
|
// 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);
|
2026-06-13 11:24:42 +07:00
|
|
|
app.use("/api", createDashboardRouter());
|
2026-06-13 14:10:34 +07:00
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
app.use("/api", protectedRoute);
|
2026-06-13 19:46:56 +07:00
|
|
|
app.use("/api", createMessagesRouter());
|
|
|
|
|
app.use("/api", createAnalysisRouter());
|
|
|
|
|
app.use("/api", createMascotChatRouter());
|
2026-07-02 00:02:41 +07:00
|
|
|
|
|
|
|
|
// These routers are already guarded by the protectedRoute above
|
2026-06-13 19:46:56 +07:00
|
|
|
app.use("/api", createMediaRouter());
|
|
|
|
|
app.use("/api", createVoiceRouter());
|
|
|
|
|
app.use("/api", createRecordingsRouter());
|
|
|
|
|
app.use("/api", createUiStateRouter());
|
2026-06-02 00:11:29 +07:00
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// Guilds routes — always protected (even in public mode)
|
|
|
|
|
app.use("/api/guilds", sessionAuth(ADMIN_PASSWORD));
|
2026-06-13 19:46:56 +07:00
|
|
|
app.use("/api/guilds", createGuildsRouter());
|
2026-06-01 21:44:29 +07:00
|
|
|
|
|
|
|
|
// 404 handler
|
|
|
|
|
app.use((_req: Request, res: Response) => {
|
|
|
|
|
res.status(404).json({
|
|
|
|
|
error: "NOT_FOUND",
|
|
|
|
|
message: "Endpoint not found",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Error handler (must be last)
|
|
|
|
|
app.use(errorHandler);
|
|
|
|
|
|
|
|
|
|
return app;
|
|
|
|
|
}
|