first commit

This commit is contained in:
2026-02-28 02:44:41 +03:00
commit 97fb289fe7
70 changed files with 11928 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from 'express';
import { getValidApiKeys } from '../config/env.js';
import logger from '../utils/logger.js';
import type { ApiResponse } from '../types/index.js';
/**
* API Key Authentication Middleware
* Validates API key from X-API-Key header
*/
export function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const apiKey = req.headers['x-api-key'] as string | undefined;
if (!apiKey) {
const response: ApiResponse<never> = {
success: false,
error: {
code: 'MISSING_API_KEY',
message: 'API key is required. Include X-API-Key header.',
},
};
logger.warn('Request missing API key', {
ip: req.ip,
path: req.path,
});
res.status(401).json(response);
return;
}
const validKeys = getValidApiKeys();
if (!validKeys.has(apiKey)) {
const response: ApiResponse<never> = {
success: false,
error: {
code: 'INVALID_API_KEY',
message: 'Invalid API key provided.',
},
};
logger.warn('Invalid API key attempt', {
ip: req.ip,
path: req.path,
keyPrefix: apiKey.substring(0, 8) + '...',
});
res.status(403).json(response);
return;
}
// Valid API key, proceed
next();
}
/**
* Optional: Identify which client made the request
*/
export function identifyClient(apiKey: string): string {
const { env } = require('../config/env.js');
if (apiKey === env.API_KEY_WEB) return 'web';
if (apiKey === env.API_KEY_MOBILE) return 'mobile';
if (apiKey === env.API_KEY_ADMIN) return 'admin';
return 'unknown';
}
export default authMiddleware;

View File

@@ -0,0 +1,50 @@
import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger.js';
import type { ApiResponse } from '../types/index.js';
/**
* Global Error Handler Middleware
*/
export function errorHandler(
error: Error,
req: Request,
res: Response,
_next: NextFunction
): void {
logger.error('Unhandled error', {
error: error.message,
stack: error.stack,
path: req.path,
method: req.method,
});
const response: ApiResponse<never> = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred. Please try again later.',
},
};
res.status(500).json(response);
}
/**
* 404 Not Found Handler
*/
export function notFoundHandler(
req: Request,
res: Response
): void {
const response: ApiResponse<never> = {
success: false,
error: {
code: 'NOT_FOUND',
message: `Endpoint ${req.method} ${req.path} not found`,
},
};
res.status(404).json(response);
}
export default errorHandler;

View File

@@ -0,0 +1,87 @@
import rateLimit from 'express-rate-limit';
import { env } from '../config/env.js';
import logger from '../utils/logger.js';
import type { ApiResponse } from '../types/index.js';
/**
* Rate Limiter Configuration
* Limits requests per IP within a time window
*/
export const rateLimiter = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS, // Time window in milliseconds
max: env.RATE_LIMIT_MAX_REQUESTS, // Max requests per window per IP
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
// Custom key generator (use IP + API key for more granular limiting)
keyGenerator: (req) => {
const apiKey = req.headers['x-api-key'] as string | undefined;
return `${req.ip}:${apiKey || 'no-key'}`;
},
// Custom handler for rate limit exceeded
handler: (req, res) => {
const response: ApiResponse<never> = {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: `Too many requests. Maximum ${env.RATE_LIMIT_MAX_REQUESTS} requests per ${env.RATE_LIMIT_WINDOW_MS / 1000} seconds.`,
details: {
retryAfter: Math.ceil(env.RATE_LIMIT_WINDOW_MS / 1000),
},
},
};
logger.warn('Rate limit exceeded', {
ip: req.ip,
path: req.path,
maxRequests: env.RATE_LIMIT_MAX_REQUESTS,
windowMs: env.RATE_LIMIT_WINDOW_MS,
});
res.status(429).json(response);
},
// Skip rate limiting for health checks
skip: (req) => {
return req.path === '/health' || req.path === '/ready';
},
});
/**
* Stricter rate limiter for scraping endpoints
* Prevents abuse of Netflix scraping
*/
export const scrapeRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // Only 10 scrape requests per minute
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
const apiKey = req.headers['x-api-key'] as string | undefined;
return `scrape:${req.ip}:${apiKey || 'no-key'}`;
},
handler: (req, res) => {
const response: ApiResponse<never> = {
success: false,
error: {
code: 'SCRAPE_RATE_LIMIT_EXCEEDED',
message: 'Too many scrape requests. Please wait before trying again.',
details: {
retryAfter: 60,
},
},
};
logger.warn('Scrape rate limit exceeded', {
ip: req.ip,
path: req.path,
});
res.status(429).json(response);
},
});
export default rateLimiter;

View File

@@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import type { ApiResponse, GetInfoRequest } from '../types/index.js';
/**
* Validation schema for /api/getinfo endpoint
*/
const getInfoSchema = z.object({
url: z.string().url('Invalid URL format').refine((url) => {
// Validate Netflix URL
try {
const parsedUrl = new URL(url);
const validHosts = [
'www.netflix.com',
'netflix.com',
'www.netflix.com.tr',
'netflix.com.tr',
];
const hasTitlePath = /\/title\/\d+/.test(url);
return validHosts.includes(parsedUrl.hostname) && hasTitlePath;
} catch {
return false;
}
}, 'URL must be a valid Netflix title URL (e.g., https://www.netflix.com/tr/title/81616256)'),
});
/**
* Validate request body for /api/getinfo
*/
export function validateGetInfo(
req: Request,
res: Response,
next: NextFunction
): void {
const result = getInfoSchema.safeParse(req.body);
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
const response: ApiResponse<never> = {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request parameters',
details: { errors },
},
};
res.status(400).json(response);
return;
}
// Attach validated data to request
(req as Request & { validated: GetInfoRequest }).validated = result.data;
next();
}
/**
* Generic validation middleware factory
*/
export function validateBody<T extends z.ZodType>(
schema: T
): (req: Request, res: Response, next: NextFunction) => void {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
const response: ApiResponse<never> = {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request parameters',
details: { errors },
},
};
res.status(400).json(response);
return;
}
next();
};
}
export default validateGetInfo;