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:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-25 10:34:12 +05:30
parent 1d0ec95f19
commit 8297d8f27d
11 changed files with 159 additions and 515 deletions
+10 -2
View File
@@ -1,5 +1,13 @@
# Origins you want to allow # Origins you want to allow (comma-separated, use * for all)
ALLOWED_ORIGINS=https://site1.com,https://site2.com ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz
# Port (optional, defaults to 3000) # Port (optional, defaults to 3000)
PORT=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
+27
View File
@@ -1,5 +1,32 @@
# Changelog # 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 ## v2.1.3
### Diagnostic Sweep — Bug fixes, security, AniList query corrections ### Diagnostic Sweep — Bug fixes, security, AniList query corrections
+4 -1
View File
@@ -1,6 +1,9 @@
const axios = require("axios"); 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/"; const PRU_BASE = "https://pru.ultracloud.cc/";
function base64urlXor(str) { function base64urlXor(str) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "miruro-api", "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", "description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+16 -12
View File
@@ -88,11 +88,15 @@ app.use((req, res, next) => {
// ---- FEATURE: Unified CORS middleware with preflight handling ---- // ---- FEATURE: Unified CORS middleware with preflight handling ----
app.use((req, res, next) => { app.use((req, res, next) => {
const origin = req.headers.origin; const origin = req.headers.origin;
if ( const isProduction = process.env.VERCEL || process.env.NODE_ENV === "production";
!allowedOrigins ||
allowedOrigins.includes("*") || if (allowedOrigins && allowedOrigins.length > 0) {
(origin && allowedOrigins.includes(origin)) 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-Origin", origin || "*");
} }
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -191,7 +195,8 @@ app.use((req, res, next) => {
const ip = const ip =
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
req.ip || req.ip ||
req.connection.remoteAddress; req.connection?.remoteAddress ||
"unknown";
const now = Date.now(); const now = Date.now();
const windowMs = 60000; const windowMs = 60000;
const maxRequests = 100; const maxRequests = 100;
@@ -212,12 +217,11 @@ app.use((req, res, next) => {
timestamps.push(now); timestamps.push(now);
// NOTE: Periodic cleanup — prune expired IP entries when map grows large // NOTE: Aggressive cleanup on serverless — prune all empty entries every request
if (requestCounts.size > 200) { // since memory is per-invocation and doesn't persist
if (requestCounts.size > 50) {
for (const [key, val] of requestCounts) { for (const [key, val] of requestCounts) {
if (val.filter((t) => now - t < windowMs).length === 0) { if (val.length === 0) requestCounts.delete(key);
requestCounts.delete(key);
}
} }
} }
@@ -265,7 +269,7 @@ app.use((req, res) => {
if (!process.env.VERCEL) { if (!process.env.VERCEL) {
app.listen(PORT, () => { 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}`);
}); });
} }
-444
View File
@@ -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/<id>',
'GET /api/info/<id>/episodes',
'GET /api/characters/<id>',
'GET /api/recommendations/<id>',
'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/<int:anime_id>')
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/<int:anime_id>/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/<int:anime_id>')
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/<int:anime_id>')
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/<id>')
print(' GET /api/info/<id>/episodes')
print(' GET /api/characters/<id>')
print(' GET /api/recommendations/<id>')
print(' GET /api/genres\n')
app.run(host='0.0.0.0', port=3000, debug=True)
+6 -4
View File
@@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => {
* @returns {Promise<object>} Paginated filtered results * @returns {Promise<object>} Paginated filtered results
*/ */
const filterAnime = async ({ const filterAnime = async ({
genre, tag, year, season, format, status, search, genre, tag, year, season, format, status,
sort = "POPULARITY_DESC", page = 1, perPage = 20, sort = "POPULARITY_DESC", page = 1, perPage = 20,
}) => { }) => {
const SORT_MAP = { const SORT_MAP = {
@@ -548,13 +548,14 @@ const filterAnime = async ({
START_DATE_DESC: "START_DATE_DESC", START_DATE_DESC: "START_DATE_DESC",
FAVOURITES_DESC: "FAVOURITES_DESC", FAVOURITES_DESC: "FAVOURITES_DESC",
UPDATED_AT_DESC: "UPDATED_AT_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 args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
const variables = { page, perPage }; const variables = { page, perPage };
const varTypes = ["$page: Int", "$perPage: Int"]; 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 (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 (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"); } 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 ---- // ---- FEATURE: Weekly trending anime ----
/** /**
* Fetches currently trending anime (last 30 days). * 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} [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 * @returns {Promise<object>} Paginated trending results
*/ */
const getTrendingWeekly = async (page = 1, perPage = 20) => { const getTrendingWeekly = async (page = 1, perPage = 40) => {
const gql = ` const gql = `
query ($page: Int, $perPage: Int) { query ($page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) { Page(page: $page, perPage: $perPage) {
+31 -17
View File
@@ -73,7 +73,10 @@ const encodePipeRequest = (payload) => {
* *
* @type {Buffer} * @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 * @param {string} encodedStr - The base64url-encoded response
@@ -227,23 +230,28 @@ const translateId = (encodedId) => {
* Walks a JSON structure and decodes any base64 "id" fields. * Walks a JSON structure and decodes any base64 "id" fields.
* Saves the original base64 value in a `rawPipeId` field on episode objects * Saves the original base64 value in a `rawPipeId` field on episode objects
* so the pipe sources endpoint can be called directly from the client. * 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 * @param {object|Array} obj - The object or array to process
* @returns {object|Array} The same object with decoded IDs (mutated in place) * @returns {object|Array} A new object with decoded IDs
*/ */
const deepTranslate = (obj) => { const deepTranslate = (obj) => {
if (obj && typeof obj === "object") { if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) { if (Array.isArray(obj)) {
if (key === "id" && typeof obj[key] === "string") { return obj.map((item) => deepTranslate(item));
// NOTE: Save original base64 before decoding (needed for pipe sources call)
if (obj.number !== undefined) {
obj.rawPipeId = obj[key];
} }
obj[key] = translateId(obj[key]); const clone = { ...obj };
} else if (typeof obj[key] === "object") { for (const key of Object.keys(clone)) {
deepTranslate(obj[key]); if (key === "id" && typeof clone[key] === "string") {
if (clone.number !== undefined) {
clone.rawPipeId = clone[key];
}
clone[key] = translateId(clone[key]);
} else if (typeof clone[key] === "object") {
clone[key] = deepTranslate(clone[key]);
} }
} }
return clone;
} }
return obj; return obj;
}; };
@@ -503,10 +511,14 @@ const extractSkipTimes = (sources) => {
* @returns {object|null} Best available stream object or null * @returns {object|null} Best available stream object or null
*/ */
const getBestStream = (sources, preferredQuality = "1080p") => { 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; 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 qualityOrder = ["1080p", "720p", "480p", "360p"];
const startIdx = qualityOrder.indexOf(preferredQuality); const startIdx = qualityOrder.indexOf(preferredQuality);
const ordered = startIdx >= 0 const ordered = startIdx >= 0
@@ -514,15 +526,17 @@ const getBestStream = (sources, preferredQuality = "1080p") => {
: qualityOrder; : qualityOrder;
for (const q of ordered) { 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; if (match) return match;
} }
// NOTE: Some providers don't include quality — return first active HLS stream, or first HLS stream const active = usable.find((s) => s.isActive);
const active = streams.find((s) => s.isActive);
if (active) return active; if (active) return active;
return streams[0]; return usable[0];
}; };
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
+50 -33
View File
@@ -23,6 +23,18 @@ const pipe = require("../helpers/pipe");
const { getCached, setCache } = require("../helpers/cache"); const { getCached, setCache } = require("../helpers/cache");
const { addCreatorInfo } = require("../middleware/creatorInfo"); 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 // ROUTE REGISTRATION
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -116,16 +128,10 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Multi-parameter search (query + filters combined) ---- // ---- FEATURE: Multi-parameter search (query + filters combined) ----
router.get("/multi-search", async (req, res) => { router.get("/multi-search", async (req, res) => {
try { 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({ const result = await anilist.filterAnime({
search: q || null,
genre, tag, year: year ? parseInt(year) : null, genre, tag, year: year ? parseInt(year) : null,
season, format, status, sort, season, format, status, sort,
page: parseInt(page), perPage: parseInt(per_page), page: parseInt(page), perPage: parseInt(per_page),
@@ -341,13 +347,15 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Seasonal anime by year and season ---- // ---- FEATURE: Seasonal anime by year and season ----
router.get("/season/:year/:season", async (req, res) => { router.get("/season/:year/:season", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `season:${year}:${season}:${page}:${per_page}`; const cacheKey = `season:${year}:${season}:${page}:${per_page}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
if (!result) { 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); setCache(cacheKey, result, 300 * 1000);
} }
@@ -360,13 +368,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Anime by studio name ---- // ---- FEATURE: Anime by studio name ----
router.get("/studio/:name", async (req, res) => { router.get("/studio/:name", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `studio:${name}:${page}:${per_page}`; const cacheKey = `studio:${name}:${page}:${per_page}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
if (!result) { 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); setCache(cacheKey, result, 300 * 1000);
} }
@@ -415,13 +424,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Anime by genre name ---- // ---- FEATURE: Anime by genre name ----
router.get("/genre/:name", async (req, res) => { router.get("/genre/:name", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `genre:${name}:${page}:${per_page}`; const cacheKey = `genre:${name}:${page}:${per_page}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
if (!result) { 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); setCache(cacheKey, result, 120 * 1000);
} }
@@ -434,13 +444,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Anime by year ---- // ---- FEATURE: Anime by year ----
router.get("/year/:year", async (req, res) => { router.get("/year/:year", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `year:${year}:${page}:${per_page}`; const cacheKey = `year:${year}:${page}:${per_page}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
if (!result) { 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); setCache(cacheKey, result, 300 * 1000);
} }
@@ -453,7 +464,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Anime by status ---- // ---- FEATURE: Anime by status ----
router.get("/status/:status", async (req, res) => { router.get("/status/:status", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `status:${status}:${page}:${per_page}`; const cacheKey = `status:${status}:${page}:${per_page}`;
@@ -472,7 +484,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Anime by format ---- // ---- FEATURE: Anime by format ----
router.get("/format/:format", async (req, res) => { router.get("/format/:format", async (req, res) => {
try { 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 { page = 1, per_page = 20 } = req.query;
const cacheKey = `format:${format}:${page}:${per_page}`; const cacheKey = `format:${format}:${page}:${per_page}`;
@@ -495,8 +508,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Character detail with anime list ---- // ---- FEATURE: Character detail with anime list ----
router.get("/character/:id", async (req, res) => { router.get("/character/:id", async (req, res) => {
try { try {
const id = parseInt(req.params.id); const id = sanitizeId(req.params.id);
if (isNaN(id)) return jsonError(res, "Invalid character ID", 400); if (!id) return jsonError(res, "Invalid character ID", 400);
const cacheKey = `character:${id}`; const cacheKey = `character:${id}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
@@ -515,8 +528,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ---- FEATURE: Staff detail with anime list ---- // ---- FEATURE: Staff detail with anime list ----
router.get("/staff/:id", async (req, res) => { router.get("/staff/:id", async (req, res) => {
try { try {
const id = parseInt(req.params.id); const id = sanitizeId(req.params.id);
if (isNaN(id)) return jsonError(res, "Invalid staff ID", 400); if (!id) return jsonError(res, "Invalid staff ID", 400);
const cacheKey = `staff:${id}`; const cacheKey = `staff:${id}`;
let result = getCached(cacheKey); let result = getCached(cacheKey);
@@ -828,16 +841,20 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
const genres = await anilist.getAllGenres(); const genres = await anilist.getAllGenres();
const stats = {}; const stats = {};
// NOTE: Fetch top anime for each genre (limited to avoid rate limiting) const topGenres = genres.slice(0, 10);
for (const genre of genres.slice(0, 10)) { const results = await Promise.allSettled(
try { topGenres.map((genre) =>
const data = await anilist.filterAnime({ genre, perPage: 1 }); anilist.filterAnime({ genre, perPage: 1 }).then((data) => ({
stats[genre] = { genre,
total: data.total, total: data.total,
topAnime: data.results[0] || null, topAnime: data.results[0] || null,
}; })).catch(() => ({ genre, total: 0, topAnime: null }))
} catch { )
stats[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); let result = getCached(cacheKey);
if (!result) { if (!result) {
// NOTE: Get airing schedule for the month // 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 monthStart = new Date(y, m - 1, 1);
const monthEnd = new Date(y, m, 0, 23, 59, 59); const monthEnd = new Date(y, m, 0, 23, 59, 59);
@@ -1106,7 +1123,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
success: true, success: true,
results: { results: {
status: "healthy", status: "healthy",
version: "2.1.3", version: "2.1.4",
uptime: `${hours}h ${minutes}m ${seconds}s`, uptime: `${hours}h ${minutes}m ${seconds}s`,
uptimeSeconds: uptime, uptimeSeconds: uptime,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1161,7 +1178,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
info: { info: {
title: "MiruroAPI", title: "MiruroAPI",
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers", 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" }, contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
}, },
servers: [ servers: [
+1 -1
View File
@@ -104,7 +104,7 @@ async function runTest(test) {
} }
async function runAll() { 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) { for (const test of tests) {
await runTest(test); await runTest(test);
+13
View File
@@ -19,5 +19,18 @@
"src": "/api/(.*)", "src": "/api/(.*)",
"dest": "/api/index.js" "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=()" }
]
}
] ]
} }