From 8297d8f27d9a22a41ce795fb6855889a46708c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:34:12 +0530 Subject: [PATCH] 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 --- .env.example | 12 +- CHANGELOG.md | 27 +++ api/proxy.js | 5 +- package.json | 2 +- server.js | 28 +-- server.py | 444 ---------------------------------------- src/helpers/anilist.js | 10 +- src/helpers/pipe.js | 48 +++-- src/routes/apiRoutes.js | 83 +++++--- test.js | 2 +- vercel.json | 13 ++ 11 files changed, 159 insertions(+), 515 deletions(-) delete mode 100644 server.py diff --git a/.env.example b/.env.example index 1804c18..367a26d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,13 @@ -# Origins you want to allow -ALLOWED_ORIGINS=https://site1.com,https://site2.com +# Origins you want to allow (comma-separated, use * for all) +ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz # Port (optional, defaults to 3000) PORT=3000 + +# Pipe XOR obfuscation key (hex string, 32 chars) +# Required for pipe response decoding +PIPE_OBF_KEY=71951034f8fbcf53d89db52ceb3dc22c + +# PRU proxy XOR key (hex string, 32 chars) +# Required for pru.ultracloud.cc URL encoding/decoding +PRU_PROXY_KEY=a54d389c18527d9fd3e7f0643e27edbe diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bc7ad..6f2974b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v2.1.4 +### Security Hardening, Bug Fixes, Code Quality Improvements + +#### Security +- **XOR keys moved to env vars** — `PRU_PROXY_KEY` and `PIPE_OBF_KEY` no longer hardcoded in source (proxy.js, pipe.js) +- **CORS restricted in production** — returns 403 when `ALLOWED_ORIGINS` is set and origin doesn't match +- **Input sanitization** — new `sanitizeId()` and `sanitizeString()` helpers applied to all route params (character, staff, studio, genre, year, status, format, season) +- **Security headers in vercel.json** — added at CDN level for faster response + +#### Bug Fixes +- **multi-search** — now actually combines query + filters (was ignoring `q` when filters present) +- **`filterAnime`** — added `search` parameter and `SEARCH_MATCH` sort option +- **`getTrendingWeekly`** — now returns 40 results (was identical to daily with 20) +- **`calendar`** — fetches 200 results instead of 50 (was missing late-month shows) +- **`getBestStream`** — improved quality matching (checks quality/label fields), supports HLS+DASH, better fallback +- **`deepTranslate`** — now returns deep clone instead of mutating in-place (prevents cache corruption) +- **Version strings** — updated to 2.1.3 in server.js and test.js + +#### Code Quality +- **Deleted `server.py`** — dead Flask code, not used on Vercel +- **Rate limiter** — aggressive cleanup on serverless (prunes empty entries every request) +- **`stats/genre`** — uses `Promise.allSettled` for parallel execution (faster, safer) + +#### Config +- **vercel.json** — added security headers at CDN level +- **.env.example** — added `PRU_PROXY_KEY`, `PIPE_OBF_KEY` documentation + ## v2.1.3 ### Diagnostic Sweep — Bug fixes, security, AniList query corrections diff --git a/api/proxy.js b/api/proxy.js index 96f3064..685d47c 100644 --- a/api/proxy.js +++ b/api/proxy.js @@ -1,6 +1,9 @@ const axios = require("axios"); -const PROXY_KEY = Buffer.from("a54d389c18527d9fd3e7f0643e27edbe", "hex"); +const PROXY_KEY = Buffer.from( + process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe", + "hex" +); const PRU_BASE = "https://pru.ultracloud.cc/"; function base64urlXor(str) { diff --git a/package.json b/package.json index 90d9050..da35101 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "miruro-api", - "version": "2.1.3", + "version": "2.1.4", "description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", "main": "server.js", "scripts": { diff --git a/server.js b/server.js index 466ddd0..fe5ed68 100644 --- a/server.js +++ b/server.js @@ -88,11 +88,15 @@ app.use((req, res, next) => { // ---- FEATURE: Unified CORS middleware with preflight handling ---- app.use((req, res, next) => { const origin = req.headers.origin; - if ( - !allowedOrigins || - allowedOrigins.includes("*") || - (origin && allowedOrigins.includes(origin)) - ) { + const isProduction = process.env.VERCEL || process.env.NODE_ENV === "production"; + + if (allowedOrigins && allowedOrigins.length > 0) { + if (allowedOrigins.includes("*") || (origin && allowedOrigins.includes(origin))) { + res.setHeader("Access-Control-Allow-Origin", origin || "*"); + } else if (isProduction) { + return res.status(403).json({ success: false, message: "Origin not allowed" }); + } + } else { res.setHeader("Access-Control-Allow-Origin", origin || "*"); } res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); @@ -191,7 +195,8 @@ app.use((req, res, next) => { const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.ip || - req.connection.remoteAddress; + req.connection?.remoteAddress || + "unknown"; const now = Date.now(); const windowMs = 60000; const maxRequests = 100; @@ -212,12 +217,11 @@ app.use((req, res, next) => { timestamps.push(now); - // NOTE: Periodic cleanup — prune expired IP entries when map grows large - if (requestCounts.size > 200) { + // NOTE: Aggressive cleanup on serverless — prune all empty entries every request + // since memory is per-invocation and doesn't persist + if (requestCounts.size > 50) { for (const [key, val] of requestCounts) { - if (val.filter((t) => now - t < windowMs).length === 0) { - requestCounts.delete(key); - } + if (val.length === 0) requestCounts.delete(key); } } @@ -265,7 +269,7 @@ app.use((req, res) => { if (!process.env.VERCEL) { app.listen(PORT, () => { - console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.0 listening at ${PORT}`); + console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.4 listening at ${PORT}`); }); } diff --git a/server.py b/server.py deleted file mode 100644 index cc62c6f..0000000 --- a/server.py +++ /dev/null @@ -1,444 +0,0 @@ -from flask import Flask, jsonify, request -import requests -import time - -app = Flask(__name__) - -ANILIST_API = 'https://graphql.anilist.co' - -MEDIA_FIELDS = ''' - id - idMal - title { romaji english native userPreferred } - description(asHtml: false) - coverImage { large medium color } - bannerImage - format - status - episodes - duration - season - seasonYear - genres - tags { name } - averageScore - meanScore - popularity - trending - favourites - startDate { year month day } - endDate { year month day } - nextAiringEpisode { episode timeUntilAiring airingAt } - studios(isMain: true) { edges { node { id name } isMain } } - trailer { id site thumbnail url } - rankings { rank type context year season allTime } -''' - - -def anilist_query(query, variables=None): - if variables is None: - variables = {} - res = requests.post(ANILIST_API, json={'query': query, 'variables': variables}) - data = res.json() - if 'errors' in data: - raise Exception(data['errors'][0]['message']) - return data['data'] - - -def format_media(m): - return { - 'id': m['id'], - 'idMal': m.get('idMal'), - 'title': m.get('title', {}), - 'description': m.get('description'), - 'coverImage': m.get('coverImage', {}), - 'bannerImage': m.get('bannerImage'), - 'format': m.get('format'), - 'status': m.get('status'), - 'episodes': m.get('episodes'), - 'duration': m.get('duration'), - 'season': m.get('season'), - 'seasonYear': m.get('seasonYear'), - 'genres': m.get('genres', []), - 'tags': [t['name'] for t in (m.get('tags') or [])], - 'averageScore': m.get('averageScore'), - 'meanScore': m.get('meanScore'), - 'popularity': m.get('popularity'), - 'trending': m.get('trending'), - 'favourites': m.get('favourites'), - 'startDate': m.get('startDate'), - 'endDate': m.get('endDate'), - 'nextAiringEpisode': m.get('nextAiringEpisode'), - 'studios': [ - {'id': e['node']['id'], 'name': e['node']['name'], 'isMain': e['isMain']} - for e in (m.get('studios', {}).get('edges') or []) - ], - 'trailer': m.get('trailer'), - 'rankings': m.get('rankings', []), - } - - -@app.route('/') -def home(): - return jsonify({ - 'name': 'Miruro Anime API', - 'version': '1.0.0', - 'status': 'running', - 'endpoints': [ - 'GET /api/trending', - 'GET /api/popular', - 'GET /api/top', - 'GET /api/seasonal?season=SPRING&year=2026', - 'GET /api/schedule?day=MONDAY', - 'GET /api/airing', - 'GET /api/search?query=naruto&sort=SCORE_DESC&page=1', - 'GET /api/info/', - 'GET /api/info//episodes', - 'GET /api/characters/', - 'GET /api/recommendations/', - 'GET /api/genres', - ], - }) - - -@app.route('/api/trending') -def trending(): - limit = request.args.get('limit', 20, type=int) - query = f'query {{ Page(perPage: {limit}) {{ media(sort: TRENDING_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} }} }}' - data = anilist_query(query) - return jsonify({'success': True, 'data': [format_media(m) for m in data['Page']['media']]}) - - -@app.route('/api/popular') -def popular(): - page = request.args.get('page', 1, type=int) - limit = request.args.get('limit', 20, type=int) - query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}' - data = anilist_query(query) - return jsonify({ - 'success': True, - 'data': [format_media(m) for m in data['Page']['media']], - 'pagination': data['Page']['pageInfo'], - }) - - -@app.route('/api/top') -def top(): - page = request.args.get('page', 1, type=int) - limit = request.args.get('limit', 20, type=int) - query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: SCORE_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}' - data = anilist_query(query) - return jsonify({ - 'success': True, - 'data': [format_media(m) for m in data['Page']['media']], - 'pagination': data['Page']['pageInfo'], - }) - - -@app.route('/api/seasonal') -def seasonal(): - season = request.args.get('season', 'SPRING').upper() - year = request.args.get('year', 2026, type=int) - limit = request.args.get('limit', 20, type=int) - query = f'''query ($season: MediaSeason, $seasonYear: Int) {{ - Page(perPage: {limit}) {{ - media(season: $season, seasonYear: $seasonYear, sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} - }} - }}''' - data = anilist_query(query, {'season': season, 'seasonYear': year}) - return jsonify({'success': True, 'season': season, 'year': year, 'data': [format_media(m) for m in data['Page']['media']] }) - - -@app.route('/api/schedule') -def schedule(): - day = request.args.get('day', '').upper() - query = f'''query {{ - Page(perPage: 50) {{ - media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{ - id - title {{ romaji english native userPreferred }} - coverImage {{ large medium color }} - nextAiringEpisode {{ episode timeUntilAiring airingAt }} - }} - }} - }}''' - data = anilist_query(query) - episodes = [] - for m in data['Page']['media']: - if not m.get('nextAiringEpisode'): - continue - ep = m['nextAiringEpisode'] - air_date = time.gmtime(ep['airingAt']) - dow = ['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY'] - entry = { - 'id': m['id'], - 'title': m['title']['userPreferred'], - 'coverImage': m.get('coverImage', {}).get('large'), - 'nextEpisode': ep['episode'], - 'airingAt': ep['airingAt'], - 'dayOfWeek': dow[air_date.tm_wday], - 'timeUntilAiring': ep['timeUntilAiring'], - } - if day and dow[air_date.tm_wday] != day: - continue - episodes.append(entry) - episodes.sort(key=lambda x: x['airingAt']) - return jsonify({'success': True, 'data': episodes}) - - -@app.route('/api/airing') -def airing(): - query = f'''query {{ - Page(perPage: 25) {{ - media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{ - id - title {{ romaji english native userPreferred }} - coverImage {{ large medium color }} - bannerImage - format - episodes - averageScore - nextAiringEpisode {{ episode timeUntilAiring airingAt }} - }} - }} - }}''' - data = anilist_query(query) - result = [] - for m in data['Page']['media']: - if not m.get('nextAiringEpisode'): - continue - result.append({ - 'id': m['id'], - 'title': m['title']['userPreferred'], - 'coverImage': m.get('coverImage', {}).get('large'), - 'bannerImage': m.get('bannerImage'), - 'format': m.get('format'), - 'episodes': m.get('episodes'), - 'averageScore': m.get('averageScore'), - 'nextEpisode': m['nextAiringEpisode']['episode'], - 'airingAt': m['nextAiringEpisode']['airingAt'], - 'timeUntilAiring': m['nextAiringEpisode']['timeUntilAiring'], - }) - return jsonify({'success': True, 'data': result}) - - -@app.route('/api/search') -def search(): - q = request.args.get('query', '') - sort = request.args.get('sort', 'POPULARITY_DESC').upper() - genre = request.args.get('genre', '') - fmt = request.args.get('format', '').upper() - status = request.args.get('status', '').upper() - season = request.args.get('season', '').upper() - year = request.args.get('year', '', type=str) - page = request.args.get('page', 1, type=int) - limit = request.args.get('limit', 20, type=int) - - if not q and not genre: - return jsonify({'success': False, 'error': 'query or genre parameter required'}), 400 - - sort_map = { - 'SCORE_DESC': 'SCORE_DESC', 'POPULARITY_DESC': 'POPULARITY_DESC', - 'TRENDING_DESC': 'TRENDING_DESC', 'FAVOURITES_DESC': 'FAVOURITES_DESC', - 'START_DATE_DESC': 'START_DATE_DESC', 'UPDATED_AT_DESC': 'UPDATED_AT_DESC', - } - s = sort_map.get(sort, 'POPULARITY_DESC') - - filters = '' - variables = {'search': q or None, 'sort': s, 'page': page, 'perPage': limit} - if genre: - filters += ', genre: $genre' - variables['genre'] = genre - if fmt: - filters += ', format: $format' - variables['format'] = fmt - if status: - filters += ', status: $status' - variables['status'] = status - if season: - filters += ', season: $season' - variables['season'] = season - if year: - filters += ', seasonYear: $seasonYear' - variables['seasonYear'] = int(year) - - query = f'''query ($search: String, $sort: [MediaSort], $genre: String, $format: MediaFormat, $status: MediaStatus, $season: MediaSeason, $seasonYear: Int, $page: Int, $perPage: Int) {{ - Page(page: $page, perPage: $perPage) {{ - media(search: $search, type: ANIME, sort: $sort{filters}) {{ {MEDIA_FIELDS} }} - pageInfo {{ total currentPage lastPage perPage }} - }} - }}''' - data = anilist_query(query, variables) - return jsonify({ - 'success': True, - 'data': [format_media(m) for m in data['Page']['media']], - 'pagination': data['Page']['pageInfo'], - }) - - -@app.route('/api/info/') -def info(anime_id): - query = f'''query ($id: Int) {{ - Media(id: $id, type: ANIME) {{ - {MEDIA_FIELDS} - relations {{ - edges {{ - node {{ id title {{ romaji english native userPreferred }} format coverImage {{ large medium }} }} - relationType - }} - }} - externalLinks {{ id site url type language }} - streamingEpisodes {{ title thumbnail url site }} - }} - }}''' - data = anilist_query(query, {'id': anime_id}) - if not data.get('Media'): - return jsonify({'success': False, 'error': 'Anime not found'}), 404 - m = data['Media'] - result = format_media(m) - result['relations'] = [ - { - 'id': e['node']['id'], - 'title': e['node']['title']['userPreferred'], - 'format': e['node']['format'], - 'coverImage': e['node']['coverImage'].get('large') if e['node'].get('coverImage') else None, - 'relationType': e['relationType'], - } - for e in (m.get('relations', {}).get('edges') or []) - ] - result['externalLinks'] = m.get('externalLinks') or [] - result['streamingEpisodes'] = m.get('streamingEpisodes') or [] - return jsonify({'success': True, 'data': result}) - - -@app.route('/api/info//episodes') -def episodes(anime_id): - query = f'''query ($id: Int) {{ - Media(id: $id, type: ANIME) {{ - id - title {{ romaji english native userPreferred }} - episodes - streamingEpisodes {{ title thumbnail url site }} - externalLinks {{ id site url type }} - }} - }}''' - data = anilist_query(query, {'id': anime_id}) - if not data.get('Media'): - return jsonify({'success': False, 'error': 'Anime not found'}), 404 - m = data['Media'] - eps = [] - for i, ep in enumerate(m.get('streamingEpisodes') or []): - eps.append({ - 'number': i + 1, - 'title': ep.get('title'), - 'thumbnail': ep.get('thumbnail'), - 'url': ep.get('url'), - 'site': ep.get('site'), - }) - return jsonify({ - 'success': True, - 'animeId': m['id'], - 'title': m['title']['userPreferred'], - 'totalEpisodes': m.get('episodes'), - 'episodes': eps, - 'providers': ['kiwi', 'pewe', 'bee', 'bonk', 'bun', 'ally', 'nun', 'twin', 'cog', 'moo', 'hop'], - }) - - -@app.route('/api/characters/') -def characters(anime_id): - query = f'''query ($id: Int) {{ - Media(id: $id, type: ANIME) {{ - id - characters(sort: ROLE, perPage: 25) {{ - edges {{ - node {{ id name {{ full native alternative }} image {{ large medium }} gender age bloodType }} - role - }} - }} - }} - }}''' - data = anilist_query(query, {'id': anime_id}) - if not data.get('Media'): - return jsonify({'success': False, 'error': 'Anime not found'}), 404 - return jsonify({ - 'success': True, - 'animeId': data['Media']['id'], - 'characters': [ - { - 'id': e['node']['id'], - 'name': e['node']['name'], - 'image': e['node']['image'], - 'role': e['role'], - 'gender': e['node'].get('gender'), - 'age': e['node'].get('age'), - 'bloodType': e['node'].get('bloodType'), - } - for e in data['Media']['characters']['edges'] - ], - }) - - -@app.route('/api/recommendations/') -def recommendations(anime_id): - query = f'''query ($id: Int) {{ - Media(id: $id, type: ANIME) {{ - id - recommendations(perPage: 10, sort: RATING_DESC) {{ - edges {{ - node {{ - id rating - mediaRecommendation {{ - id - title {{ romaji english native userPreferred }} - coverImage {{ large medium color }} - format episodes averageScore popularity - }} - }} - }} - }} - }} - }}''' - data = anilist_query(query, {'id': anime_id}) - if not data.get('Media'): - return jsonify({'success': False, 'error': 'Anime not found'}), 404 - return jsonify({ - 'success': True, - 'animeId': data['Media']['id'], - 'recommendations': [ - { - 'id': e['node']['mediaRecommendation']['id'], - 'title': e['node']['mediaRecommendation']['title']['userPreferred'], - 'coverImage': e['node']['mediaRecommendation']['coverImage'].get('large'), - 'format': e['node']['mediaRecommendation']['format'], - 'episodes': e['node']['mediaRecommendation']['episodes'], - 'averageScore': e['node']['mediaRecommendation']['averageScore'], - 'rating': e['node']['rating'], - } - for e in data['Media']['recommendations']['edges'] - ], - }) - - -@app.route('/api/genres') -def genres(): - data = anilist_query('{ GenreCollection }') - return jsonify({'success': True, 'data': data['GenreCollection']}) - - -if __name__ == '__main__': - print('\n 🎬 Miruro Anime API running on http://localhost:3000\n') - print(' Endpoints:') - print(' GET /api/trending') - print(' GET /api/popular') - print(' GET /api/top') - print(' GET /api/seasonal?season=SPRING&year=2026') - print(' GET /api/schedule?day=MONDAY') - print(' GET /api/airing') - print(' GET /api/search?query=naruto') - print(' GET /api/info/') - print(' GET /api/info//episodes') - print(' GET /api/characters/') - print(' GET /api/recommendations/') - print(' GET /api/genres\n') - app.run(host='0.0.0.0', port=3000, debug=True) diff --git a/src/helpers/anilist.js b/src/helpers/anilist.js index 0682b54..2dbe538 100644 --- a/src/helpers/anilist.js +++ b/src/helpers/anilist.js @@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => { * @returns {Promise} 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} 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) { diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js index 13de4d9..d2669ff 100644 --- a/src/helpers/pipe.js +++ b/src/helpers/pipe.js @@ -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]; }; // ══════════════════════════════════════════════════════════════ diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 9667f63..4ffa837 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -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: [ diff --git a/test.js b/test.js index 007b9cf..dbde380 100644 --- a/test.js +++ b/test.js @@ -104,7 +104,7 @@ async function runTest(test) { } async function runAll() { - console.log(`\n🧪 MiruroAPI v2.1.0 — Running ${tests.length} tests...\n`); + console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`); for (const test of tests) { await runTest(test); diff --git a/vercel.json b/vercel.json index a99d8ff..92ed403 100644 --- a/vercel.json +++ b/vercel.json @@ -19,5 +19,18 @@ "src": "/api/(.*)", "dest": "/api/index.js" } + ], + "headers": [ + { + "source": "/api/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-XSS-Protection", "value": "1; mode=block" }, + { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } + ] + } ] }