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
+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* SEO metadata + JSON-LD builder. One instance per request, rendered by
* the layout partial (views/partials/seo.php).
*
* Only structured data the visible page actually supports is emitted —
* no fake ratings or reviews anywhere.
*/
final class Seo
{
private string $title = '';
private string $description = '';
private string $canonical = '';
private string $ogType = 'website';
private ?string $ogImage = null;
/** @var list<array{position:int,name:string,item:string}> */
private array $breadcrumbs = [];
/** @var list<array> JSON-LD graph nodes */
private array $jsonLd = [];
/** @var array<string,string> hreflang lang => url */
private array $alternates = [];
private bool $indexable = true;
public function title(string $title): self
{
$this->title = $title;
return $this;
}
public function description(string $description): self
{
$this->description = mb_substr(trim($description), 0, 320);
return $this;
}
public function canonical(string $pathOrUrl): self
{
$this->canonical = str_starts_with($pathOrUrl, 'http')
? $pathOrUrl
: base_url('/' . ltrim($pathOrUrl, '/'));
return $this;
}
/**
* Breadcrumb trail: [['label' => 'YouTube Tools', 'url' => '/youtube-tools'], ...]
* Home is prepended automatically and BreadcrumbList JSON-LD emitted.
*/
public function breadcrumbs(array $items): self
{
$trail = [['label' => 'Home', 'url' => base_url('/')]];
foreach ($items as $item) {
$trail[] = [
'label' => $item['label'],
'url' => isset($item['url']) ? base_url($item['url']) : null,
];
}
$this->breadcrumbs = array_map(
static fn (array $t, int $i): array => [
'position' => $i + 1,
'name' => $t['label'],
'item' => $t['url'] ?? current_url(),
],
$trail,
range(0, count($trail) - 1)
);
return $this;
}
public function webApplication(string $name, string $description, string $category = 'MultimediaApplication'): self
{
$this->jsonLd[] = [
'@type' => 'WebApplication',
'name' => $name,
'url' => $this->canonical ?: base_url('/'),
'description' => $description,
'applicationCategory' => $category,
'operatingSystem' => 'Any',
'browserRequirements' => 'Requires JavaScript when processing files',
'publisher' => ['@type' => 'Organization', 'name' => config('Site')->name],
];
return $this;
}
public function faqPage(array $faqs): self
{
if ($faqs === []) {
return $this;
}
$entities = [];
foreach ($faqs as $faq) {
if (($faq['q'] ?? '') === '' || ($faq['a'] ?? '') === '') {
continue;
}
$entities[] = [
'@type' => 'Question',
'name' => $faq['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => strip_tags($faq['a'])],
];
}
if ($entities !== []) {
$this->jsonLd[] = ['@type' => 'FAQPage', 'mainEntity' => $entities];
}
return $this;
}
/** HowTo steps: [['name' => ..., 'text' => ...], ...] */
public function howTo(string $name, array $steps): self
{
if ($steps === []) {
return $this;
}
$items = [];
foreach (array_values($steps) as $i => $step) {
$items[] = [
'@type' => 'HowToStep',
'position' => $i + 1,
'name' => $step['name'] ?? ('Step ' . ($i + 1)),
'text' => $step['text'] ?? '',
];
}
$this->jsonLd[] = ['@type' => 'HowTo', 'name' => $name, 'step' => $items];
return $this;
}
public function article(string $headline, string $bodyText, string $publishedAt, string $authorName): self
{
$this->ogType = 'article';
$this->jsonLd[] = [
'@type' => 'Article',
'headline' => mb_substr($headline, 0, 110),
'description' => mb_substr(strip_tags($bodyText), 0, 200),
'datePublished' => $publishedAt,
'dateModified' => $publishedAt,
'author' => ['@type' => 'Organization', 'name' => $authorName],
'publisher' => ['@type' => 'Organization', 'name' => $authorName],
'mainEntityOfPage' => $this->canonical ?: current_url(),
];
return $this;
}
public function noIndex(): self
{
$this->indexable = false;
return $this;
}
public function ogImage(?string $path): self
{
$this->ogImage = $path;
return $this;
}
public function alternate(string $locale, string $url): self
{
$this->alternates[$locale] = $url;
return $this;
}
// -----------------------------------------------------------------
// Renderers used by the layout
// -----------------------------------------------------------------
public function renderTitle(): string
{
return esc($this->title);
}
public function renderHead(): string
{
$site = config('Site');
$out = [];
$canonical = $this->canonical ?: current_url();
$robots = $this->indexable
? 'index, follow, max-image-preview:large'
: 'noindex, nofollow';
$out[] = '<link rel="canonical" href="' . esc($canonical) . '">';
$out[] = '<meta name="robots" content="' . $robots . '">';
$title = $this->title !== '' ? $this->title : $site->name . ' ' . $site->tagline;
$description = $this->description !== '' ? $this->description : $site->description;
$out[] = '<meta property="og:title" content="' . esc($title) . '">';
$out[] = '<meta property="og:description" content="' . esc($description) . '">';
$out[] = '<meta property="og:type" content="' . esc($this->ogType) . '">';
$out[] = '<meta property="og:url" content="' . esc($canonical) . '">';
$out[] = '<meta property="og:site_name" content="' . esc($site->name) . '">';
$image = $this->ogImage ?? base_url('/og/default.png');
$out[] = '<meta property="og:image" content="' . esc($image) . '">';
$out[] = '<meta name="twitter:card" content="summary_large_image">';
$out[] = '<meta name="twitter:title" content="' . esc($title) . '">';
$out[] = '<meta name="twitter:description" content="' . esc($description) . '">';
$out[] = '<meta name="twitter:image" content="' . esc($image) . '">';
foreach ($this->alternates as $lang => $url) {
$out[] = '<link rel="alternate" hreflang="' . esc($lang) . '" href="' . esc($url) . '">';
}
return implode("\n", $out);
}
public function renderJsonLd(): string
{
$graph = [];
// WebSite + SearchAction on every page (site-wide sitelinks searchbox)
if (uri_string() === '') {
$graph[] = [
'@type' => 'WebSite',
'name' => config('Site')->name,
'url' => base_url('/'),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => base_url('/search') . '?q={search_term_string}',
'query-input' => 'required name=search_term_string',
],
];
}
if ($this->breadcrumbs !== []) {
$graph[] = [
'@type' => 'BreadcrumbList',
'itemListElement' => $this->breadcrumbs,
];
}
return json_encode(
['@context' => 'https://schema.org', '@graph' => [...$graph, ...$this->jsonLd]],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}