Files
AniKotoAPI/src/routes/apiRoutes.js
T
Shinei Nouzen 30fdcd93c2 fix: close SSRF in stream proxies and fix dotenv load order
Security:
- Fixed unauthenticated SSRF in /api/stream/proxy and /api/stream/ts-proxy
  - proxy allowlist had escape hatch via pathname.endsWith('.m3u8') bypass
  - ts-proxy had no destination validation at all
  - Both could fetch internal services (127.0.0.1, 169.254.169.254)
- Added streamProxy.helper.js with domain allowlist, private IP blocking,
  and http(s)-only enforcement applied to both endpoints
- Removed dead megaplay-1.buzz; added vid-tube.site, norami.top,
  kryntal.top, akirax.buzz to allowlist
- Added SECURITY.md with vulnerability reporting policy

Bug fix:
- Fixed dotenv.config() loading after static imports -- helpers reading
  process.env at top level never received .env values. Changed to
  import 'dotenv/config' as first import

Config:
- Added STREAM_PROXY_DOMAINS env var for custom allowlist
- Added STREAM_PROXY_REFERER env var for custom CDN Referer
2026-08-27 14:40:09 +05:30

701 lines
30 KiB
JavaScript

/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — apiRoutes.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Central API router that maps all Express GET endpoints to their
* corresponding controller handlers. Each route wraps its controller
* in try/catch for consistent error handling.
*
* @exports
* createApiRoutes - Function that registers all API routes on the Express app
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { version } = require("../../package.json");
import { getHomeInfo } from "../controllers/homeInfo.controller.js";
import { getAnimeInfo } from "../controllers/animeInfo.controller.js";
import { getSearchResults, getSearchSuggestions } from "../controllers/search.controller.js";
import { getEpisodeList } from "../controllers/episodeList.controller.js";
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
import { getResolvedStream, getStreamQualities } from "../controllers/streamResolver.controller.js";
import { getSchedule } from "../controllers/schedule.controller.js";
import { getSpotlight } from "../controllers/spotlight.controller.js";
import { getTrending } from "../controllers/trending.controller.js";
import { getTopTen } from "../controllers/topten.controller.js";
import { getSuggestions } from "../controllers/suggestion.controller.js";
import { getRandom } from "../controllers/random.controller.js";
import { getPopular } from "../controllers/popular.controller.js";
import { getFilter } from "../controllers/filter.controller.js";
import { getWatchPage } from "../controllers/watchPage.controller.js";
import { getAzList } from "../controllers/azList.controller.js";
import { getNewRelease, getNewlyAdded, getLatestUpdated } from "../controllers/newRelease.controller.js";
import { getStatus } from "../controllers/status.controller.js";
import { getTrendingSidebar } from "../controllers/trendingSidebar.controller.js";
import { getSeasons } from "../controllers/seasons.controller.js";
import { getWatchOrder } from "../controllers/watchOrder.controller.js";
import { getDownloadLinks } from "../controllers/download.controller.js";
import { getUpcomingAnime } from "../controllers/upcomingAnime.controller.js";
import { getTopAnimeRankings } from "../controllers/topAnimeRankings.controller.js";
import { getRecentlyUpdatedTabs } from "../controllers/recentlyUpdatedTabs.controller.js";
import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
import { categoryRoutes } from "./category.route.js";
import { getMirrorStatus, resetMirrorCache, getProxyStatus } from "../helper/mirror.helper.js";
import { getCacheStats } from "../helper/cache.helper.js";
import { assertProxyableUrl, streamRefererHeaders } from "../helper/streamProxy.helper.js";
// ══════════════════════════════════════════════════════════════
// ROUTE REGISTRATION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Main API route registration function ----
/**
* Registers all API routes on the Express app instance.
* Each route delegates to a controller and uses the provided
* jsonResponse/jsonError helpers for consistent output.
*
* @param {object} app - Express application instance
* @param {Function} jsonResponse - Helper to wrap data in success JSON
* @param {Function} jsonError - Helper to return error JSON
*
* @example
* createApiRoutes(app, jsonResponse, jsonError);
*/
const createApiRoutes = (app, jsonResponse, jsonError) => {
// ══════════════════════════════════════════════════════════════
// REQUEST COUNTER (must be before all routes)
// ══════════════════════════════════════════════════════════════
const startTime = Date.now();
let requestCount = 0;
let errorCount = 0;
app.use((req, res, next) => {
if (req.path.startsWith("/api")) {
requestCount++;
res.on("finish", () => {
if (res.statusCode >= 400) errorCount++;
});
}
next();
});
// ══════════════════════════════════════════════════════════════
// HOME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Homepage data endpoint ----
app.get("/api/", async (req, res, next) => {
try {
await getHomeInfo(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// ANIME INFO
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Anime detail information by slug ----
app.get("/api/info", async (req, res, next) => {
try {
await getAnimeInfo(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// WATCH PAGE
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Watch page data for specific episode ----
app.get("/api/watch", async (req, res, next) => {
try {
await getWatchPage(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// SEARCH
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Full search results with pagination ----
app.get("/api/search", async (req, res, next) => {
try {
await getSearchResults(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Search autocomplete suggestions ----
app.get("/api/search/suggest", async (req, res, next) => {
try {
await getSearchSuggestions(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// EPISODES
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Episode list for anime by ID ----
app.get("/api/episodes/:id", async (req, res, next) => {
try {
await getEpisodeList(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: AJAX-loaded episode list (additional data) ----
app.get("/api/episodes-ajax/:id", async (req, res, next) => {
try {
await getEpisodeListAjax(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// STREAMING
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Stream URL and metadata for episode ----
app.get("/api/stream", async (req, res, next) => {
try {
await getStreamInfo(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Available streaming servers for episode ----
app.get("/api/servers", async (req, res, next) => {
try {
await getServerList(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Mapped servers across multiple episodes ----
app.get("/api/mapper-servers", async (req, res, next) => {
try {
await getMapperServers(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Download links for episode ----
app.get("/api/download", async (req, res, next) => {
try {
await getDownloadLinks(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Resolve actual stream URL (m3u8/mp4) from link ID ----
app.get("/api/stream/resolve", async (req, res, next) => {
try {
await getResolvedStream(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Available quality options for a stream URL ----
app.get("/api/stream/qualities", async (req, res, next) => {
try {
await getStreamQualities(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: M3U8 proxy — fetch and serve M3U8 playlists through API ----
app.get("/api/stream/proxy", async (req, res, next) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
}
// NOTE: Validate URL is from known streaming domains.
// WARNING: Do not add an "or the path looks like a playlist" escape hatch —
// the previous version had one, which let any host through and turned this
// endpoint into an open proxy onto the server's own network.
const check = assertProxyableUrl(url);
if (!check.ok) {
return res.status(check.status).json({ success: false, message: check.message });
}
const axios = (await import("axios")).default;
const { headers: defaultHeaders } = await import("../configs/header.config.js");
const response = await axios.get(url, {
headers: {
...defaultHeaders,
...streamRefererHeaders(),
},
timeout: 15000,
responseType: "text",
});
// NOTE: Rewrite relative URLs in M3U8 to proxy URLs
let content = typeof response.data === "string" ? response.data : String(response.data);
const baseUrl = url.substring(0, url.lastIndexOf("/") + 1);
content = content.replace(/(^(?!#).+\.m3u8.*$)/gm, (match) => {
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
return `/api/stream/proxy?url=${encodeURIComponent(absoluteUrl)}`;
});
content = content.replace(/(^(?!#).+\.ts.*$)/gm, (match) => {
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
return `/api/stream/ts-proxy?url=${encodeURIComponent(absoluteUrl)}`;
});
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=30");
res.send(content);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: TS segment proxy for video segments ----
app.get("/api/stream/ts-proxy", async (req, res, next) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ success: false, message: "TS URL is required" });
}
// NOTE: Segment URLs come from the caller too, so they need the same guard
// the playlist proxy uses — this endpoint previously had none.
const check = assertProxyableUrl(url);
if (!check.ok) {
return res.status(check.status).json({ success: false, message: check.message });
}
const axios = (await import("axios")).default;
const { headers: defaultHeaders } = await import("../configs/header.config.js");
const response = await axios.get(url, {
headers: {
...defaultHeaders,
...streamRefererHeaders(),
},
timeout: 30000,
responseType: "arraybuffer",
});
res.setHeader("Content-Type", "video/mp2t");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(Buffer.from(response.data));
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// SCHEDULE
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Anime airing schedule by date ----
app.get("/api/schedule", async (req, res, next) => {
try {
await getSchedule(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// DISCOVERY / HOMEPAGE SECTIONS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Spotlight/featured anime carousel ----
app.get("/api/spotlight", async (req, res, next) => {
try {
await getSpotlight(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Currently trending anime ----
app.get("/api/trending", async (req, res, next) => {
try {
await getTrending(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Top 10 ranked anime ----
app.get("/api/top-ten", async (req, res, next) => {
try {
await getTopTen(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Auto-complete suggestions by keyword ----
app.get("/api/suggestions", async (req, res, next) => {
try {
await getSuggestions(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Random anime recommendation ----
app.get("/api/random", async (req, res, next) => {
try {
await getRandom(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Most popular anime list ----
app.get("/api/most-popular", async (req, res, next) => {
try {
await getPopular(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Advanced filter/search by parameters ----
app.get("/api/filter", async (req, res, next) => {
try {
await getFilter(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// RELEASE LISTS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Recently released episodes ----
app.get("/api/new-release", async (req, res, next) => {
try {
await getNewRelease(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Newly added anime series ----
app.get("/api/newly-added", async (req, res, next) => {
try {
await getNewlyAdded(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Recently updated anime (sorted by update time) ----
app.get("/api/latest-updated", async (req, res, next) => {
try {
await getLatestUpdated(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// SIDEBAR
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Trending sidebar widget data ----
app.get("/api/trending-sidebar", async (req, res, next) => {
try {
await getTrendingSidebar(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// UPCOMING ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Upcoming anime releases ----
app.get("/api/upcoming", async (req, res, next) => {
try {
await getUpcomingAnime(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// TOP ANIME RANKINGS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Top anime rankings with sort (top/newest) ----
app.get("/api/top-rankings", async (req, res, next) => {
try {
await getTopAnimeRankings(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// RECENTLY UPDATED (TAB FILTERED)
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Recently updated anime with tab filter (all/dub/sub) ----
app.get("/api/recently-updated", async (req, res, next) => {
try {
await getRecentlyUpdatedTabs(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// COMPLETED ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Completed anime series ----
app.get("/api/completed", async (req, res, next) => {
try {
await getCompletedAnime(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// SEASONS & WATCH ORDER
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Season information for anime ----
app.get("/api/seasons/:id", async (req, res, next) => {
try {
await getSeasons(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Recommended watch order for anime ----
app.get("/api/watch-order/:id", async (req, res, next) => {
try {
await getWatchOrder(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// A-Z LIST
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Alphabetical anime listing ----
app.get("/api/az-list/:letter", async (req, res, next) => {
try {
await getAzList(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// CATEGORY ROUTES
// ══════════════════════════════════════════════════════════════
// NOTE: Category routes (genre, type, status) are registered separately
categoryRoutes(app, jsonResponse, jsonError);
// ══════════════════════════════════════════════════════════════
// HEALTH & STATS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Health check endpoint ----
app.get("/api/health", (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60;
res.json({
success: true,
results: {
status: "healthy",
version: version,
uptime: `${hours}h ${minutes}m ${seconds}s`,
uptimeSeconds: uptime,
timestamp: new Date().toISOString(),
node: process.version,
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB",
},
},
});
});
// ---- FEATURE: Cache & API statistics ----
app.get("/api/stats", (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
res.json({
success: true,
results: {
uptime: uptime + "s",
requests: {
total: requestCount,
errors: errorCount,
successRate: requestCount > 0
? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%"
: "100%",
},
cache: {
type: "in-memory",
ttl: "5 minutes",
description: "Map-based cache with TTL expiration",
},
endpoints: 38,
timestamp: new Date().toISOString(),
},
});
});
// ---- FEATURE: Mirror status and health check ----
app.get("/api/mirrors", async (req, res) => {
try {
const status = await getMirrorStatus();
res.json({ success: true, results: status });
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: Cache statistics ----
app.get("/api/cache/stats", (req, res) => {
const stats = getCacheStats();
res.json({ success: true, results: stats });
});
// ---- FEATURE: Reset mirror cache ----
app.post("/api/mirrors/reset", (req, res) => {
resetMirrorCache();
res.json({ success: true, message: "Mirror cache reset" });
});
// ---- FEATURE: Proxy status (ScraperAPI / FlareSolverr) ----
app.get("/api/proxy/status", (req, res) => {
const status = getProxyStatus();
res.json({ success: true, results: status });
});
// ---- FEATURE: OpenAPI specification ----
app.get("/api/openapi", (req, res) => {
res.json({
success: true,
openapi: "3.0.3",
info: {
title: "AniKotoAPI",
description: "Free REST API for anime data from anikototv.to",
version: version,
contact: {
name: "Shinei Nouzen",
url: "https://github.com/Shineii86/AniKotoAPI",
},
},
servers: [
{ url: "https://anikototvapi.vercel.app/api", description: "Production" },
],
paths: {
"/": { get: { summary: "Homepage data", tags: ["Home"] } },
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
"/info": { get: { summary: "Anime info", tags: ["Anime"] } },
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Episodes"] } },
"/servers": { get: { summary: "Server list", tags: ["Streaming"] } },
"/stream": { get: { summary: "Stream URL", tags: ["Streaming"] } },
"/download": { get: { summary: "Download links", tags: ["Streaming"] } },
"/schedule": { get: { summary: "Anime schedule", tags: ["Schedule"] } },
"/random": { get: { summary: "Random anime", tags: ["Discovery"] } },
"/top-ten": { get: { summary: "Top 10 rankings", tags: ["Rankings"] } },
"/trending": { get: { summary: "Trending anime", tags: ["Discovery"] } },
"/spotlight": { get: { summary: "Spotlight anime", tags: ["Discovery"] } },
"/most-popular": { get: { summary: "Most popular", tags: ["Rankings"] } },
"/new-release": { get: { summary: "New releases", tags: ["Releases"] } },
"/filter": { get: { summary: "Advanced filter", tags: ["Filter"] } },
"/genre/{name}": { get: { summary: "By genre", tags: ["Categories"] } },
"/type/{name}": { get: { summary: "By type", tags: ["Categories"] } },
"/status/{name}": { get: { summary: "By status", tags: ["Categories"] } },
"/health": { get: { summary: "Health check", tags: ["System"] } },
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
"/mirrors": { get: { summary: "Mirror status", tags: ["System"] } },
"/mirrors/reset": { post: { summary: "Reset mirror cache", tags: ["System"] } },
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
"/stream/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", tags: ["Streaming"] } },
"/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } },
"/stream/proxy": { get: { summary: "M3U8 playlist proxy (rewrites URLs)", tags: ["Streaming"] } },
"/stream/ts-proxy": { get: { summary: "TS video segment proxy", tags: ["Streaming"] } },
"/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } },
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
"/newly-added": { get: { summary: "Newly added", tags: ["Releases"] } },
"/latest-updated": { get: { summary: "Recently updated", tags: ["Releases"] } },
"/trending-sidebar": { get: { summary: "Trending sidebar", tags: ["Discovery"] } },
"/seasons/{id}": { get: { summary: "Season data", tags: ["Anime"] } },
"/watch-order/{id}": { get: { summary: "Watch order", tags: ["Anime"] } },
"/az-list/{letter}": { get: { summary: "A-Z listing", tags: ["Discovery"] } },
"/suggestions": { get: { summary: "Anime suggestions", tags: ["Search"] } },
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Discovery"] } },
"/top-rankings": { get: { summary: "Top anime rankings (day/week/month)", tags: ["Rankings"] } },
"/recently-updated": { get: { summary: "Recently updated (all/dub/sub tabs)", tags: ["Releases"] } },
"/completed": { get: { summary: "Completed anime series", tags: ["Rankings"] } },
"/proxy/status": { get: { summary: "Proxy status (ScraperAPI / FlareSolverr)", tags: ["System"] } },
},
tags: [
{ name: "Home", description: "Homepage data" },
{ name: "Search", description: "Search & suggestions" },
{ name: "Anime", description: "Anime details" },
{ name: "Episodes", description: "Episode lists" },
{ name: "Streaming", description: "Stream URLs & servers" },
{ name: "Schedule", description: "Airing schedule" },
{ name: "Discovery", description: "Random, trending, spotlight" },
{ name: "Rankings", description: "Top 10, most popular" },
{ name: "Releases", description: "New releases" },
{ name: "Filter", description: "Advanced filtering" },
{ name: "Categories", description: "Genre, type, status" },
{ name: "System", description: "Health, stats, OpenAPI" },
],
});
});
};
export { createApiRoutes };
// ══════════════════════════════════════════════════════════════ END: apiRoutes.js