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