Files
deepseek beaf0e1f37 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
2026-08-23 07:10:30 +00:00

300 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}