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,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]);
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
]]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user