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
+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);
}
}