request->is('post')) { return $this->jsonError(405, 'POST required.'); } $slug = (string) $this->request->getPost('tool'); $tool = service('finder')->tool($slug); if ($tool === null) { return $this->jsonError(400, 'Unknown tool.'); } $files = $this->request->getFiles()['files'] ?? null; if ($files === null) { $single = $this->request->getFile('file'); $files = $single !== null ? [$single] : []; } if (! is_array($files)) { $files = [$files]; } $stored = []; foreach ($files as $file) { if (! ($file instanceof \CodeIgniter\HTTP\Files\UploadedFile) || ! $file->isValid()) { continue; } $stored[] = $this->validateAndStore($file, $tool); } if ($stored === []) { return $this->jsonError(400, 'No valid file was received.'); } session()->set('pending_uploads', $stored); service('analytics')->track('upload', ['tool' => $slug]); return response()->setJSON([ 'ok' => true, 'file' => $stored[0]['file'], 'name' => $stored[0]['name'], 'size' => $stored[0]['size'], 'count' => count($stored), ]); } /** Validate one upload against the tool registry and move it into temp storage. */ private function validateAndStore(\CodeIgniter\HTTP\Files\UploadedFile $file, array $tool): array { // ---- validation: size ------------------------------------------------ $limitMb = (int) ($tool['max_upload_mb'] ?: config('Site')->maxUploadMb); if ($file->getSizeByUnit('mb') > $limitMb) { throw new \RuntimeException("File exceeds the {$limitMb} MB limit for this tool."); } // ---- validation: extension allowlist --------------------------------- $allowedExt = array_merge( $tool['input_formats'] ?? [], in_array($tool['operation'], ['merge', 'add_audio', 'add_subtitles', 'images_to_pdf', 'frames_to_gif'], true) ? ['mp3', 'wav', 'm4a', 'aac', 'srt', 'vtt', 'png', 'jpg', 'webp', 'gif', 'mp4', 'pdf'] : [] ); $ext = strtolower($file->getClientExtension()); if ($allowedExt !== [] && ! in_array($ext, $allowedExt, true)) { throw new \RuntimeException('This file type is not accepted. Allowed: ' . implode(', ', $allowedExt)); } // ---- validation: real MIME -------------------------------------------- $allowedMimes = $tool['accepts_mimes'] ?? []; $realMime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getTempName()) ?: 'application/octet-stream'; if ($allowedMimes !== [] && ! in_array($realMime, $allowedMimes, true)) { // tolerate container/mime drift (e.g. mkv detected as application/octet-stream) $familyOk = str_starts_with($realMime, 'video/') || str_starts_with($realMime, 'audio/') || str_starts_with($realMime, 'image/'); if (! ($ext === 'mkv' && $familyOk)) { throw new \RuntimeException('The file content does not match its extension.'); } } // ---- persist under UUID name, outside web root ------------------------- $uuid = $this->uuid(); $target = Pipeline::incomingDir() . '/' . Pipeline::safeName($uuid, $ext); $file->move(dirname($target), basename($target)); return [ 'file' => basename($target), 'name' => (string) $file->getClientName(), 'size' => (int) $file->getSize(), 'mime' => $realMime, ]; } /** Create a job from an uploaded file (session-owned). */ public function startJob(): ResponseInterface { $isJson = str_contains((string) $this->request->getHeaderLine('Content-Type'), 'application/json'); $body = $isJson ? ($this->request->getJSON(true) ?: []) : []; $slug = (string) ($body['tool'] ?? $this->request->getPost('tool')); $tool = service('finder')->tool($slug); $pending = session('pending_uploads') ?? []; $isTextTool = ($tool['kind'] ?? '') === 'text'; if (! $isTextTool && ($pending === [] || ! is_file(Pipeline::incomingDir() . '/' . $pending[0]['file']))) { return $this->jsonError(400, 'Upload expired — please add your file again.'); } $params = array_intersect_key($body ?: $this->request->getPost(), array_flip([ 'format', 'bitrate', 'quality', 'width', 'height', 'start', 'end', 'x', 'y', 'crop_width', 'crop_height', 'degrees', 'direction', 'factor', 'fps', 'gain_db', 'level', 'preset', 'pages', 'delay', 'content', 'rotate', 'flip', 'stop_at_shortest', 'dpi', '_operation', ])); $params['_operation'] = $params['_operation'] ?? $tool['operation']; $params['_input_file'] = $isTextTool ? '' : $pending[0]['file']; $params['_input_name'] = $isTextTool ? '' : $pending[0]['name']; $params['_input_size'] = $isTextTool ? 0 : (int) $pending[0]['size']; $params['_input_mime'] = $isTextTool ? 'text/plain' : $pending[0]['mime']; // additional files (mergers, images-to-PDF, frames-to-GIF) if (count($pending) > 1) { $params['extra_files'] = array_column(array_slice($pending, 1), 'file'); } try { $jobId = service('pipeline')->enqueue($tool, $params, 'web'); } catch (\RuntimeException $e) { return $this->jsonError(429, $e->getMessage()); } session()->remove('pending_uploads'); model(\App\Models\ToolModel::class)->bumpUseCount((int) $tool['id']); service('analytics')->track('tool_start', ['tool' => $slug]); return response()->setJSON(['ok' => true, 'job_id' => $jobId]); } public function download(string $id): ResponseInterface { $job = model(JobModel::class)->find($id); if ($job === null || $job['status'] !== 'completed' || $job['output_file'] === null || $job['session_id'] !== service('analytics')->sessionHash()) { return (new Errors())->notFound(); } $path = Pipeline::storageDir() . '/' . $job['output_file']; if (! is_file($path)) { return (new Errors())->notFound(); } model(JobModel::class)->builder()->where('id', $id)->set('download_count', 'download_count+1', false)->update(); service('analytics')->track('download', ['tool' => '', 'format' => pathinfo((string) $job['output_name'], PATHINFO_EXTENSION)]); // stream outside the framework to avoid buffering large media header('Content-Type: application/octet-stream'); header('Content-Length: ' . filesize($path)); header('Content-Disposition: attachment; filename="' . rawurlencode((string) $job['output_name']) . '"'); header('X-Content-Type-Options: nosniff'); readfile($path); exit; } /** Inline preview of completed results (images/video/audio). */ public function preview(string $id, string $ext): ResponseInterface { $job = model(JobModel::class)->find($id); if ($job === null || $job['status'] !== 'completed' || $job['output_file'] === null || $job['session_id'] !== service('analytics')->sessionHash() || preg_replace('/[^a-z0-9]/i', '', $ext) !== strtolower(pathinfo((string) $job['output_file'], PATHINFO_EXTENSION))) { return (new Errors())->notFound(); } $path = Pipeline::storageDir() . '/' . $job['output_file']; if (! is_file($path)) { return (new Errors())->notFound(); } return response()->download($path, null) ->setFileName((string) $job['output_name']) ->setContentType($this->previewMime(strtolower($ext))) ->inline(); } private function previewMime(string $ext): string { return match ($ext) { 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif', 'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mp3' => 'audio/mpeg', 'wav' => 'audio/wav', 'm4a' => 'audio/mp4', 'pdf' => 'application/pdf', default => 'application/octet-stream', }; } private function jsonError(int $code, string $message): ResponseInterface { return response()->setStatusCode($code)->setJSON(['ok' => false, 'error' => $message]); } private function uuid(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } }