Files
deepseek beaf0e1f37 Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
2026-08-23 07:10:30 +00:00

224 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Pipeline;
use App\Models\JobModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Upload + download endpoints for tool pages.
*
* Uploads are validated (extension, real MIME via fileinfo, size),
* stored outside the web root with UUID names, and enqueued on the
* processing queue. Downloads stream from temp storage and only for
* jobs owned by the requesting session.
*/
final class Media extends BaseController
{
public function upload(): ResponseInterface
{
if (! $this->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));
}
}