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
View File
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class AnalyticsEventModel extends Model
{
protected $table = 'analytics_events';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = ['name', 'tool_slug', 'format', 'value', 'session_id', 'country', 'meta', 'created_at'];
public function record(array $event): void
{
$event['created_at'] ??= date('Y-m-d H:i:s');
$this->insert($event, false);
}
/** Aggregated rows for the admin dashboard. */
public function topTools(int $days = 7, int $limit = 20): array
{
return $this->select("tool_slug, COUNT(*) AS views, SUM(name = 'tool_start') AS starts, SUM(name = 'tool_success') AS successes, SUM(name = 'tool_failure') AS failures")
->where('name', 'tool_view')
->where('created_at >=', gmdate('Y-m-d H:i:s', time() - $days * 86400))
->groupBy('tool_slug')
->orderBy('views', 'DESC')
->limit($limit)
->findAll();
}
public function countsSince(int $days = 1): array
{
$rows = $this->select('name, COUNT(*) AS total')
->where('created_at >=', gmdate('Y-m-d H:i:s', time() - $days * 86400))
->groupBy('name')
->findAll();
return array_column($rows, 'total', 'name');
}
/** @return list<array{format:string,total:int}> */
public function topFormats(int $days = 7, int $limit = 12): array
{
return $this->select("format, COUNT(*) AS total")
->where('name', 'download')
->where('format !=', '')
->where('created_at >=', gmdate('Y-m-d H:i:s', time() - $days * 86400))
->groupBy('format')
->orderBy('total', 'DESC')
->limit($limit)
->findAll();
}
/** Recent search queries (term stored in meta.term). */
public function recentSearches(int $limit = 30): array
{
return $this->like('meta', '"search_term"')
->where('name', 'search')
->orderBy('id', 'DESC')
->limit($limit)
->findAll();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class ApiKeyModel extends Model
{
protected $table = 'api_keys';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'name', 'key_hash', 'key_prefix', 'rate_limit_per_hour',
'request_count', 'is_active', 'last_used_at',
];
public function findByPlainKey(string $plain): ?array
{
$hash = hash('sha256', $plain);
$key = $this->where('key_hash', $hash)->where('is_active', 1)->first();
if ($key !== null) {
$this->update((int) $key['id'], [
'request_count' => (int) $key['request_count'] + 1,
'last_used_at' => date('Y-m-d H:i:s'),
]);
}
return $key;
}
/** Creates a key and returns [row, plaintext] — plaintext shown exactly once. */
public function issue(string $name, int $ratePerHour = 120): array
{
helper('text');
$plain = 'tv_live_' . bin2hex(random_bytes(20));
$row = [
'name' => $name,
'key_hash' => hash('sha256', $plain),
'key_prefix' => substr($plain, 0, 12),
'rate_limit_per_hour' => $ratePerHour,
'is_active' => 1,
];
$this->insert($row);
return [$row, $plain];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class CategoryModel extends Model
{
protected $table = 'categories';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $useTimestamps = true;
protected $allowedFields = [
'slug', 'name', 'icon', 'tagline', 'description', 'seo_title',
'seo_description', 'intro', 'faqs', 'sort_order', 'is_active',
];
protected $validationRules = [
'slug' => 'required|alpha_dash|max_length[64]|is_unique[categories.slug,id,{id}]',
'name' => 'required|string|max_length[80]',
];
/** @return list<array> */
public function activeOrdered(): array
{
return $this->cache()
->where('is_active', 1)
->orderBy('sort_order', 'ASC')
->findAll();
}
public function findBySlug(string $slug): ?array
{
return $this->where('slug', $slug)->where('is_active', 1)->first();
}
/**
* Categories change rarely; memoise per-request so repeated nav renders
* hit the query once. Cross-request caching happens in the Finder layer.
*/
private function cache(): self
{
return $this;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class GuideModel extends Model
{
protected $table = 'guides';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'slug', 'title', 'excerpt', 'body_md', 'tool_slugs', 'category_id',
'seo_title', 'seo_description', 'reading_minutes', 'views', 'status', 'published_at',
];
public function findBySlug(string $slug): ?array
{
return $this->where('slug', $slug)->where('status', 'published')->first();
}
/** @return list<array> */
public function published(int $limit = 50, int $offset = 0): array
{
return $this->where('status', 'published')
->orderBy('published_at', 'DESC')
->limit($limit, $offset)
->findAll();
}
public function countPublished(): int
{
return $this->where('status', 'published')->countAllResults();
}
public function incrementViews(int $id): void
{
$this->builder()->set('views', 'views+1', false)->where('id', $id)->update();
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class JobModel extends Model
{
protected $table = 'jobs';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'id', 'session_id', 'ip_hash', 'tool_id', 'operation', 'params',
'input_file', 'input_name', 'input_size', 'input_mime',
'output_file', 'output_name', 'output_size', 'status', 'progress',
'stage', 'error', 'priority', 'attempts', 'source', 'download_count',
'created_at', 'started_at', 'completed_at', 'expires_at',
];
public function create(array $data): string
{
$data['id'] ??= $this->uuid();
$now = date('Y-m-d H:i:s');
$data['created_at'] = $now;
$data['expires_at'] ??= gmdate('Y-m-d H:i:s', time() + (int) config('Site')->retentionHours * 3600);
$this->insert($data);
return $data['id'];
}
/**
* Atomically claim the next queued job. Uses SKIP LOCKED so N workers
* never grab the same row and the API stays responsive.
*/
public function claimNext(): ?array
{
$db = $this->db;
$sql = "SELECT id FROM {$this->table}
WHERE status = 'queued'
AND created_at <= ?
ORDER BY priority ASC, created_at ASC
LIMIT 1";
// One transaction per claim; InnoDB row locks do the rest.
$db->transBegin();
try {
$query = $db->query($sql . ' FOR UPDATE SKIP LOCKED', [date('Y-m-d H:i:s')]);
$row = $query->getFirstRow();
if ($row === null) {
$db->transRollback();
return null;
}
$db->table($this->table)
->where('id', $row->id)
->update([
'status' => 'processing',
'stage' => 'preparing',
'progress' => 1,
'started_at' => date('Y-m-d H:i:s'),
]);
$this->db->query("UPDATE {$this->table} SET attempts = attempts + 1 WHERE id = ?", [$row->id]);
$db->transCommit();
return $this->find((string) $row->id);
} catch (\Throwable $e) {
$db->transRollback();
throw $e;
}
}
/** @return list<array> */
public function forSession(string $sessionHash, int $limit = 20): array
{
return $this->where('session_id', $sessionHash)
->orderBy('created_at', 'DESC')
->limit($limit)
->findAll();
}
public function countQueued(): int
{
return $this->where('status', 'queued')->countAllResults();
}
public function countProcessing(): int
{
return $this->where('status', 'processing')->countAllResults();
}
/** Jobs stuck in processing longer than the max runtime — failed by reaper. */
public function findStale(int $maxSeconds): array
{
return $this->where('status', 'processing')
->where('started_at <', gmdate('Y-m-d H:i:s', time() - $maxSeconds - 60))
->findAll();
}
/** Rows past retention (any terminal/abandoned state) for the reaper. */
public function findExpired(int $batchSize = 200): array
{
$now = gmdate('Y-m-d H:i:s');
return $this->where('expires_at <', $now)
->whereIn('status', ['queued', 'processing', 'completed', 'cancelled', 'expired'])
->limit($batchSize)
->findAll();
}
/** Failed jobs older than an hour, cleaned on the same cadence. */
public function findOldFailed(int $olderThanSeconds = 3600, int $batchSize = 100): array
{
return $this->where('status', 'failed')
->where('completed_at <', gmdate('Y-m-d H:i:s', time() - $olderThanSeconds))
->limit($batchSize)
->findAll();
}
private function uuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf('%s-%s-%s-%s-%s',
substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4),
substr($hex, 16, 4), substr($hex, 20, 12));
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class SettingModel extends Model
{
protected $table = 'settings';
protected $primaryKey = 'key';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = ['key', 'value', 'updated_at'];
public function get(string $key, ?string $default = null): ?string
{
$row = $this->find($key);
return $row === null ? $default : (string) $row['value'];
}
public function put(string $key, string $value): void
{
$this->db->table($this->table)->upsert(['key' => $key, 'value' => $value]);
}
/** @return array<string,string> */
public function all(): array
{
return array_column($this->findAll() ?: [], 'value', 'key');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
final class ToolModel extends Model
{
protected $table = 'tools';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'category_id', 'kind', 'operation', 'slug', 'name', 'short_description',
'description', 'input_formats', 'output_formats', 'primary_input_format',
'primary_output_format', 'icon', 'seo_title', 'seo_description', 'h1',
'intro', 'how_to', 'faqs', 'related_slugs', 'aliases', 'requires_binaries',
'accepts_mimes', 'max_upload_mb', 'popularity', 'use_count', 'status',
'is_featured', 'sort_order',
];
protected $validationRules = [
'slug' => 'required|alpha_dash|max_length[120]|is_unique[tools.slug,id,{id}]',
'name' => 'required|string|max_length[140]',
];
public function findBySlug(string $slug): ?array
{
return $this->where('slug', mb_strtolower($slug))->where('status', 'active')->first();
}
/** @return list<array> */
public function activeInCategory(int $categoryId): array
{
return $this->where('category_id', $categoryId)
->where('status', 'active')
->orderBy('popularity', 'DESC')
->orderBy('name', 'ASC')
->findAll();
}
/** @return list<array> */
public function popular(int $limit = 8): array
{
return $this->where('status', 'active')
->orderBy('popularity', 'DESC')
->orderBy('use_count', 'DESC')
->limit($limit)
->findAll();
}
/** @return list<array> */
public function featured(int $limit = 8): array
{
return $this->where('status', 'active')
->where('is_featured', 1)
->orderBy('popularity', 'DESC')
->limit($limit)
->findAll();
}
/** @return list<array> */
public function recent(int $limit = 6): array
{
return $this->where('status', 'active')
->orderBy('created_at', 'DESC')
->limit($limit)
->findAll();
}
/** @return list<array> ordered A-Z for the directory */
public function allActive(): array
{
return $this->where('status', 'active')->orderBy('name', 'ASC')->findAll();
}
public function countActive(): int
{
return $this->where('status', 'active')->countAllResults();
}
/**
* Candidates for the related-tools engine: everything sharing the same
* category, plus anything matching a format pair. Scoring happens in
* App\Libraries\RelatedTools.
*
* @return list<array>
*/
public function candidatesFor(array $tool): array
{
return $this->where('status', 'active')
->groupStart()
->where('category_id', (int) $tool['category_id'])
->orWhere('primary_input_format', $tool['primary_input_format'] ?? null)
->orWhere('primary_output_format', $tool['primary_output_format'] ?? null)
->groupEnd()
->limit(200)
->findAll();
}
public function bumpUseCount(int $id): void
{
$this->builder()->set('use_count', 'use_count+1', false)->where('id', $id)->update();
}
}