first commit
This commit is contained in:
73
src/middleware/auth.middleware.ts
Normal file
73
src/middleware/auth.middleware.ts
Normal 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;
|
||||
50
src/middleware/error.middleware.ts
Normal file
50
src/middleware/error.middleware.ts
Normal 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;
|
||||
87
src/middleware/rateLimit.middleware.ts
Normal file
87
src/middleware/rateLimit.middleware.ts
Normal 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;
|
||||
93
src/middleware/validation.middleware.ts
Normal file
93
src/middleware/validation.middleware.ts
Normal 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;
|
||||
Reference in New Issue
Block a user