/* * ======= • ======= • ======= • ======= • =======• ======= * AniKotoAPI — server.js * Repository: https://github.com/Shineii86/AniKotoAPI * * @description * Main entry point for the AniKotoAPI Express server. * Configures CORS, middleware, static files, API routes, * and 404 handling. Starts the server on the configured port. * * @exports * None (side-effect: starts Express server) * * @author Shinei Nouzen * @license MIT * ======= • ======= • ======= • ======= • =======• ======= */ import dotenv from "dotenv"; import express from "express"; import cors from "cors"; import compression from "compression"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; import { dirname } from "path"; import { createApiRoutes } from "./src/routes/apiRoutes.js"; import { getCacheStats } from "./src/helper/cache.helper.js"; dotenv.config(); // ══════════════════════════════════════════════════════════════ // SERVER CONFIGURATION // ══════════════════════════════════════════════════════════════ const app = express(); const PORT = process.env.PORT || 4444; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const publicDir = path.join(process.cwd(), "public"); const has404File = fs.existsSync(path.join(publicDir, "404.html")); const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(","); // ---- FEATURE: Response compression ---- app.use(compression({ filter: (req, res) => { if (req.headers["x-no-compression"]) return false; return compression.filter(req, res); }, level: 6, threshold: 1024 })); // ══════════════════════════════════════════════════════════════ // CORS MIDDLEWARE // ══════════════════════════════════════════════════════════════ // NOTE: Single unified CORS middleware — handles all origin validation app.use((req, res, next) => { const origin = req.headers.origin; if (!allowedOrigins || allowedOrigins.includes("*") || (origin && allowedOrigins.includes(origin))) { res.setHeader("Access-Control-Allow-Origin", origin || "*"); } res.setHeader("Access-Control-Allow-Methods", "GET"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { return res.status(204).end(); } next(); }); // ---- FEATURE: Security headers ---- app.use((req, res, next) => { res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-XSS-Protection", "1; mode=block"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); next(); }); // ══════════════════════════════════════════════════════════════ // STATIC FILES // ══════════════════════════════════════════════════════════════ // NOTE: redirect: false prevents automatic redirects to index.html app.use(express.static(publicDir, { redirect: false })); // ══════════════════════════════════════════════════════════════ // CLEAN URL ROUTES // ══════════════════════════════════════════════════════════════ // Serve HTML files without .html extension app.get("/tos", (req, res) => { res.sendFile(path.join(publicDir, "tos.html")); }); app.get("/privacy", (req, res) => { res.sendFile(path.join(publicDir, "privacy.html")); }); // ══════════════════════════════════════════════════════════════ // RESPONSE HELPERS // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Standardized JSON response wrapper ---- /** * Wraps data in a standardized success JSON response. * * @param {object} res - Express response object * @param {*} data - The data to return in the response * @param {number} status - HTTP status code (default: 200) */ const jsonResponse = (res, data, status = 200) => res.status(status).json({ success: true, results: data }); // ---- FEATURE: Standardized error response wrapper ---- /** * Returns a standardized error JSON response. * * @param {object} res - Express response object * @param {string} message - Error message to return (default: "Internal server error") * @param {number} status - HTTP status code (default: 500) */ const jsonError = (res, message = "Internal server error", status = 500) => res.status(status).json({ success: false, message }); // ══════════════════════════════════════════════════════════════ // API ROUTES // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Rate limiting (configurable, default 100 requests per minute per IP) ---- const requestCounts = new Map(); const RATE_LIMIT = parseInt(process.env.RATE_LIMIT) || 100; const RATE_WINDOW = parseInt(process.env.RATE_WINDOW) || 60000; app.use((req, res, next) => { const ip = req.ip || req.connection.remoteAddress; const now = Date.now(); if (!requestCounts.has(ip)) { requestCounts.set(ip, []); } const timestamps = requestCounts.get(ip).filter(t => now - t < RATE_WINDOW); requestCounts.set(ip, timestamps); if (timestamps.length >= RATE_LIMIT) { return res.status(429).json({ success: false, message: "Rate limit exceeded. Try again later.", retryAfter: Math.ceil((timestamps[0] + RATE_WINDOW - now) / 1000) }); } timestamps.push(now); res.setHeader("X-RateLimit-Limit", RATE_LIMIT); res.setHeader("X-RateLimit-Remaining", RATE_LIMIT - timestamps.length); next(); }); // ---- FEATURE: Cache stats endpoint ---- app.get("/api/cache/stats", (req, res) => { res.json({ success: true, results: getCacheStats() }); }); createApiRoutes(app, jsonResponse, jsonError); // ══════════════════════════════════════════════════════════════ // 404 HANDLER // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Request timeout middleware (30 seconds) ---- app.use((req, res, next) => { const timeout = parseInt(process.env.REQUEST_TIMEOUT) || 30000; req.setTimeout(timeout, () => { res.status(408).json({ success: false, message: "Request timeout" }); }); next(); }); // ---- FEATURE: Global error handler ---- app.use((err, req, res, next) => { console.error(`[ERROR] ${err.message}`, err.stack); if (err.type === 'entity.too.large') { return res.status(413).json({ success: false, message: "Request entity too large" }); } res.status(err.status || 500).json({ success: false, message: process.env.NODE_ENV === 'production' ? "Internal server error" : err.message }); }); // ---- FEATURE: Catch-all 404 handler for undefined routes ---- app.use((req, res) => { const filePath = path.join(publicDir, "404.html"); if (fs.existsSync(filePath)) { res.status(404).sendFile(filePath); } else { res.status(404).json({ success: false, message: "Endpoint not found", availableEndpoints: [ "/", "/search", "/info", "/episodes", "/servers", "/stream", "/spotlight", "/trending", "/top-ten", "/random", "/most-popular", "/new-release", "/schedule", "/genre/:genre", "/type/:type", "/status/:status", "/health", "/stats", "/openapi" ] }); } }); // ══════════════════════════════════════════════════════════════ // SERVER START // ══════════════════════════════════════════════════════════════ app.listen(PORT, () => { console.info(`AniKotoAPI listening at ${PORT}`); }); // ══════════════════════════════════════════════════════════════ END: server.js