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,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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 70–320 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
/**
|
||||
* Tool directory: /tools with search, category filter, popular, recent
|
||||
* and A–Z 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 A–Z',
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user