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
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
<?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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user