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