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:
deepseek
2026-08-23 07:10:30 +00:00
commit beaf0e1f37
217 changed files with 19619 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Api;
use CodeIgniter\Controller;
/**
* Analytics beacon: POST /api/events {name, tool, format, meta}.
* Accepts only the whitelisted event names; everything is anonymized
* server-side before storage.
*/
final class Events extends Controller
{
public function track(): \CodeIgniter\HTTP\ResponseInterface
{
$payload = $this->request->getJSON(true) ?: $this->request->getPost();
$name = (string) ($payload['name'] ?? '');
if (! in_array($name, \App\Libraries\Analytics::EVENTS, true)) {
return response()->setStatusCode(422)->setJSON(['ok' => false]);
}
service('analytics')->track($name, [
'tool' => mb_substr((string) ($payload['tool'] ?? ''), 0, 120),
'format' => isset($payload['format']) ? (string) $payload['format'] : null,
'value' => (int) ($payload['value'] ?? 0),
'meta' => is_array($payload['meta'] ?? null) ? array_slice($payload['meta'], 0, 5) : [],
]);
return response()->setJSON(['ok' => true]);
}
}
+145
View File
@@ -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.';
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Api;
use App\Libraries\ToolSearch;
use CodeIgniter\Controller;
/** Typeahead suggestions for the global search box. */
final class SearchApi extends Controller
{
public function suggest(): \CodeIgniter\HTTP\ResponseInterface
{
$q = trim((string) $this->request->getGet('q'));
if (mb_strlen($q) < 2) {
return response()->setJSON(['ok' => true, 'results' => []]);
}
$hits = (new ToolSearch())->find($q, 6);
$out = [];
foreach ($hits as $tool) {
$out[] = [
'slug' => $tool['slug'],
'name' => $tool['name'],
'url' => '/' . $tool['slug'],
'icon' => $tool['icon'],
];
}
if ($out !== []) {
service('analytics')->track('search', ['meta' => ['term' => mb_substr($q, 0, 60), 'scope' => 'suggest']]);
}
return response()->setJSON(['ok' => true, 'results' => $out]);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Api;
use App\Libraries\CatalogBuilder;
use CodeIgniter\Controller;
/** Public read-only tool registry for developers: /api/v1/tools */
final class ToolsApi extends Controller
{
public function index(): \CodeIgniter\HTTP\ResponseInterface
{
$tools = service('finder')->all();
$out = array_map(static fn (array $t): array => [
'slug' => $t['slug'],
'name' => $t['name'],
'category' => service('finder')->categoryById((int) $t['category_id'])['slug'] ?? null,
'kind' => $t['kind'],
'operation' => $t['operation'],
'input_formats' => $t['input_formats'],
'output_formats' => $t['output_formats'],
'short_description' => $t['short_description'],
'url' => base_url('/' . $t['slug']),
], $tools);
return response()->setJSON(['ok' => true, 'count' => count($out), 'tools' => $out]);
}
public function show(string $slug): \CodeIgniter\HTTP\ResponseInterface
{
$tool = service('finder')->tool($slug);
if ($tool === null) {
return response()->setStatusCode(404)->setJSON(['ok' => false, 'error' => 'not_found']);
}
return response()->setJSON(['ok' => true, 'tool' => [
'slug' => $tool['slug'],
'name' => $tool['name'],
'kind' => $tool['kind'],
'operation' => $tool['operation'],
'input_formats' => $tool['input_formats'],
'output_formats' => $tool['output_formats'],
'how_to' => $tool['how_to'],
'faqs' => $tool['faqs'],
'requires_binaries' => $tool['requires_binaries'],
'accepts_mimes' => $tool['accepts_mimes'],
'max_upload_mb' => (int) ($tool['max_upload_mb'] ?: config('Site')->maxUploadMb),
'url' => base_url('/' . $tool['slug']),
]]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Api;
use App\Controllers\BaseController;
use App\Libraries\Pipeline\Youtube as YoutubeLib;
/**
* Instant YouTube URL analysis used by the paste-a-link UX: returns
* oEmbed metadata + thumbnail candidates before any job is queued.
*/
final class Youtube extends BaseController
{
public function info(): \CodeIgniter\HTTP\ResponseInterface
{
$payload = $this->request->getJSON(true) ?: $this->request->getPost();
$url = (string) ($payload['url'] ?? '');
$videoId = YoutubeLib::extractId($url);
if ($videoId === null) {
return response()->setStatusCode(422)->setJSON([
'ok' => false, 'error' => 'invalid_url',
'message' => 'Invalid YouTube URL. Paste a link like https://www.youtube.com/watch?v=…',
]);
}
service('analytics')->track('tool_start', ['tool' => 'youtube-url-analyzer']);
return response()->setJSON([
'ok' => true,
'id' => $videoId,
'watch_url' => 'https://www.youtube.com/watch?v=' . $videoId,
'embed_url' => 'https://www.youtube-nocookie.com/embed/' . $videoId,
'thumbnails' => [
['label' => 'Max resolution', 'url' => "https://i.ytimg.com/vi/{$videoId}/maxresdefault.jpg"],
['label' => 'Standard', 'url' => "https://i.ytimg.com/vi/{$videoId}/sddefault.jpg"],
['label' => 'High quality', 'url' => "https://i.ytimg.com/vi/{$videoId}/hqdefault.jpg"],
['label' => 'Medium', 'url' => "https://i.ytimg.com/vi/{$videoId}/mqdefault.jpg"],
],
'info' => YoutubeLib::fetchInfo($videoId),
]);
}
}