commit 92cdba42fb2e704259fc5f9d0d3362dff591ba17 Author: corenthin-lebreton Date: Wed Feb 25 22:54:49 2026 +0100 Initial commit of yt2jellyfin-server project diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..72050da --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Configuration SSH pour le transfert vers Jellyfin +SSH_PRIVATE_KEY_PATH=/path/to/your/private/key +JELLYFIN_HOST=192.168.1.100 +JELLYFIN_SSH_PORT=22 +JELLYFIN_DESTINATION_PATH=/home/playlist + +# Configuration CORS +CORS_ORIGIN=http://localhost:5173 + +# Dossier temporaire pour les téléchargements +TEMP_DIR=./tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8e1bef --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Yt2Jellyfin - Backend + +API REST pour télécharger, convertir et transférer de la musique YouTube vers un serveur Jellyfin. + +## Stack Technique + +- **Bun** - Runtime JavaScript ultra-rapide +- **Hono** - Framework web léger +- **TypeScript** - Typage statique + +## Prérequis Système + +Les outils suivants doivent être installés sur le système: + +- `yt-dlp` - Pour télécharger les vidéos YouTube +- `ffmpeg` - Pour la conversion audio +- `scp` - Pour le transfert SSH (généralement préinstallé) + +### Installation des dépendances système + +```bash +# Debian/Ubuntu +sudo apt update +sudo apt install yt-dlp ffmpeg openssh-client + +# Arch Linux +sudo pacman -S yt-dlp ffmpeg openssh +``` + +## Installation + +```bash +bun install +``` + +## Configuration + +1. Créez un fichier `.env` basé sur `.env.example`: + +```bash +cp .env.example .env +``` + +2. Configurez les variables d'environnement: + +```env +SSH_PRIVATE_KEY_PATH=/path/to/your/ssh/key +JELLYFIN_HOST=192.168.1.100 +JELLYFIN_SSH_PORT=22 +JELLYFIN_DESTINATION_PATH=/home/playlist +CORS_ORIGIN=http://localhost:5173 +TEMP_DIR=./temp +``` + +3. Assurez-vous que la clé SSH privée a les bonnes permissions: + +```bash +chmod 600 /path/to/your/ssh/key +``` + +## Développement + +```bash +bun run dev +``` + +Le serveur sera disponible sur `http://localhost:3000`. + +## Production + +```bash +bun run start +``` + +## API Endpoints + +### POST `/api/process` + +Télécharge, convertit et transfère un fichier audio YouTube vers Jellyfin. + +**Request Body:** + +```json +{ + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "filename": "ma-chanson", + "format": "mp3" +} +``` + +**Response (Success):** + +```json +{ + "success": true, + "message": "Fichier téléchargé, converti et transféré avec succès", + "filename": "ma-chanson.mp3" +} +``` + +**Response (Error):** + +```json +{ + "success": false, + "error": "URL YouTube invalide", + "details": "..." +} +``` + +### GET `/health` + +Vérifie l'état du serveur. + +**Response:** + +```json +{ + "status": "ok" +} +``` + +## Structure + +``` +src/ +├── config/ # Configuration +│ └── env.ts +├── controllers/ # Contrôleurs API +│ └── process-controller.ts +├── middleware/ # Middleware HTTP +│ └── cors.ts +├── services/ # Logique métier +│ ├── youtube-downloader.ts +│ └── file-transfer.ts +├── types/ # Définitions TypeScript +│ └── api.ts +├── utils/ # Utilitaires +│ └── validators.ts +└── index.ts # Point d'entrée +``` + +## Sécurité + +- Validation stricte des URLs YouTube pour prévenir les injections de commandes +- Sanitisation des noms de fichiers +- Utilisation de clés SSH pour l'authentification +- CORS configuré pour autoriser uniquement l'origine du frontend +- Nettoyage automatique des fichiers temporaires diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bdd991c --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "yt2jellyfin-server", + "dependencies": { + "hono": "^4.6.14", + }, + "devDependencies": { + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e59a411 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "yt2jellyfin-server", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "check": "bash scripts/check-prerequisites.sh", + "test": "bun test" + }, + "dependencies": { + "hono": "^4.6.14" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..992512a --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,26 @@ +import type { Config } from '../types/api'; +import { + DEFAULT_SSH_PORT, + DEFAULT_CORS_ORIGIN, + DEFAULT_TEMP_DIR, + DEFAULT_DESTINATION_PATH, +} from '../constants/config'; + +function getEnvVariable(key: string, defaultValue?: string): string { + const value = process.env[key]; + if (!value && !defaultValue) { + throw new Error(`Variable d'environnement manquante: ${key}`); + } + return value || defaultValue!; +} + +export function loadConfig(): Config { + return { + sshPrivateKeyPath: getEnvVariable('SSH_PRIVATE_KEY_PATH'), + jellyfinHost: getEnvVariable('JELLYFIN_HOST'), + jellyfinSshPort: parseInt(getEnvVariable('JELLYFIN_SSH_PORT', String(DEFAULT_SSH_PORT)), 10), + corsOrigin: getEnvVariable('CORS_ORIGIN', DEFAULT_CORS_ORIGIN), + tempDir: getEnvVariable('TEMP_DIR', DEFAULT_TEMP_DIR), + jellyfinDestinationPath: getEnvVariable('JELLYFIN_DESTINATION_PATH', DEFAULT_DESTINATION_PATH), + }; +} diff --git a/src/constants/config.ts b/src/constants/config.ts new file mode 100644 index 0000000..d83361e --- /dev/null +++ b/src/constants/config.ts @@ -0,0 +1,6 @@ +export const DEFAULT_SSH_PORT = 22; +export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173'; +export const DEFAULT_TEMP_DIR = './temp'; +export const DEFAULT_DESTINATION_PATH = '/home/playlist'; +export const MAX_FILE_SIZE = '100M'; +export const SERVER_PORT = 3000; diff --git a/src/controllers/process-controller.ts b/src/controllers/process-controller.ts new file mode 100644 index 0000000..9712228 --- /dev/null +++ b/src/controllers/process-controller.ts @@ -0,0 +1,95 @@ +import { basename } from 'path'; +import type { Context } from 'hono'; +import type { ProcessRequest, ProcessResponse, ProcessError, Config } from '../types/api'; +import { validateProcessRequest, sanitizeFilename } from '../utils/validators'; +import { downloadAndConvertAudio } from '../services/youtube-downloader'; +import { transferFileToJellyfin, cleanupTempFile } from '../services/file-transfer'; +import { logger } from '../utils/logger'; + +export async function handleProcessRequest( + c: Context, + config: Config +): Promise { + try { + const body = await c.req.json(); + + const validation = validateProcessRequest(body); + if (!validation.isValid) { + logger.warn('Requête invalide', { error: validation.error }); + const errorResponse: ProcessError = { + success: false, + error: validation.error!, + }; + return c.json(errorResponse, 400); + } + + const request = body as ProcessRequest; + logger.info('Nouvelle requête de traitement', { + format: request.format, + hasCustomFilename: !!request.filename + }); + + const sanitizedFilename = request.filename + ? sanitizeFilename(request.filename) + : undefined; + + logger.info('Début du téléchargement et conversion'); + const downloadResult = await downloadAndConvertAudio( + request.url, + request.format, + config.tempDir, + sanitizedFilename + ); + + if (!downloadResult.success || !downloadResult.filePath) { + logger.error('Échec du téléchargement', { error: downloadResult.error }); + const errorResponse: ProcessError = { + success: false, + error: 'Échec du téléchargement', + details: downloadResult.error, + }; + return c.json(errorResponse, 500); + } + + logger.info('Téléchargement réussi', { filePath: downloadResult.filePath }); + + logger.info('Début du transfert vers Jellyfin'); + const transferResult = await transferFileToJellyfin( + downloadResult.filePath, + config + ); + + if (!transferResult.success) { + logger.error('Échec du transfert', { error: transferResult.error }); + await cleanupTempFile(downloadResult.filePath); + const errorResponse: ProcessError = { + success: false, + error: 'Échec du transfert vers Jellyfin', + details: transferResult.error, + }; + return c.json(errorResponse, 500); + } + + logger.info('Transfert réussi, nettoyage en cours'); + await cleanupTempFile(downloadResult.filePath); + + const filename = basename(downloadResult.filePath); + logger.info('Traitement terminé avec succès', { filename }); + + const successResponse: ProcessResponse = { + success: true, + message: 'Fichier téléchargé, converti et transféré avec succès', + filename, + }; + + return c.json(successResponse, 200); + } catch (error) { + logger.error('Erreur interne', { error: error instanceof Error ? error.message : String(error) }); + const errorResponse: ProcessError = { + success: false, + error: 'Erreur interne du serveur', + details: error instanceof Error ? error.message : String(error), + }; + return c.json(errorResponse, 500); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8168bbd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { mkdir } from 'fs/promises'; +import { loadConfig } from './config/env'; +import { createCorsMiddleware } from './middleware/cors'; +import { handleProcessRequest } from './controllers/process-controller'; +import { SERVER_PORT } from './constants/config'; +import { logger } from './utils/logger'; + +const PORT = SERVER_PORT; + +async function initializeServer() { + try { + logger.info('Chargement de la configuration...'); + const config = loadConfig(); + + logger.info('Création du dossier temporaire...', { path: config.tempDir }); + await mkdir(config.tempDir, { recursive: true }); + + const app = new Hono(); + + app.use('*', createCorsMiddleware(config.corsOrigin)); + + app.post('/api/process', (c: Context) => handleProcessRequest(c, config)); + + app.get('/health', (c: Context) => c.json({ status: 'ok' })); + + logger.info('Serveur démarré avec succès', { + port: PORT, + tempDir: config.tempDir, + jellyfinHost: config.jellyfinHost, + corsOrigin: config.corsOrigin, + }); + + return app; + } catch (error) { + logger.error('Échec de l\'initialisation du serveur', { error }); + throw error; + } +} + +const app = await initializeServer(); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts new file mode 100644 index 0000000..45ed52b --- /dev/null +++ b/src/middleware/cors.ts @@ -0,0 +1,19 @@ +import type { Context, Next } from 'hono'; + +export function createCorsMiddleware(allowedOrigin: string) { + return async (c: Context, next: Next) => { + const origin = c.req.header('Origin'); + + if (origin === allowedOrigin) { + c.header('Access-Control-Allow-Origin', allowedOrigin); + c.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); + c.header('Access-Control-Allow-Headers', 'Content-Type'); + } + + if (c.req.method === 'OPTIONS') { + return c.text('', 204); + } + + await next(); + }; +} diff --git a/src/services/file-transfer.ts b/src/services/file-transfer.ts new file mode 100644 index 0000000..c0380c0 --- /dev/null +++ b/src/services/file-transfer.ts @@ -0,0 +1,88 @@ +import { spawn } from 'bun'; +import { basename } from 'path'; +import type { Config } from '../types/api'; +import { logger } from '../utils/logger'; + +export interface TransferResult { + success: boolean; + error?: string; +} + +export async function transferFileToJellyfin( + localFilePath: string, + config: Config +): Promise { + try { + const file = Bun.file(localFilePath); + if (!(await file.exists())) { + return { + success: false, + error: 'Fichier local introuvable', + }; + } + + const filename = basename(localFilePath); + const remotePath = `${config.jellyfinDestinationPath}/${filename}`; + + logger.debug('Transfert SCP', { + from: localFilePath, + to: `root@${config.jellyfinHost}:${remotePath}` + }); + + const args = [ + '-i', config.sshPrivateKeyPath, + '-P', String(config.jellyfinSshPort), + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=30', + localFilePath, + `root@${config.jellyfinHost}:${remotePath}`, + ]; + + const proc = spawn({ + cmd: ['scp', ...args], + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + logger.error('Échec SCP', { exitCode, stderr }); + return { + success: false, + error: `Échec du transfert SCP (code ${exitCode})`, + }; + } + + return { success: true }; + } catch (error) { + logger.error('Exception lors du transfert', { error }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function cleanupTempFile(filePath: string): Promise { + try { + const file = Bun.file(filePath); + if (await file.exists()) { + await unlink(filePath); + logger.debug('Fichier temporaire supprimé', { filePath }); + } + } catch (error) { + logger.warn('Erreur lors du nettoyage', { filePath, error }); + } +} + +async function unlink(path: string): Promise { + const proc = spawn({ + cmd: ['rm', '-f', path], + stdout: 'pipe', + stderr: 'pipe', + }); + await proc.exited; +} diff --git a/src/services/youtube-downloader.ts b/src/services/youtube-downloader.ts new file mode 100644 index 0000000..907a50e --- /dev/null +++ b/src/services/youtube-downloader.ts @@ -0,0 +1,89 @@ +import { spawn } from 'bun'; +import { join } from 'path'; +import type { AudioFormat } from '../types/api'; +import { MAX_FILE_SIZE } from '../constants/config'; + +const AUDIO_QUALITY_BEST = '0'; + +export interface DownloadResult { + success: boolean; + filePath?: string; + error?: string; +} + +export async function downloadAndConvertAudio( + url: string, + format: AudioFormat, + tempDir: string, + customFilename?: string +): Promise { + try { + const timestamp = Date.now(); + const safeFilename = customFilename + ? customFilename.replace(/[^a-zA-Z0-9_-]/g, '_') + : `${timestamp}_%(title)s`; + + const outputTemplate = join(tempDir, `${safeFilename}.%(ext)s`); + + const audioFormat = format === 'mp3' ? 'mp3' : 'flac'; + + const args = [ + '--extract-audio', + '--audio-format', audioFormat, + '--audio-quality', AUDIO_QUALITY_BEST, + '--max-filesize', MAX_FILE_SIZE, + '--output', outputTemplate, + '--no-playlist', + '--quiet', + '--no-warnings', + '--', + url, + ]; + + const proc = spawn({ + cmd: ['yt-dlp', ...args], + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + return { + success: false, + error: `yt-dlp a échoué avec le code ${exitCode}`, + }; + } + + const expectedPath = customFilename + ? join(tempDir, `${safeFilename}.${format}`) + : null; + + if (expectedPath) { + const file = Bun.file(expectedPath); + if (await file.exists()) { + return { success: true, filePath: expectedPath }; + } + } + + const files = await Array.fromAsync( + new Bun.Glob(`*.${format}`).scan({ cwd: tempDir }) + ); + + if (files.length === 0) { + return { + success: false, + error: 'Aucun fichier audio trouvé après le téléchargement', + }; + } + + const latestFile = files.sort().pop()!; + return { success: true, filePath: join(tempDir, latestFile) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..ac0d011 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,28 @@ +export type AudioFormat = 'mp3' | 'flac'; + +export interface ProcessRequest { + url: string; + filename?: string; + format: AudioFormat; +} + +export interface ProcessResponse { + success: boolean; + message: string; + filename?: string; +} + +export interface ProcessError { + success: false; + error: string; + details?: string; +} + +export interface Config { + sshPrivateKeyPath: string; + jellyfinHost: string; + jellyfinSshPort: number; + corsOrigin: string; + tempDir: string; + jellyfinDestinationPath: string; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..0c2a046 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,34 @@ +type LogLevel = 'info' | 'warn' | 'error' | 'debug'; + +const LOG_COLORS = { + info: '\x1b[36m', + warn: '\x1b[33m', + error: '\x1b[31m', + debug: '\x1b[90m', + reset: '\x1b[0m', +}; + +function formatTimestamp(): string { + return new Date().toISOString(); +} + +function log(level: LogLevel, message: string, data?: unknown): void { + const timestamp = formatTimestamp(); + const color = LOG_COLORS[level]; + const reset = LOG_COLORS.reset; + + const logMessage = `${color}[${timestamp}] [${level.toUpperCase()}]${reset} ${message}`; + + if (data) { + console.log(logMessage, data); + } else { + console.log(logMessage); + } +} + +export const logger = { + info: (message: string, data?: unknown) => log('info', message, data), + warn: (message: string, data?: unknown) => log('warn', message, data), + error: (message: string, data?: unknown) => log('error', message, data), + debug: (message: string, data?: unknown) => log('debug', message, data), +}; diff --git a/src/utils/validators.test.ts b/src/utils/validators.test.ts new file mode 100644 index 0000000..cb96fcf --- /dev/null +++ b/src/utils/validators.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from 'bun:test'; +import { validateYoutubeUrl, validateFormat, sanitizeFilename } from './validators'; + +describe('validateYoutubeUrl', () => { + test('accepte les URLs YouTube valides', () => { + const validUrls = [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ', + 'http://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtu.be/dQw4w9WgXcQ', + 'https://www.youtube.com/shorts/abc123', + 'youtube.com/watch?v=dQw4w9WgXcQ', + ]; + + validUrls.forEach((url) => { + const result = validateYoutubeUrl(url); + expect(result.isValid).toBe(true); + }); + }); + + test('rejette les URLs non-YouTube', () => { + const invalidUrls = [ + 'https://vimeo.com/123456', + 'https://google.com', + 'not a url', + '', + 'ftp://youtube.com/watch?v=123', + ]; + + invalidUrls.forEach((url) => { + const result = validateYoutubeUrl(url); + expect(result.isValid).toBe(false); + }); + }); + + test('rejette les URLs avec caractères dangereux', () => { + const dangerousUrls = [ + 'https://youtube.com/watch?v=123; rm -rf /', + 'https://youtube.com/watch?v=123 | cat /etc/passwd', + 'https://youtube.com/watch?v=123`whoami`', + 'https://youtube.com/watch?v=123$USER', + 'https://youtube.com/watch?v=123 && echo hacked', + ]; + + dangerousUrls.forEach((url) => { + const result = validateYoutubeUrl(url); + expect(result.isValid).toBe(false); + }); + }); +}); + +describe('validateFormat', () => { + test('accepte les formats valides', () => { + expect(validateFormat('mp3').isValid).toBe(true); + expect(validateFormat('flac').isValid).toBe(true); + }); + + test('rejette les formats invalides', () => { + expect(validateFormat('wav').isValid).toBe(false); + expect(validateFormat('ogg').isValid).toBe(false); + expect(validateFormat('').isValid).toBe(false); + expect(validateFormat(null).isValid).toBe(false); + expect(validateFormat(123).isValid).toBe(false); + }); +}); + +describe('sanitizeFilename', () => { + test('supprime les caractères dangereux', () => { + expect(sanitizeFilename('test<>file')).toBe('testfile'); + expect(sanitizeFilename('test:file')).toBe('testfile'); + expect(sanitizeFilename('test/file')).toBe('testfile'); + expect(sanitizeFilename('test\\file')).toBe('testfile'); + expect(sanitizeFilename('test|file')).toBe('testfile'); + expect(sanitizeFilename('test?file')).toBe('testfile'); + expect(sanitizeFilename('test*file')).toBe('testfile'); + }); + + test('remplace les espaces par des underscores', () => { + expect(sanitizeFilename('my song name')).toBe('my_song_name'); + expect(sanitizeFilename('song with spaces')).toBe('song_with_spaces'); + }); + + test('limite la longueur à 255 caractères', () => { + const longName = 'a'.repeat(300); + expect(sanitizeFilename(longName).length).toBe(255); + }); + + test('gère les points multiples', () => { + expect(sanitizeFilename('file...name')).toBe('file.name'); + }); +}); diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..4cc67b4 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,76 @@ +import type { ProcessRequest, AudioFormat } from '../types/api'; + +const YOUTUBE_URL_REGEX = /^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|shorts\/)|youtu\.be\/)[\w-]+/; +const ALLOWED_FORMATS: AudioFormat[] = ['mp3', 'flac']; + +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +export function validateYoutubeUrl(url: string): ValidationResult { + if (!url || typeof url !== 'string') { + return { isValid: false, error: 'URL manquante ou invalide' }; + } + + const trimmedUrl = url.trim(); + + if (!YOUTUBE_URL_REGEX.test(trimmedUrl)) { + return { isValid: false, error: 'URL YouTube invalide' }; + } + + if (trimmedUrl.includes(';') || trimmedUrl.includes('|') || trimmedUrl.includes('`') || trimmedUrl.includes('$')) { + return { isValid: false, error: 'URL contient des caractères interdits' }; + } + + if (trimmedUrl.includes('&&') || trimmedUrl.includes('||')) { + return { isValid: false, error: 'URL contient des opérateurs shell interdits' }; + } + + return { isValid: true }; +} + +export function validateFormat(format: unknown): ValidationResult { + if (!format || typeof format !== 'string') { + return { isValid: false, error: 'Format manquant ou invalide' }; + } + + if (!ALLOWED_FORMATS.includes(format as AudioFormat)) { + return { isValid: false, error: `Format non supporté. Utilisez: ${ALLOWED_FORMATS.join(', ')}` }; + } + + return { isValid: true }; +} + +export function sanitizeFilename(filename: string): string { + return filename + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') + .replace(/\s+/g, '_') + .replace(/\.+/g, '.') + .substring(0, 255); +} + +export function validateProcessRequest(body: unknown): ValidationResult { + if (!body || typeof body !== 'object') { + return { isValid: false, error: 'Corps de requête invalide' }; + } + + const request = body as Partial; + + const urlValidation = validateYoutubeUrl(request.url || ''); + if (!urlValidation.isValid) { + return urlValidation; + } + + const formatValidation = validateFormat(request.format); + if (!formatValidation.isValid) { + return formatValidation; + } + + if (request.filename && typeof request.filename !== 'string') { + return { isValid: false, error: 'Le nom de fichier doit être une chaîne de caractères' }; + } + + return { isValid: true }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c132cda --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext"], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": [] + }, + "include": ["src/**/*"] +}