mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
v2.1.4: Security hardening, bug fixes, code quality improvements
- Move XOR keys to env vars (PRU_PROXY_KEY, PIPE_OBF_KEY) - Fix multi-search to combine query + filters - Fix deepTranslate to return clone instead of mutating - Improve getBestStream quality matching and fallback - Add input sanitization for all route params - CORS blocks unknown origins in production - Security headers in vercel.json at CDN level - stats/genre uses Promise.allSettled for parallel execution - Delete dead server.py, fix version strings
This commit is contained in:
@@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => {
|
||||
* @returns {Promise<object>} Paginated filtered results
|
||||
*/
|
||||
const filterAnime = async ({
|
||||
genre, tag, year, season, format, status,
|
||||
search, genre, tag, year, season, format, status,
|
||||
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
||||
}) => {
|
||||
const SORT_MAP = {
|
||||
@@ -548,13 +548,14 @@ const filterAnime = async ({
|
||||
START_DATE_DESC: "START_DATE_DESC",
|
||||
FAVOURITES_DESC: "FAVOURITES_DESC",
|
||||
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
||||
SEARCH_MATCH: "SEARCH_MATCH",
|
||||
};
|
||||
|
||||
// NOTE: Build dynamic GraphQL arguments based on provided filters
|
||||
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
||||
const variables = { page, perPage };
|
||||
const varTypes = ["$page: Int", "$perPage: Int"];
|
||||
|
||||
if (search) { args.push("search: $search"); variables.search = search; varTypes.push("$search: String"); }
|
||||
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
||||
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
||||
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
||||
@@ -681,12 +682,13 @@ const getTrendingDaily = async (page = 1, perPage = 20) => {
|
||||
// ---- FEATURE: Weekly trending anime ----
|
||||
/**
|
||||
* Fetches currently trending anime (last 30 days).
|
||||
* Returns more results than daily to cover a wider window.
|
||||
*
|
||||
* @param {number} [page=1] - Page number
|
||||
* @param {number} [perPage=20] - Results per page
|
||||
* @param {number} [perPage=40] - Results per page (default 40, wider than daily)
|
||||
* @returns {Promise<object>} Paginated trending results
|
||||
*/
|
||||
const getTrendingWeekly = async (page = 1, perPage = 20) => {
|
||||
const getTrendingWeekly = async (page = 1, perPage = 40) => {
|
||||
const gql = `
|
||||
query ($page: Int, $perPage: Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
|
||||
+31
-17
@@ -73,7 +73,10 @@ const encodePipeRequest = (payload) => {
|
||||
*
|
||||
* @type {Buffer}
|
||||
*/
|
||||
const PIPE_OBF_KEY = Buffer.from("71951034f8fbcf53d89db52ceb3dc22c", "hex");
|
||||
const PIPE_OBF_KEY = Buffer.from(
|
||||
process.env.PIPE_OBF_KEY || "71951034f8fbcf53d89db52ceb3dc22c",
|
||||
"hex"
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {string} encodedStr - The base64url-encoded response
|
||||
@@ -227,23 +230,28 @@ const translateId = (encodedId) => {
|
||||
* Walks a JSON structure and decodes any base64 "id" fields.
|
||||
* Saves the original base64 value in a `rawPipeId` field on episode objects
|
||||
* so the pipe sources endpoint can be called directly from the client.
|
||||
* Returns a deep clone — does not mutate the original object.
|
||||
*
|
||||
* @param {object|Array} obj - The object or process
|
||||
* @returns {object|Array} The same object with decoded IDs (mutated in place)
|
||||
* @param {object|Array} obj - The object or array to process
|
||||
* @returns {object|Array} A new object with decoded IDs
|
||||
*/
|
||||
const deepTranslate = (obj) => {
|
||||
if (obj && typeof obj === "object") {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === "id" && typeof obj[key] === "string") {
|
||||
// NOTE: Save original base64 before decoding (needed for pipe sources call)
|
||||
if (obj.number !== undefined) {
|
||||
obj.rawPipeId = obj[key];
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => deepTranslate(item));
|
||||
}
|
||||
const clone = { ...obj };
|
||||
for (const key of Object.keys(clone)) {
|
||||
if (key === "id" && typeof clone[key] === "string") {
|
||||
if (clone.number !== undefined) {
|
||||
clone.rawPipeId = clone[key];
|
||||
}
|
||||
obj[key] = translateId(obj[key]);
|
||||
} else if (typeof obj[key] === "object") {
|
||||
deepTranslate(obj[key]);
|
||||
clone[key] = translateId(clone[key]);
|
||||
} else if (typeof clone[key] === "object") {
|
||||
clone[key] = deepTranslate(clone[key]);
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
@@ -503,10 +511,14 @@ const extractSkipTimes = (sources) => {
|
||||
* @returns {object|null} Best available stream object or null
|
||||
*/
|
||||
const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||
const streams = (sources.streams || []).filter((s) => s.type === "hls" && s.url);
|
||||
const streams = (sources.streams || []).filter((s) => s.url);
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
// NOTE: Try preferred quality first, then fallback
|
||||
const hlsStreams = streams.filter((s) => s.type === "hls" || !s.type || s.url?.endsWith(".m3u8") || s.url?.includes("m3u8"));
|
||||
const dashStreams = streams.filter((s) => s.type === "dash" || s.url?.endsWith(".mpd"));
|
||||
|
||||
const usable = hlsStreams.length > 0 ? hlsStreams : streams;
|
||||
|
||||
const qualityOrder = ["1080p", "720p", "480p", "360p"];
|
||||
const startIdx = qualityOrder.indexOf(preferredQuality);
|
||||
const ordered = startIdx >= 0
|
||||
@@ -514,15 +526,17 @@ const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||
: qualityOrder;
|
||||
|
||||
for (const q of ordered) {
|
||||
const match = streams.find((s) => s.quality === q);
|
||||
const match = usable.find((s) => {
|
||||
const quality = (s.quality || s.label || "").toLowerCase();
|
||||
return quality.includes(q);
|
||||
});
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
// NOTE: Some providers don't include quality — return first active HLS stream, or first HLS stream
|
||||
const active = streams.find((s) => s.isActive);
|
||||
const active = usable.find((s) => s.isActive);
|
||||
if (active) return active;
|
||||
|
||||
return streams[0];
|
||||
return usable[0];
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
+50
-33
@@ -23,6 +23,18 @@ const pipe = require("../helpers/pipe");
|
||||
const { getCached, setCache } = require("../helpers/cache");
|
||||
const { addCreatorInfo } = require("../middleware/creatorInfo");
|
||||
|
||||
// ---- FEATURE: Input sanitization helpers ----
|
||||
const sanitizeId = (val) => {
|
||||
const n = parseInt(val);
|
||||
return isNaN(n) || n <= 0 ? null : n;
|
||||
};
|
||||
|
||||
const sanitizeString = (val, maxLen = 100) => {
|
||||
if (typeof val !== "string") return null;
|
||||
const cleaned = val.replace(/[<>"'`;\\]/g, "").trim();
|
||||
return cleaned.length > 0 && cleaned.length <= maxLen ? cleaned : null;
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ROUTE REGISTRATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -116,16 +128,10 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Multi-parameter search (query + filters combined) ----
|
||||
router.get("/multi-search", async (req, res) => {
|
||||
try {
|
||||
const { q, genre, tag, year, season, format, status, sort = "SEARCH_MATCH", page = 1, per_page = 20 } = req.query;
|
||||
const { q, genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
|
||||
|
||||
// NOTE: If only query is provided, use regular search
|
||||
if (q && !genre && !tag && !year && !season && !format && !status) {
|
||||
const result = await anilist.searchAnime(q, parseInt(page), parseInt(per_page));
|
||||
return jsonResponse(res, result);
|
||||
}
|
||||
|
||||
// NOTE: If filters only, use filter endpoint
|
||||
const result = await anilist.filterAnime({
|
||||
search: q || null,
|
||||
genre, tag, year: year ? parseInt(year) : null,
|
||||
season, format, status, sort,
|
||||
page: parseInt(page), perPage: parseInt(per_page),
|
||||
@@ -341,13 +347,15 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Seasonal anime by year and season ----
|
||||
router.get("/season/:year/:season", async (req, res) => {
|
||||
try {
|
||||
const { year, season } = req.params;
|
||||
const year = sanitizeId(req.params.year);
|
||||
const season = sanitizeString(req.params.season?.toUpperCase());
|
||||
if (!year || !season) return jsonError(res, "Invalid year or season", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `season:${year}:${season}:${page}:${per_page}`;
|
||||
|
||||
let result = getCached(cacheKey);
|
||||
if (!result) {
|
||||
result = await anilist.getSeasonalAnime(parseInt(year), season, parseInt(page), parseInt(per_page));
|
||||
result = await anilist.getSeasonalAnime(year, season, parseInt(page), parseInt(per_page));
|
||||
setCache(cacheKey, result, 300 * 1000);
|
||||
}
|
||||
|
||||
@@ -360,13 +368,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Anime by studio name ----
|
||||
router.get("/studio/:name", async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const name = sanitizeString(decodeURIComponent(req.params.name));
|
||||
if (!name) return jsonError(res, "Invalid studio name", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `studio:${name}:${page}:${per_page}`;
|
||||
|
||||
let result = getCached(cacheKey);
|
||||
if (!result) {
|
||||
result = await anilist.getAnimeByStudio(decodeURIComponent(name), parseInt(page), parseInt(per_page));
|
||||
result = await anilist.getAnimeByStudio(name, parseInt(page), parseInt(per_page));
|
||||
setCache(cacheKey, result, 300 * 1000);
|
||||
}
|
||||
|
||||
@@ -415,13 +424,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Anime by genre name ----
|
||||
router.get("/genre/:name", async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const name = sanitizeString(decodeURIComponent(req.params.name));
|
||||
if (!name) return jsonError(res, "Invalid genre name", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `genre:${name}:${page}:${per_page}`;
|
||||
|
||||
let result = getCached(cacheKey);
|
||||
if (!result) {
|
||||
result = await anilist.filterAnime({ genre: decodeURIComponent(name), page: parseInt(page), perPage: parseInt(per_page) });
|
||||
result = await anilist.filterAnime({ genre: name, page: parseInt(page), perPage: parseInt(per_page) });
|
||||
setCache(cacheKey, result, 120 * 1000);
|
||||
}
|
||||
|
||||
@@ -434,13 +444,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Anime by year ----
|
||||
router.get("/year/:year", async (req, res) => {
|
||||
try {
|
||||
const { year } = req.params;
|
||||
const year = sanitizeId(req.params.year);
|
||||
if (!year) return jsonError(res, "Invalid year", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `year:${year}:${page}:${per_page}`;
|
||||
|
||||
let result = getCached(cacheKey);
|
||||
if (!result) {
|
||||
result = await anilist.filterAnime({ year: parseInt(year), page: parseInt(page), perPage: parseInt(per_page) });
|
||||
result = await anilist.filterAnime({ year, page: parseInt(page), perPage: parseInt(per_page) });
|
||||
setCache(cacheKey, result, 300 * 1000);
|
||||
}
|
||||
|
||||
@@ -453,7 +464,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Anime by status ----
|
||||
router.get("/status/:status", async (req, res) => {
|
||||
try {
|
||||
const { status } = req.params;
|
||||
const status = sanitizeString(req.params.status?.toUpperCase());
|
||||
if (!status) return jsonError(res, "Invalid status", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `status:${status}:${page}:${per_page}`;
|
||||
|
||||
@@ -472,7 +484,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Anime by format ----
|
||||
router.get("/format/:format", async (req, res) => {
|
||||
try {
|
||||
const { format } = req.params;
|
||||
const format = sanitizeString(req.params.format?.toUpperCase());
|
||||
if (!format) return jsonError(res, "Invalid format", 400);
|
||||
const { page = 1, per_page = 20 } = req.query;
|
||||
const cacheKey = `format:${format}:${page}:${per_page}`;
|
||||
|
||||
@@ -495,8 +508,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Character detail with anime list ----
|
||||
router.get("/character/:id", async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return jsonError(res, "Invalid character ID", 400);
|
||||
const id = sanitizeId(req.params.id);
|
||||
if (!id) return jsonError(res, "Invalid character ID", 400);
|
||||
|
||||
const cacheKey = `character:${id}`;
|
||||
let result = getCached(cacheKey);
|
||||
@@ -515,8 +528,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
// ---- FEATURE: Staff detail with anime list ----
|
||||
router.get("/staff/:id", async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return jsonError(res, "Invalid staff ID", 400);
|
||||
const id = sanitizeId(req.params.id);
|
||||
if (!id) return jsonError(res, "Invalid staff ID", 400);
|
||||
|
||||
const cacheKey = `staff:${id}`;
|
||||
let result = getCached(cacheKey);
|
||||
@@ -828,16 +841,20 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
const genres = await anilist.getAllGenres();
|
||||
const stats = {};
|
||||
|
||||
// NOTE: Fetch top anime for each genre (limited to avoid rate limiting)
|
||||
for (const genre of genres.slice(0, 10)) {
|
||||
try {
|
||||
const data = await anilist.filterAnime({ genre, perPage: 1 });
|
||||
stats[genre] = {
|
||||
const topGenres = genres.slice(0, 10);
|
||||
const results = await Promise.allSettled(
|
||||
topGenres.map((genre) =>
|
||||
anilist.filterAnime({ genre, perPage: 1 }).then((data) => ({
|
||||
genre,
|
||||
total: data.total,
|
||||
topAnime: data.results[0] || null,
|
||||
};
|
||||
} catch {
|
||||
stats[genre] = { total: 0, topAnime: null };
|
||||
})).catch(() => ({ genre, total: 0, topAnime: null }))
|
||||
)
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled") {
|
||||
stats[r.value.genre] = { total: r.value.total, topAnime: r.value.topAnime };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,7 +880,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
let result = getCached(cacheKey);
|
||||
if (!result) {
|
||||
// NOTE: Get airing schedule for the month
|
||||
const schedule = await anilist.getSchedule(1, 50);
|
||||
const schedule = await anilist.getSchedule(1, 200);
|
||||
const monthStart = new Date(y, m - 1, 1);
|
||||
const monthEnd = new Date(y, m, 0, 23, 59, 59);
|
||||
|
||||
@@ -1106,7 +1123,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
success: true,
|
||||
results: {
|
||||
status: "healthy",
|
||||
version: "2.1.3",
|
||||
version: "2.1.4",
|
||||
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||
uptimeSeconds: uptime,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -1161,7 +1178,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
info: {
|
||||
title: "MiruroAPI",
|
||||
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
||||
version: "2.1.3",
|
||||
version: "2.1.4",
|
||||
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
||||
},
|
||||
servers: [
|
||||
|
||||
Reference in New Issue
Block a user