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

146 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Libraries\Pipeline;
use App\Libraries\Pipeline\Youtube as YoutubeLib;
use App\Models\JobModel;
/**
* Job lifecycle API used by tool pages and the v1 developer API.
* POST /api/jobs — enqueue (URL-based tools)
* GET /api/jobs/{id} — poll status/progress
* DELETE /api/jobs/{id} — cancel queued job / delete result
*/
final class Jobs extends BaseController
{
public function create(): \CodeIgniter\HTTP\ResponseInterface
{
$isApiV1 = uri_string() === 'api/v1/convert';
$payload = $this->request->getJSON(true) ?: $this->request->getPost();
$slug = (string) ($payload['tool'] ?? '');
$tool = service('finder')->tool($slug);
if ($tool === null) {
return response()->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'unknown_tool', 'message' => 'Unknown tool slug.']);
}
// URL-kind tools fetch their input server-side (SSRF-guarded).
$params = array_intersect_key($payload, array_flip([
'url', 'format', 'bitrate', 'quality', 'width', 'height',
'start', 'end', 'x', 'y', 'crop_width', 'crop_height',
'degrees', 'direction', 'factor', 'fps', 'gain_db', 'level',
'preset', 'pages', '_operation',
]));
$params['_operation'] = $params['_operation'] ?? $tool['operation'];
if ($tool['kind'] === 'upload') {
return response()->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'upload_required', 'message' => 'This tool requires a file upload via POST /upload first.']);
}
if ($tool['kind'] === 'url') {
$videoId = YoutubeLib::extractId((string) ($params['url'] ?? ''));
if ($videoId === null) {
service('analytics')->track('error', ['tool' => $slug]);
return response()->setStatusCode(422)->setJSON(['ok' => false, 'error' => 'invalid_url', 'message' => 'Invalid YouTube URL.']);
}
// canonicalize to a plain watch URL
$params['url'] = 'https://www.youtube.com/watch?v=' . $videoId;
}
try {
$jobId = service('pipeline')->enqueue($tool, $params, $isApiV1 ? 'api' : 'web');
} catch (\RuntimeException $e) {
return response()->setStatusCode(429)->setJSON(['ok' => false, 'error' => 'limit_reached', 'message' => $e->getMessage()]);
}
model(\App\Models\ToolModel::class)->bumpUseCount((int) $tool['id']);
service('analytics')->track('tool_start', ['tool' => $slug]);
return response()->setStatusCode(202)->setJSON([
'ok' => true,
'job_id' => $jobId,
'status' => 'queued',
'poll_url' => base_url('/api/jobs/' . $jobId),
]);
}
public function show(string $id): \CodeIgniter\HTTP\ResponseInterface
{
$job = model(JobModel::class)->find($id);
if ($job === null || ! $this->owns($job)) {
return response()->setStatusCode(404)->setJSON(['ok' => false, 'error' => 'not_found']);
}
$body = [
'job_id' => $job['id'],
'status' => $job['status'],
'stage' => $job['stage'],
'progress' => (int) $job['progress'],
'created_at' => $job['created_at'],
'expires_at' => $job['expires_at'],
];
if ($job['status'] === 'completed') {
$body['download_url'] = '/download/' . $job['id'];
$body['preview_url'] = '/preview/' . $job['id'] . '.' . pathinfo((string) $job['output_file'], PATHINFO_EXTENSION);
$body['output_name'] = $job['output_name'];
$body['output_size'] = (int) $job['output_size'];
}
if ($job['status'] === 'failed') {
$body['message'] = self::friendlyError((string) ($job['error'] ?? 'Processing failed.'));
}
return response()->setJSON(['ok' => true] + $body);
}
public function delete(string $id): \CodeIgniter\HTTP\ResponseInterface
{
$jobs = model(JobModel::class);
$job = $jobs->find($id);
if ($job === null || ! $this->owns($job)) {
return response()->setStatusCode(404)->setJSON(['ok' => false, 'error' => 'not_found']);
}
foreach (['input_file', 'output_file'] as $field) {
if (! empty($job[$field])) {
@unlink(Pipeline::storageDir() . '/' . $job[$field]);
@unlink(Pipeline::incomingDir() . '/' . $job[$field]);
}
}
if (in_array($job['status'], ['queued'], true)) {
$jobs->update($id, ['status' => 'cancelled', 'completed_at' => gmdate('Y-m-d H:i:s')]);
} else {
$jobs->delete($id);
}
return response()->setJSON(['ok' => true, 'deleted' => true]);
}
/** Session ownership for web callers; API-token jobs are keyed by session hash too. */
private function owns(array $job): bool
{
return $job['session_id'] === service('analytics')->sessionHash();
}
private static function friendlyError(string $raw): string
{
$known = [
'Invalid YouTube URL.' => 'That does not look like a valid YouTube link. Paste the full URL.',
'disabled on this server' => 'This capability is currently disabled on this server.',
'exceeds' => 'The file is too large for this tool.',
];
foreach ($known as $needle => $friendly) {
if (str_contains($raw, $needle)) {
return $friendly;
}
}
return 'This file could not be processed. Please check the format and try again.';
}
}