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
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Libraries\CatalogBuilder;
use App\Models\CategoryModel;
use App\Models\GuideModel;
use App\Models\ToolModel;
/**
* Admin base: shared layout rendering + nav context.
* All children sit behind the admin-auth filter.
*/
abstract class AdminBase extends BaseController
{
protected function render(string $view, array $data = []): string
{
$data['viewContent'] = view('admin/' . $view, $data);
return view('layout', ['seo' => $this->seo] + $data);
}
/** Shared tool-form field list. */
protected function toolFields(): array
{
return [
'slug', 'name', 'short_description', 'description', 'icon',
'seo_title', 'seo_description', 'h1', 'intro', 'popularity', 'status',
];
}
/** Build a registry row from POST for admin-created tools. */
protected function toolFromInput(array $post, int $categoryId): array
{
// reuse the builder's content generator so admin-created tools get
// complete SEO fields even when left blank
$generated = null;
if (! empty($post['format_in']) && ! empty($post['format_out'])) {
foreach (CatalogBuilder::build() as $candidate) {
if ($candidate['slug'] === strtolower($post['format_in']) . '-to-' . strtolower($post['format_out'])) {
$generated = $candidate;
break;
}
}
}
return [
'category_id' => $categoryId,
'kind' => $post['kind'] ?? ($generated['kind'] ?? 'upload'),
'operation' => $post['operation'] ?? ($generated['operation'] ?? 'convert'),
'slug' => $post['slug'] ?? ($generated['slug'] ?? ''),
'name' => $post['name'],
'short_description' => $post['short_description'] ?? '',
'description' => $post['description'] ?? null,
'input_formats' => json_encode($generated['input_formats'] ?? []),
'output_formats' => json_encode($generated['output_formats'] ?? []),
'primary_input_format' => $post['format_in'] ?? ($generated['primary_input_format'] ?? null),
'primary_output_format' => $post['format_out'] ?? ($generated['primary_output_format'] ?? null),
'icon' => $post['icon'] ?? 'file',
'seo_title' => $post['seo_title'] ?: ($generated['seo_title'] ?? ($post['name'] . ' Free Online Tool')),
'seo_description' => $post['seo_description'] ?: mb_substr((string) ($post['short_description'] ?? ''), 0, 300),
'h1' => $post['h1'] ?: $post['name'],
'intro' => $post['intro'] ?: ($generated['intro'] ?? null),
'how_to' => json_encode($generated['how_to'] ?? []),
'faqs' => json_encode($generated['faqs'] ?? []),
'related_slugs' => json_encode([]),
'aliases' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['aliases'] ?? ''))))),
'requires_binaries' => json_encode($generated['requires_binaries'] ?? []),
'accepts_mimes' => json_encode($generated['accepts_mimes'] ?? []),
'popularity' => (int) ($post['popularity'] ?? 1000),
'status' => in_array(($post['status'] ?? ''), ['active', 'draft', 'inactive'], true) ? $post['status'] : 'active',
];
}
protected function categories(): array
{
return model(CategoryModel::class)->findAll();
}
protected function toolsModel(): ToolModel
{
return model(ToolModel::class);
}
protected function guidesModel(): GuideModel
{
return model(GuideModel::class);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Models\AnalyticsEventModel;
/** Traffic & conversion analytics dashboard (anonymous events only). */
final class AnalyticsAdmin extends AdminBase
{
public function index(): string
{
$events = model(AnalyticsEventModel::class);
$topTools = $events->topTools(7, 15);
foreach ($topTools as &$row) {
$starts = (int) ($row['starts'] ?? 0);
$successes = (int) ($row['successes'] ?? 0);
$failures = (int) ($row['failures'] ?? 0);
$finished = $successes + $failures;
$row['success_rate'] = $finished > 0 ? (int) round($successes * 100 / $finished) : null;
unset($row);
}
return $this->render('analytics_index', [
'counts' => $events->countsSince(1),
'week' => $events->countsSince(7),
'topTools' => $topTools,
'formats' => $events->topFormats(7),
'searches' => $events->recentSearches(15),
]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Admin authentication: single operator password (bcrypt) from .env.
*/
final class Auth extends BaseController
{
public function loginForm(): string
{
return $this->render('admin/login', ['error' => session()->getFlashdata('login_error')]);
}
public function attempt(): ResponseInterface
{
$password = (string) $this->request->getPost('password');
$hash = (string) env('site.adminPassword', '');
if ($password === '' || ! password_verify($password, $hash)) {
sleep(1); // brute-force damping
session()->setFlashdata('login_error', 'Incorrect password.');
service('analytics')->track('error', ['meta' => ['scope' => 'admin_login_failed']]);
return redirect()->to('/admin/login')->withInput();
}
// rehash if algorithm cost changed
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
}
session()->set(['tv_admin_ok' => true, 'tv_admin_at' => time()]);
return redirect()->to('/admin');
}
public function logout(): ResponseInterface
{
session()->remove('tv_admin_ok');
return redirect()->to('/admin/login');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use CodeIgniter\HTTP\ResponseInterface;
/** Categories CRUD (compact). */
final class CategoriesAdmin extends AdminBase
{
public function index(): string
{
return $this->render('categories_index', ['categories' => $this->categories()]);
}
public function store(): ResponseInterface
{
$post = $this->request->getPost();
model(\App\Models\CategoryModel::class)->insert([
'slug' => $post['slug'],
'name' => $post['name'],
'tagline' => $post['tagline'] ?? null,
'icon' => $post['icon'] ?? 'tool',
'seo_title' => $post['seo_title'] ?? null,
'seo_description' => $post['seo_description'] ?? null,
'sort_order' => (int) ($post['sort_order'] ?? 99),
'is_active' => 1,
]);
service('cache')->clear();
return redirect()->to('/admin/categories');
}
public function edit(int $id): string
{
return $this->render('categories_form', [
'category' => model(\App\Models\CategoryModel::class)->find($id),
]);
}
public function update(int $id): ResponseInterface
{
$post = $this->request->getPost();
model(\App\Models\CategoryModel::class)->update($id, [
'name' => $post['name'],
'tagline' => $post['tagline'] ?? null,
'icon' => $post['icon'] ?? 'tool',
'seo_title' => $post['seo_title'] ?? null,
'seo_description' => $post['seo_description'] ?? null,
'sort_order' => (int) ($post['sort_order'] ?? 99),
]);
service('cache')->clear();
return redirect()->to('/admin/categories');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Models\AnalyticsEventModel;
use App\Models\JobModel;
/** Admin overview: counts, queue health, recent activity. */
final class Dashboard extends AdminBase
{
public function index(): string
{
$jobs = model(JobModel::class);
$day = model(AnalyticsEventModel::class)->countsSince(1);
return $this->render('dashboard', [
'toolCount' => service('finder')->countTools(),
'guideCount' => $this->guidesModel()->countPublished(),
'queued' => $jobs->countQueued(),
'processing' => $jobs->countProcessing(),
'failedToday' => $jobs->where('status', 'failed')->where('created_at >=', gmdate('Y-m-d H:i:s', time() - 86400))->countAllResults(),
'viewsToday' => $day['tool_view'] ?? 0,
'startsToday' => $day['tool_start'] ?? 0,
'searches' => model(AnalyticsEventModel::class)->recentSearches(8),
'recentJobs' => $jobs->orderBy('created_at', 'DESC')->limit(8)->findAll(),
]);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use CodeIgniter\HTTP\ResponseInterface;
/** Guides CRUD with markdown body editing. */
final class GuidesAdmin extends AdminBase
{
public function index(): string
{
return $this->render('guides_index', ['guides' => $this->guidesModel()->orderBy('published_at', 'DESC')->findAll()]);
}
public function create(): string
{
return $this->render('guides_form', ['guide' => null]);
}
public function store(): ResponseInterface
{
$post = $this->request->getPost();
$body = (string) ($post['body_md'] ?? '');
$this->guidesModel()->insert([
'slug' => $post['slug'],
'title' => $post['title'],
'excerpt' => $post['excerpt'] ?? '',
'body_md' => $body,
'tool_slugs' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['tool_slugs'] ?? ''))))),
'seo_title' => $post['seo_title'] ?? null,
'seo_description' => $post['seo_description'] ?? null,
'reading_minutes' => max(1, (int) ceil(str_word_count(strip_tags($body)) / 220)),
'status' => $post['status'] === 'published' ? 'published' : 'draft',
'published_at' => date('Y-m-d H:i:s'),
]);
service('cache')->clear();
return redirect()->to('/admin/guides');
}
public function edit(int $id): string
{
return $this->render('guides_form', ['guide' => $this->guidesModel()->find($id)]);
}
public function update(int $id): ResponseInterface
{
$post = $this->request->getPost();
$body = (string) ($post['body_md'] ?? '');
$this->guidesModel()->update($id, [
'title' => $post['title'],
'excerpt' => $post['excerpt'] ?? '',
'body_md' => $body,
'tool_slugs' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['tool_slugs'] ?? ''))))),
'seo_title' => $post['seo_title'] ?? null,
'seo_description' => $post['seo_description'] ?? null,
'reading_minutes' => max(1, (int) ceil(str_word_count(strip_tags($body)) / 220)),
'status' => $post['status'] === 'published' ? 'published' : 'draft',
]);
service('cache')->clear();
return redirect()->to('/admin/guides');
}
public function delete(int $id): ResponseInterface
{
$this->guidesModel()->delete($id);
return redirect()->to('/admin/guides');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Libraries\Pipeline;
use App\Models\JobModel;
use CodeIgniter\HTTP\ResponseInterface;
/** Queue inspection + retry. */
final class JobsAdmin extends AdminBase
{
public function index(): string
{
$status = (string) ($this->request->getGet('status') ?? '');
$jobs = model(JobModel::class)->orderBy('created_at', 'DESC')->limit(100);
if ($status !== '' && in_array($status, ['queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'], true)) {
$jobs->where('status', $status);
}
return $this->render('jobs_index', [
'jobs' => $jobs->findAll(),
'filter' => $status,
'counts' => [
'queued' => model(JobModel::class)->countQueued(),
'processing' => model(JobModel::class)->countProcessing(),
'failed' => model(JobModel::class)->where('status', 'failed')->countAllResults(),
'completed' => model(JobModel::class)->where('status', 'completed')->countAllResults(),
],
]);
}
public function retry(string $id): ResponseInterface
{
$job = model(JobModel::class)->find($id);
if ($job === null) {
throw new \CodeIgniter\Exceptions\PageNotFoundException();
}
Pipeline::failJob($id, 'superseded by retry');
$clone = $job;
$clone['status'] = 'queued';
$clone['stage'] = 'queued';
$clone['progress'] = 0;
$clone['attempts'] = 0;
$clone['error'] = null;
$clone['output_file'] = null;
$clone['output_size'] = 0;
$clone['created_at'] = gmdate('Y-m-d H:i:s');
$clone['started_at'] = null;
$clone['completed_at'] = null;
$clone['expires_at'] = gmdate('Y-m-d H:i:s', time() + config('Site')->retentionHours * 3600);
unset($clone['id']);
model(JobModel::class)->insert($clone, false);
return redirect()->to('/admin/jobs');
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
/**
* SEO quality dashboard: automated audit of the whole indexable surface.
*
* Critical checks (block publishing in ToolsAdmin too):
* unique titles / descriptions, length ranges, H1 present, canonical
* slug hygiene, FAQ presence for content tools, duplicate detection.
*/
final class SeoAdmin extends AdminBase
{
public function index(): string
{
$tools = service('finder')->all();
$guides = model(\App\Models\GuideModel::class)->published(1000);
$issues = [];
$seenTitles = [];
$seenDescs = [];
foreach ($tools as $tool) {
$slug = $tool['slug'];
$title = (string) $tool['seo_title'];
$desc = (string) $tool['seo_description'];
if (trim($title) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing SEO title'];
} elseif (mb_strlen($title) > 65) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Title longer than 65 chars (' . mb_strlen($title) . ')'];
}
if (trim($desc) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing meta description'];
} elseif (mb_strlen($desc) < 70 || mb_strlen($desc) > 320) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description outside 70320 chars (' . mb_strlen($desc) . ')'];
}
if (trim((string) $tool['h1']) === '') {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing H1'];
}
if ($tool['faqs'] === [] && ! in_array($tool['kind'], ['text'], true)) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'No FAQ content'];
}
if ($tool['related_slugs'] === [] && count(service('relatedTools')->forTool($tool, 4)) < 3) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Thin internal linking (<3 related tools)'];
}
if (! preg_match('/^[a-z0-9]+$/', (string) ($tool['primary_input_format'] ?? '')) && $tool['operation'] === 'convert') {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Converter without primary format pair'];
}
$tKey = mb_strtolower($title);
if ($tKey !== '' && isset($seenTitles[$tKey])) {
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Duplicate title with /' . $seenTitles[$tKey]];
}
$seenTitles[$tKey] = $slug;
$dKey = mb_substr(mb_strtolower($desc), 0, 120);
if ($dKey !== '' && isset($seenDescs[$dKey])) {
// near-duplicate short descriptions are common across generated converters — flag only exact dupes
if (mb_strtolower((string) $tool['short_description']) === mb_strtolower((string) $desc)) {
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description identical to /' . $seenDescs[$dKey]];
}
}
$seenDescs[$dKey] = $slug;
}
foreach ($guides as $g) {
if ((string) $g['excerpt'] === '' || (string) $g['body_md'] === '') {
$issues[] = ['severity' => 'critical', 'page' => '/blog/' . $g['slug'], 'check' => 'Guide missing excerpt/body'];
}
}
usort($issues, static fn ($a, $b) => [$b['severity'], $a['page']] <=> [$a['severity'], $b['page']]);
$criticalCount = count(array_filter($issues, static fn ($i) => $i['severity'] === 'critical'));
return $this->render('seo_index', [
'issues' => $issues,
'criticalCount' => $criticalCount,
'toolCount' => count($tools),
'guideCount' => count($guides),
'sitemapUrl' => base_url('/sitemap.xml'),
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Models\SettingModel;
use CodeIgniter\HTTP\ResponseInterface;
/** Runtime feature flags & retention overrides. */
final class SettingsAdmin extends AdminBase
{
private const FLAGS = [
'ytdlp_enabled' => 'Enable YouTube video/audio downloading (requires yt-dlp binary + legal review)',
'ads_enabled' => 'Display advertising slots',
'maintenance' => 'Maintenance mode notice on tool pages',
];
public function index(): string
{
return $this->render('settings_index', [
'flags' => self::FLAGS,
'values' => model(SettingModel::class)->all(),
]);
}
public function save(): ResponseInterface
{
$model = model(SettingModel::class);
$posted = (array) $this->request->getPost();
foreach (array_keys(self::FLAGS) as $key) {
$model->put($key, isset($posted[$key]) ? '1' : '0');
}
if (! empty($posted['retention_hours'])) {
$hours = max(1, min(72, (int) $posted['retention_hours']));
$model->put('retention_hours', (string) $hours);
}
service('cache')->clear();
return redirect()->to('/admin/settings');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
/**
* System health: binaries, storage, queue depth, worker heartbeat.
* Workers touch the heartbeat cache key every pass (see supervisor config).
*/
final class SystemAdmin extends AdminBase
{
public function index(): string
{
$site = config('Site');
$binaries = [];
foreach ($site->binaries as $name => $path) {
$binaries[$name] = [
'path' => $path,
'ok' => $path !== '' && is_executable($path),
];
}
return $this->render('system_index', [
'phpVersion' => PHP_VERSION,
'ciVersion' => \CodeIgniter\CodeIgniter::CI_VERSION,
'environment' => ENVIRONMENT,
'dbOk' => $this->checkDb(),
'diskFreeMb' => (int) round(disk_free_space(WRITEPATH) / 1048576),
'storageFiles' => count(glob(\App\Libraries\Pipeline::storageDir() . '/*') ?: []),
'incoming' => count(glob(\App\Libraries\Pipeline::incomingDir() . '/*') ?: []),
'workerBeat' => cache()->get('tv_worker_heartbeat'),
'ytDlpEnabled' => service('pipeline')->youtubeEnabled(),
'retentionHrs' => $site->retentionHours,
'maxUploadMb' => $site->maxUploadMb,
] + ['binaries' => $binaries]);
}
private function checkDb(): bool
{
try {
db_connect()->query('SELECT 1');
return true;
} catch (\Throwable) {
return false;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use CodeIgniter\HTTP\ResponseInterface;
/** Tools CRUD: list, create (with generated SEO content), edit, delete. */
final class ToolsAdmin extends AdminBase
{
public function index(): string
{
$q = trim((string) $this->request->getGet('q'));
$query = $this->toolsModel()->orderBy('name', 'ASC')->limit(300);
if ($q !== '') {
$query->like('name', $q)->orLike('slug', $q);
}
return $this->render('tools_index', [
'tools' => $query->findAll(),
'categories' => $this->categories(),
'q' => $q,
]);
}
public function create(): string
{
return $this->render('tools_form', [
'tool' => null,
'categories' => $this->categories(),
]);
}
public function store(): ResponseInterface
{
return $this->save(null);
}
public function edit(int $id): string
{
$tool = $this->toolsModel()->find($id) ?? throw new \CodeIgniter\Exceptions\PageNotFoundException();
return $this->render('tools_form', [
'tool' => $tool,
'categories' => $this->categories(),
]);
}
public function update(int $id): ResponseInterface
{
return $this->save($id);
}
private function save(?int $id): ResponseInterface
{
$post = $this->request->getPost();
$catId = (int) ($post['category_id'] ?? 0);
try {
$row = $this->toolFromInput((array) $post, $catId);
} catch (\Throwable $e) {
session()->setFlashdata('error', 'Could not build the tool: ' . $e->getMessage());
return redirect()->back()->withInput();
}
// pre-publish SEO validation (critical checks only)
$problems = [];
if (trim((string) $row['seo_title']) === '') {
$problems[] = 'SEO title missing';
}
if (mb_strlen((string) $row['seo_description']) < 50) {
$problems[] = 'SEO description too short (<50 chars)';
}
if (trim((string) $row['h1']) === '') {
$problems[] = 'H1 missing';
}
if ($problems !== [] && ($row['status'] ?? '') === 'active') {
session()->setFlashdata('error', 'Blocked from publishing: ' . implode('; ', $problems));
session()->setFlashdata('form_data', $post);
return redirect()->to($id ? '/admin/tools/' . $id . '/edit' : '/admin/tools/new')->withInput();
}
if ($id !== null) {
$this->toolsModel()->update($id, $row);
session()->setFlashdata('success', 'Tool updated.');
} else {
$this->toolsModel()->insert($row);
session()->setFlashdata('success', 'Tool created.');
}
return redirect()->to('/admin/tools');
}
public function delete(int $id): ResponseInterface
{
$this->toolsModel()->delete($id);
service('cache')->clear();
return redirect()->to('/admin/tools');
}
}
+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),
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Seo;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* Shared controller bootstrap: anonymous session boot, SEO service.
*/
abstract class BaseController extends Controller
{
protected Seo $seo;
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
helper('text');
// Anonymous session (cookie) used only for job ownership + rate buckets.
// CI4 auto-starts sessions when the session service is first touched.
$this->seo = service('seo');
}
/** Track a page-level tool view event. */
protected function trackToolView(string $slug): void
{
service('analytics')->track('tool_view', ['tool' => $slug]);
}
/** Render a page inside the shared layout. */
protected function render(string $view, array $data = []): string
{
$data['viewContent'] = view($view, $data);
return view('layout', ['seo' => $this->seo] + $data);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Category landing pages: /youtube-tools, /video-tools, ...
*/
final class Categories extends BaseController
{
public function show(string $slug): string
{
$finder = service('finder');
$category = $finder->category($slug);
if ($category === null) {
return (new Errors())->notFound();
}
$tools = $finder->toolsInCategory($slug);
$guides = model(\App\Models\GuideModel::class)->published(50);
// guides touching this category's tools
$catToolSlugs = array_column($tools, 'slug');
$relatedGuides = array_values(array_filter($guides, static function (array $g) use ($catToolSlugs): bool {
$linked = json_decode((string) ($g['tool_slugs'] ?? '[]'), true) ?: [];
return count(array_intersect($linked, $catToolSlugs)) > 0;
}));
$this->seo->title($category['seo_title'] ?: ($category['name'] . ' Free Online'))
->description($category['seo_description'] ?? (string) $category['tagline'])
->canonical('/' . $category['slug'])
->breadcrumbs([['label' => $category['name']]])
->webApplication($category['name'], (string) ($category['tagline'] ?? ''))
->faqPage(is_array($category['faqs']) ? $category['faqs'] : [])
->ogImage('/og/' . $category['slug'] . '.png');
$this->trackToolView($category['slug']);
return $this->render('category', [
'category' => $category,
'tools' => $tools,
'featured' => array_slice(array_filter($tools, static fn ($t) => (int) $t['is_featured'] === 1), 0, 4),
'guides' => array_slice($relatedGuides, 0, 3),
]);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Tool directory: /tools with search, category filter, popular, recent
* and AZ views. Server-rendered links only — crawlable navigation.
*/
final class Directory extends BaseController
{
public function index(): string
{
$finder = service('finder');
$q = trim((string) $this->request->getGet('q'));
$cat = (string) ($this->request->getGet('category') ?? '');
$tools = $finder->all();
if ($q !== '') {
$tools = service('toolSearch')->find($q, 100);
service('analytics')->track('search', ['meta' => ['term' => mb_substr($q, 0, 80), 'scope' => 'directory']]);
}
if ($cat !== '') {
$tools = array_values(array_filter(
$tools,
static fn (array $t): bool => (service('finder')->categoryById((int) $t['category_id'])['slug'] ?? '') === $cat
));
}
$this->seo->title('All Tools ' . config('Site')->name)
->description('Search and browse every free online tool: video, audio, image, GIF, PDF and YouTube utilities.')
->canonical('/tools')
->breadcrumbs([['label' => 'Tools']]);
return $this->render('directory', [
'tools' => $tools,
'categories' => $finder->categories(),
'query' => $q,
'activeCat' => $cat,
'view' => 'all-tools',
]);
}
public function popular(): string
{
return $this->renderList(
'Popular Tools',
'The most used tools across the platform right now.',
service('finder')->popular(24),
'/tools/popular'
);
}
public function recent(): string
{
return $this->renderList(
'Recently Added Tools',
'Fresh additions to the toolbox.',
service('finder')->recent(24),
'/tools/recent'
);
}
public function az(): string
{
return $this->renderList(
'All Tools AZ',
'Every tool in alphabetical order.',
service('finder')->all(),
'/tools/a-z'
);
}
public function category(string $slug): string
{
return redirect()->to('/' . $slug, 301);
}
private function renderList(string $h1, string $intro, array $tools, string $path): string
{
$this->seo->title($h1 . ' ' . config('Site')->name)
->description(mb_substr($intro, 0, 300))
->canonical($path)
->breadcrumbs([['label' => 'Tools', 'url' => '/tools'], ['label' => $h1]]);
return $this->render('directory_list', [
'tools' => $tools,
'h1' => $h1,
'intro' => $intro,
'view' => $path,
]);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Error pages. Every one offers a route back into the toolbox.
* Deliberately standalone (works when invoked from the router's 404
* override without full initController).
*/
final class Errors extends BaseController
{
public function showNotFound(): string
{
return $this->notFound();
}
public function notFound(): string
{
service('analytics')->sessionHash(); // ensure session exists for ownership checks
service('seo')->title('Tool not found')
->description('The page you requested does not exist. Browse popular tools instead.')
->noIndex();
// status rides the shared response; body returned as string
service('response')->setStatusCode(404);
return view('errors/tool_not_found', [
'popular' => service('finder')->popular(6),
'categories' => service('finder')->categories(),
]);
}
public function forbidden(): string
{
service('seo')->title('Access denied')->noIndex();
service('response')->setStatusCode(403);
return view('errors/http', [
'code' => 403, 'title' => 'Access denied',
'message' => 'You do not have permission to view this page.',
]);
}
public function serverError(): string
{
service('seo')->title('Something went wrong')->noIndex();
service('response')->setStatusCode(500);
return view('errors/http', [
'code' => 500, 'title' => 'Something went wrong',
'message' => 'Our team has been notified. Please try again in a moment.',
]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\MarkdownConverter;
/**
* Guides cluster: /blog and /blog/{slug}.
*/
final class Guides extends BaseController
{
public function index(): string
{
$guides = model(\App\Models\GuideModel::class)->published(50);
$this->seo->title('Guides & Tutorials Media How-Tos')
->description('Practical guides for converting, compressing and editing media: video, audio, images, GIFs, PDFs and YouTube workflows.')
->canonical('/blog')
->breadcrumbs([['label' => 'Blog']]);
return $this->render('guides_index', ['guides' => $guides]);
}
public function show(string $slug): string
{
$guide = model(\App\Models\GuideModel::class)->findBySlug($slug);
if ($guide === null) {
return (new Errors())->notFound();
}
model(\App\Models\GuideModel::class)->incrementViews((int) $guide['id']);
$linkedTools = [];
foreach (json_decode((string) ($guide['tool_slugs'] ?? '[]'), true) ?: [] as $toolSlug) {
if ($tool = service('finder')->tool((string) $toolSlug)) {
$linkedTools[] = $tool;
}
}
$related = array_values(array_filter(
model(\App\Models\GuideModel::class)->published(20),
static fn (array $g): bool => $g['slug'] !== $guide['slug']
));
$this->seo->title($guide['seo_title'] ?: $guide['title'])
->description($guide['seo_description'] ?: mb_substr($guide['excerpt'], 0, 300))
->canonical('/blog/' . $guide['slug'])
->breadcrumbs([['label' => 'Blog', 'url' => '/blog'], ['label' => $guide['title']]])
->article($guide['title'], (string) $guide['body_md'], (string) $guide['published_at'], config('Site')->name)
->ogImage('/og/blog-' . $guide['slug'] . '.png');
return $this->render('guide', [
'guide' => $guide,
'html' => self::markdown((string) $guide['body_md']),
'tools' => $linkedTools,
'related'=> array_slice($related, 0, 4),
]);
}
public function rss(): \CodeIgniter\HTTP\Response
{
$guides = model(\App\Models\GuideModel::class)->published(30);
$site = config('Site');
$items = '';
foreach ($guides as $g) {
$items .= "\n<item><title>" . htmlspecialchars((string) $g['title'], ENT_XML1)
. "</title><link>" . base_url('/blog/' . $g['slug']) . "</link>"
. "<description>" . htmlspecialchars((string) $g['excerpt'], ENT_XML1) . "</description>"
. "<pubDate>" . date(DATE_RSS, strtotime((string) $g['published_at'])) . "</pubDate>"
. "<guid>" . base_url('/blog/' . $g['slug']) . "</guid></item>";
}
$xml = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>'
. htmlspecialchars($site->name . ' Guides', ENT_XML1) . '</title><link>' . base_url('/blog')
. '</link><description>' . htmlspecialchars($site->tagline, ENT_XML1) . '</description>'
. $items . '</channel></rss>';
return response()->setContentType('application/rss+xml; charset=utf-8')->setBody($xml);
}
private static function markdown(string $text): string
{
$env = new Environment(['html_input' => 'strip', 'allow_unsafe_links' => false]);
$env->addExtension(new CommonMarkCoreExtension());
$env->addExtension(new AutolinkExtension());
$env->addExtension(new TableExtension());
return (new MarkdownConverter($env))->convert($text)->getContent();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Health & readiness probes for load balancers / Docker.
* GET /health — process alive. GET /ready — DB + queue reachable.
*/
final class Health extends \CodeIgniter\Controller
{
public function index(): \CodeIgniter\HTTP\Response
{
return response()->setJSON([
'status' => 'ok',
'time' => gmdate('c'),
]);
}
public function ready(): \CodeIgniter\HTTP\Response
{
$checks = [
'database' => false,
'storage' => is_writable(\App\Libraries\Pipeline::storageDir()) || @mkdir(\App\Libraries\Pipeline::storageDir(), 0750, true),
'ffmpeg' => null,
];
try {
db_connect()->query('SELECT 1');
$checks['database'] = true;
} catch (\Throwable) {
}
$path = config('Site')->binaries['ffmpeg'];
if ($path !== null && file_exists($path)) {
$checks['ffmpeg'] = is_executable($path);
}
$ready = $checks['database'] && $checks['storage'];
return response()->setStatusCode($ready ? 200 : 503)->setJSON([
'status' => $ready ? 'ready' : 'not_ready',
'checks' => array_filter($checks, static fn ($v) => $v !== null),
'time' => gmdate('c'),
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Homepage: hero search, popular tools, category grid, content sections.
* Fully server-rendered.
*/
final class Home extends BaseController
{
public function index(): string
{
$finder = service('finder');
$data = [
'popular' => $finder->featured(8),
'recently' => $finder->recent(6),
'mostUsed' => $finder->popular(8),
'categories' => $finder->categories(),
'toolCount' => $finder->countTools(),
'guides' => model(\App\Models\GuideModel::class)->published(4),
];
$this->seo->title(config('Site')->name . ' Free Online Video, YouTube & Image Tools')
->description('Fast, simple and free tools for downloading, converting, compressing and editing media online. ' . $data['toolCount'] . ' tools, no signup required.')
->canonical('/')
->breadcrumbs([]);
return $this->render('home', $data);
}
}
+223
View File
@@ -0,0 +1,223 @@
<?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));
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Legal / compliance pages.
*/
final class Pages extends BaseController
{
public function privacy(): string
{
return $this->page('privacy', 'Privacy Policy', 'What we store (very little), what we never store, and how temporary files are handled.');
}
public function terms(): string
{
return $this->page('terms', 'Terms of Service', 'Acceptable use, content rights and platform rules for using Toolvana.');
}
public function cookies(): string
{
return $this->page('cookies', 'Cookie Policy', 'The two cookies this site sets and what they do.');
}
public function dmca(): string
{
return $this->page('dmca', 'DMCA & Copyright', 'How to submit a takedown or counter-notice, and our repeat-infringer policy.');
}
public function contact(): string
{
return $this->page('contact', 'Contact', 'Reach the team: support, abuse reports, DMCA and API inquiries.');
}
private function page(string $view, string $title, string $description): string
{
$this->seo->title($title . ' ' . config('Site')->name)
->description($description)
->canonical('/' . $view)
->breadcrumbs([['label' => $title]]);
return $this->render('static/' . $view);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
/**
* Crawlable search results page: /search?q=...
*/
final class Search extends BaseController
{
public function index(): string
{
$q = trim((string) $this->request->getGet('q'));
$finder = service('finder');
$results = [];
if ($q !== '') {
$results = service('toolSearch')->find($q, 20);
service('analytics')->track('search', ['meta' => ['term' => mb_substr($q, 0, 80)]]);
}
$this->seo->title($q !== '' ? "Search: {$q}" : 'Search Tools')
->description('Search across all free media tools.')
->canonical('/search')
->noIndex()
->breadcrumbs([['label' => 'Search']]);
return $this->render('search', [
'query' => $q,
'results' => $results,
'popular' => $finder->popular(6),
'categories'=> $finder->categories(),
]);
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\Pipeline;
/**
* SEO plumbing: robots.txt, sitemap index + child sitemaps (auto-split
* by size), Open Graph social cards (GD-generated + cached), PWA
* manifest/service worker and OpenSearch description.
*/
final class Seo extends \CodeIgniter\Controller
{
private const SITEMAP_URLS_PER_FILE = 5000;
public function robots(): \CodeIgniter\HTTP\Response
{
$base = rtrim(base_url(), '/');
$env = ENVIRONMENT === 'production' ? '' : "# development environment — full block\n";
$body = $env . "User-agent: *\n"
. "Allow: /\n"
// processing / API / admin surfaces are never indexed
. "Disallow: /api/\n"
. "Disallow: /upload\n"
. "Disallow: /download/\n"
. "Disallow: /preview/\n"
. "Disallow: /admin\n"
. "Disallow: /search\n"
. "Disallow: /*?q=\n"
. "\nSitemap: {$base}/sitemap.xml\n";
return response()->setContentType('text/plain; charset=utf-8')->setBody($body);
}
/** Sitemap index pointing at split children. */
public function sitemapIndex(): \CodeIgniter\HTTP\Response
{
$base = rtrim(base_url(), '/');
$tools = service('finder')->countTools();
$parts = max(1, (int) ceil($tools / self::SITEMAP_URLS_PER_FILE));
$entries = [];
for ($i = 1; $i <= $parts; ++$i) {
$entries[] = ['loc' => "{$base}/sitemap-tools-{$i}.xml"];
}
foreach (['categories', 'guides', 'pages'] as $kind) {
$entries[] = ['loc' => "{$base}/sitemap-{$kind}.xml"];
}
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
. '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
. implode('', array_map(static fn (array $e): string => '<sitemap><loc>' . htmlspecialchars($e['loc'], ENT_XML1) . '</loc></sitemap>', $entries))
. '</sitemapindex>';
return response()->setContentType('application/xml; charset=utf-8')->setBody($xml);
}
public function sitemapTools(int $page = 1): \CodeIgniter\HTTP\Response
{
$tools = service('finder')->all();
usort($tools, static fn ($a, $b) => strcmp($a['slug'], $b['slug']));
$chunks = array_chunk($tools, self::SITEMAP_URLS_PER_FILE) ?: [[]];
$slice = $chunks[$page - 1] ?? [];
$urls = '';
foreach ($slice as $tool) {
$priority = (int) $tool['popularity'] > 4000 ? '1.0' : '0.7';
$urls .= $this->urlEntry('/' . $tool['slug'], $tool['seo_title'] ?? $tool['name'], 'weekly', $priority);
}
return $this->xmlResponse($urls);
}
public function sitemapCategories(): \CodeIgniter\HTTP\Response
{
$urls = '';
foreach (service('finder')->categories() as $category) {
$urls .= $this->urlEntry('/' . $category['slug'], $category['name'] ?? '', 'weekly', '0.9');
}
return $this->xmlResponse($urls);
}
public function sitemapGuides(): \CodeIgniter\HTTP\Response
{
$urls = '';
foreach (model(\App\Models\GuideModel::class)->published(1000) as $guide) {
$urls .= $this->urlEntry('/blog/' . $guide['slug'], $guide['title'] ?? '', 'monthly', '0.6', $guide['updated_at'] ?? null);
}
return $this->xmlResponse($urls);
}
public function sitemapPages(): \CodeIgniter\HTTP\Response
{
$urls = $this->urlEntry('/', config('Site')->name . ' Free Online Media Tools', 'daily', '1.0')
. $this->urlEntry('/tools', 'All Tools', 'daily', '0.8')
. $this->urlEntry('/tools/popular', 'Popular Tools', 'daily', '0.7')
. $this->urlEntry('/blog', 'Guides', 'daily', '0.6')
. $this->urlEntry('/privacy', 'Privacy Policy', 'yearly', '0.2')
. $this->urlEntry('/terms', 'Terms of Service', 'yearly', '0.2')
. $this->urlEntry('/cookies', 'Cookie Policy', 'yearly', '0.2')
. $this->urlEntry('/dmca', 'DMCA Policy', 'yearly', '0.2')
. $this->urlEntry('/contact', 'Contact', 'yearly', '0.2');
return $this->xmlResponse($urls);
}
private function urlEntry(string $path, string $title = '', string $freq = 'weekly', string $priority = '0.7', ?string $lastmod = null): string
{
$loc = htmlspecialchars(rtrim(base_url(), '/') . $path, ENT_XML1);
return "<url><loc>{$loc}</loc>"
. ($lastmod !== null && $lastmod !== '' ? '<lastmod>' . date('Y-m-d', strtotime((string) $lastmod)) . '</lastmod>' : '')
. "<changefreq>{$freq}</changefreq><priority>{$priority}</priority></url>";
}
private function xmlResponse(string $urls): \CodeIgniter\HTTP\Response
{
return response()->setContentType('application/xml; charset=utf-8')->setBody(
'<?xml version="1.0" encoding="UTF-8"?>'
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . $urls . '</urlset>'
);
}
// -----------------------------------------------------------------
// Social cards: deterministic brand images cached to disk.
// -----------------------------------------------------------------
public function ogImage(string $slug): \CodeIgniter\HTTP\Response
{
$cacheDir = WRITEPATH . 'og';
is_dir($cacheDir) || mkdir($cacheDir, 0755, true);
$safe = preg_replace('/[^a-z0-9-]/', '', $slug) ?: 'default';
$file = $cacheDir . '/' . $safe . '.png';
if (! is_file($file)) {
$this->renderOgCard($file, $this->cardData($safe));
}
return response()->download($file, null)->setContentType('image/png');
}
private function cardData(string $slug): array
{
if ($slug === 'default') {
return [config('Site')->name, config('Site')->tagline, ''];
}
$tool = service('finder')->tool($slug);
if ($tool !== null) {
return [$tool['name'], (string) $tool['short_description'], strtoupper((string) ($tool['primary_output_format'] ?? ''))];
}
foreach (service('finder')->categories() as $c) {
if ($c['slug'] === $slug) {
return [$c['name'], (string) ($c['tagline'] ?? ''), count(service('finder')->toolsInCategory($slug)) . ' tools'];
}
}
if (str_starts_with($slug, 'blog-')) {
$guide = model(\App\Models\GuideModel::class)->findBySlug(substr($slug, 5));
return $guide !== null ? [$guide['title'], (string) $guide['excerpt'], 'GUIDE'] : [config('Site')->name, config('Site')->tagline, ''];
}
return [config('Site')->name, config('Site')->tagline, ''];
}
/** Draws a branded 1200x630 card with GD. */
private function renderOgCard(string $target, array $lines): void
{
[$title, $subtitle, $badge] = $lines;
$w = 1200;
$h = 630;
$img = imagecreatetruecolor($w, $h);
// background gradient (indigo -> violet)
$top = imagecolorallocate($img, 49, 46, 129);
$bot = imagecolorallocate($img, 109, 40, 217);
for ($y = 0; $y < $h; ++$y) {
$t = $y / $h;
$color = imagecolorallocate(
$img,
(int) round(49 + (109 - 49) * $t),
(int) round(46 + (40 - 46) * $t),
(int) round(129 + (217 - 129) * $t)
);
imageline($img, 0, $y, $w, $y, $color);
}
$white = imagecolorallocate($img, 255, 255, 255);
$faint = imagecolorallocate($img, 224, 224, 255);
// badge chip
if ($badge !== '') {
imagefilledrectangle($img, 60, 70, 60 + min(360, 40 + strlen($badge) * 16), 120, imagecolorallocate($img, 255, 255, 255));
imagestring($img, 5, 80, 84, substr($badge, 0, 24), imagecolorallocate($img, 76, 29, 149));
}
// title wrapped
$y = $badge !== '' ? 190 : 150;
foreach (str_split(mb_substr($title, 0, 90), 30) as $line) {
imagestring($img, 5, 62, $y, mb_substr($line, 0, 28), $white);
$y += 42;
}
$y += 10;
// subtitle wrapped
foreach (str_split(mb_substr($subtitle, 0, 160), 52) as $i => $line) {
if ($i > 2) {
break;
}
imagestring($img, 3, 64, $y, mb_substr($line, 0, 50), $faint);
$y += 26;
}
// brand footer
imagestring($img, 4, 62, $h - 70, config('Site')->name . ' — ' . config('Site')->tagline, $white);
imagepng($img, $target, 6);
imagedestroy($img);
}
public function manifest(): \CodeIgniter\HTTP\Response
{
$site = config('Site');
$manifest = [
'name' => $site->name,
'short_name' => $site->name,
'description' => $site->description,
'start_url' => '/',
'display' => 'standalone',
'background_color' => '#312e81',
'theme_color' => '#4c1d95',
'icons' => [
['src' => '/favicon.svg', 'sizes' => 'any', 'type' => 'image/svg+xml'],
['src' => '/og/default.png', 'sizes' => '512x512', 'type' => 'image/png'],
],
];
return response()->setContentType('application/manifest+json')->setBody(json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
/**
* Offline-capable shell: caches the tool directory so users can find
* tools offline. Never pretends server-side processing works offline.
*/
public function serviceWorker(): \CodeIgniter\HTTP\Response
{
$js = <<<'JS'
const CACHE = 'toolvana-v1';
const SHELL = ['/tools', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.pathname.startsWith('/api') || url.pathname.startsWith('/admin')) return;
// static assets: cache-first
if (url.pathname.startsWith('/assets/') || url.pathname === '/favicon.svg') {
event.respondWith(caches.open(CACHE).then(async (cache) => {
const hit = await cache.match(event.request);
const fresh = fetch(event.request).then((res) => { cache.put(event.request, res.clone()); return res; }).catch(() => hit);
return hit || fresh;
}));
return;
}
// pages: network-first with offline fallback
event.respondWith(fetch(event.request).catch(() =>
caches.open(CACHE).then((cache) => cache.match(url.pathname).then((hit) => hit || cache.match('/offline.html')))
));
});
JS;
return response()->setContentType('application/javascript')->setBody($js);
}
public function openSearch(): \CodeIgniter\HTTP\Response
{
$site = config('Site');
$xml = '<?xml version="1.0" encoding="UTF-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">'
. '<ShortName>' . htmlspecialchars($site->name, ENT_XML1) . '</ShortName>'
. '<Description>' . htmlspecialchars('Search ' . $site->name . ' tools', ENT_XML1) . '</Description>'
. '<InputEncoding>UTF-8</InputEncoding>'
. '<Url type="text/html" template="' . base_url('/search') . '?q={searchTerm}"/>'
. '</OpenSearchDescription>';
return response()->setContentType('application/opensearchdescription+xml')->setBody($xml);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Libraries\FormatCatalog;
use App\Libraries\Pipeline\Youtube as YoutubeLib;
/**
* Renders every individual tool page (/{slug}).
*
* The page is 100% server-rendered HTML: H1, intro, formats, how-to and
* FAQ are all in the first response. Interactive processing loads a
* small per-kind JS module afterwards.
*/
final class Tools extends BaseController
{
public function show(string $slug): string
{
$tool = service('finder')->tool($slug);
if ($tool === null) {
return (new Errors())->notFound();
}
$related = service('relatedTools')->forTool($tool);
$conversions = service('relatedTools')->conversionsAround($tool);
$category = service('finder')->categoryById((int) $tool['category_id']);
$guides = $this->guidesFor($tool);
// capability flags for honest UX
$capabilities = [
'yt_dlp' => service('pipeline')->youtubeEnabled(),
'binaries' => $this->checkBinaries($tool),
];
$this->seo->title($tool['seo_title'])
->description($tool['seo_description'])
->canonical('/' . $tool['slug'])
->breadcrumbs([
['label' => $category['name'] ?? 'Tools', 'url' => '/' . ($category['slug'] ?? 'tools')],
['label' => $tool['name']],
])
->webApplication($tool['name'], (string) $tool['short_description'], $this->appCategory($tool))
->faqPage(is_array($tool['faqs']) ? $tool['faqs'] : [])
->howTo((string) $tool['name'], is_array($tool['how_to']) ? $tool['how_to'] : [])
->ogImage('/og/' . $tool['slug'] . '.png');
foreach (config('Site')->locales as $locale => $_label) {
if ($locale === config('Site')->defaultLocale) {
continue;
}
$this->seo->alternate($locale, '/' . $locale . '/' . $tool['slug']);
}
$this->seo->alternate('x-default', '/' . $tool['slug']);
$this->trackToolView($tool['slug']);
return $this->render('tool', [
'tool' => $tool,
'category' => $category,
'related' => $related,
'conversions' => $conversions,
'guides' => $guides,
'capabilities' => $capabilities,
]);
}
/** @return list<array> */
private function guidesFor(array $tool): array
{
$guides = model(\App\Models\GuideModel::class)->published(30);
$hits = [];
foreach ($guides as $guide) {
$linked = json_decode($guide['tool_slugs'] ?? '[]', true) ?: [];
if (in_array($tool['slug'], $linked, true)) {
$hits[] = $guide;
}
}
return array_slice($hits, 0, 3);
}
private function checkBinaries(array $tool): array
{
$ok = [];
foreach (($tool['requires_binaries'] ?? []) as $bin) {
$path = config('Site')->binaries[$bin] ?? null;
$ok[$bin] = $path !== null && is_executable($path);
}
return $ok;
}
private function appCategory(array $tool): string
{
return match (FormatCatalog::family((string) ($tool['primary_output_format'] ?? ''))) {
'video' => 'MultimediaApplication',
'audio' => 'MultimediaApplication',
'image' => 'DesignApplication',
default => 'UtilitiesApplication',
};
}
}