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,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\CatalogBuilder;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\GuideModel;
|
||||
use App\Models\ToolModel;
|
||||
|
||||
/**
|
||||
* Admin base: shared layout rendering + nav context.
|
||||
* All children sit behind the admin-auth filter.
|
||||
*/
|
||||
abstract class AdminBase extends BaseController
|
||||
{
|
||||
protected function render(string $view, array $data = []): string
|
||||
{
|
||||
$data['viewContent'] = view('admin/' . $view, $data);
|
||||
|
||||
return view('layout', ['seo' => $this->seo] + $data);
|
||||
}
|
||||
|
||||
/** Shared tool-form field list. */
|
||||
protected function toolFields(): array
|
||||
{
|
||||
return [
|
||||
'slug', 'name', 'short_description', 'description', 'icon',
|
||||
'seo_title', 'seo_description', 'h1', 'intro', 'popularity', 'status',
|
||||
];
|
||||
}
|
||||
|
||||
/** Build a registry row from POST for admin-created tools. */
|
||||
protected function toolFromInput(array $post, int $categoryId): array
|
||||
{
|
||||
// reuse the builder's content generator so admin-created tools get
|
||||
// complete SEO fields even when left blank
|
||||
$generated = null;
|
||||
if (! empty($post['format_in']) && ! empty($post['format_out'])) {
|
||||
foreach (CatalogBuilder::build() as $candidate) {
|
||||
if ($candidate['slug'] === strtolower($post['format_in']) . '-to-' . strtolower($post['format_out'])) {
|
||||
$generated = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'category_id' => $categoryId,
|
||||
'kind' => $post['kind'] ?? ($generated['kind'] ?? 'upload'),
|
||||
'operation' => $post['operation'] ?? ($generated['operation'] ?? 'convert'),
|
||||
'slug' => $post['slug'] ?? ($generated['slug'] ?? ''),
|
||||
'name' => $post['name'],
|
||||
'short_description' => $post['short_description'] ?? '',
|
||||
'description' => $post['description'] ?? null,
|
||||
'input_formats' => json_encode($generated['input_formats'] ?? []),
|
||||
'output_formats' => json_encode($generated['output_formats'] ?? []),
|
||||
'primary_input_format' => $post['format_in'] ?? ($generated['primary_input_format'] ?? null),
|
||||
'primary_output_format' => $post['format_out'] ?? ($generated['primary_output_format'] ?? null),
|
||||
'icon' => $post['icon'] ?? 'file',
|
||||
'seo_title' => $post['seo_title'] ?: ($generated['seo_title'] ?? ($post['name'] . ' – Free Online Tool')),
|
||||
'seo_description' => $post['seo_description'] ?: mb_substr((string) ($post['short_description'] ?? ''), 0, 300),
|
||||
'h1' => $post['h1'] ?: $post['name'],
|
||||
'intro' => $post['intro'] ?: ($generated['intro'] ?? null),
|
||||
'how_to' => json_encode($generated['how_to'] ?? []),
|
||||
'faqs' => json_encode($generated['faqs'] ?? []),
|
||||
'related_slugs' => json_encode([]),
|
||||
'aliases' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['aliases'] ?? ''))))),
|
||||
'requires_binaries' => json_encode($generated['requires_binaries'] ?? []),
|
||||
'accepts_mimes' => json_encode($generated['accepts_mimes'] ?? []),
|
||||
'popularity' => (int) ($post['popularity'] ?? 1000),
|
||||
'status' => in_array(($post['status'] ?? ''), ['active', 'draft', 'inactive'], true) ? $post['status'] : 'active',
|
||||
];
|
||||
}
|
||||
|
||||
protected function categories(): array
|
||||
{
|
||||
return model(CategoryModel::class)->findAll();
|
||||
}
|
||||
|
||||
protected function toolsModel(): ToolModel
|
||||
{
|
||||
return model(ToolModel::class);
|
||||
}
|
||||
|
||||
protected function guidesModel(): GuideModel
|
||||
{
|
||||
return model(GuideModel::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Models\AnalyticsEventModel;
|
||||
|
||||
/** Traffic & conversion analytics dashboard (anonymous events only). */
|
||||
final class AnalyticsAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$events = model(AnalyticsEventModel::class);
|
||||
|
||||
$topTools = $events->topTools(7, 15);
|
||||
foreach ($topTools as &$row) {
|
||||
$starts = (int) ($row['starts'] ?? 0);
|
||||
$successes = (int) ($row['successes'] ?? 0);
|
||||
$failures = (int) ($row['failures'] ?? 0);
|
||||
$finished = $successes + $failures;
|
||||
$row['success_rate'] = $finished > 0 ? (int) round($successes * 100 / $finished) : null;
|
||||
unset($row);
|
||||
}
|
||||
|
||||
return $this->render('analytics_index', [
|
||||
'counts' => $events->countsSince(1),
|
||||
'week' => $events->countsSince(7),
|
||||
'topTools' => $topTools,
|
||||
'formats' => $events->topFormats(7),
|
||||
'searches' => $events->recentSearches(15),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Admin authentication: single operator password (bcrypt) from .env.
|
||||
*/
|
||||
final class Auth extends BaseController
|
||||
{
|
||||
public function loginForm(): string
|
||||
{
|
||||
return $this->render('admin/login', ['error' => session()->getFlashdata('login_error')]);
|
||||
}
|
||||
|
||||
public function attempt(): ResponseInterface
|
||||
{
|
||||
$password = (string) $this->request->getPost('password');
|
||||
$hash = (string) env('site.adminPassword', '');
|
||||
|
||||
if ($password === '' || ! password_verify($password, $hash)) {
|
||||
sleep(1); // brute-force damping
|
||||
session()->setFlashdata('login_error', 'Incorrect password.');
|
||||
service('analytics')->track('error', ['meta' => ['scope' => 'admin_login_failed']]);
|
||||
|
||||
return redirect()->to('/admin/login')->withInput();
|
||||
}
|
||||
|
||||
// rehash if algorithm cost changed
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
}
|
||||
|
||||
session()->set(['tv_admin_ok' => true, 'tv_admin_at' => time()]);
|
||||
|
||||
return redirect()->to('/admin');
|
||||
}
|
||||
|
||||
public function logout(): ResponseInterface
|
||||
{
|
||||
session()->remove('tv_admin_ok');
|
||||
|
||||
return redirect()->to('/admin/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/** Categories CRUD (compact). */
|
||||
final class CategoriesAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
return $this->render('categories_index', ['categories' => $this->categories()]);
|
||||
}
|
||||
|
||||
public function store(): ResponseInterface
|
||||
{
|
||||
$post = $this->request->getPost();
|
||||
model(\App\Models\CategoryModel::class)->insert([
|
||||
'slug' => $post['slug'],
|
||||
'name' => $post['name'],
|
||||
'tagline' => $post['tagline'] ?? null,
|
||||
'icon' => $post['icon'] ?? 'tool',
|
||||
'seo_title' => $post['seo_title'] ?? null,
|
||||
'seo_description' => $post['seo_description'] ?? null,
|
||||
'sort_order' => (int) ($post['sort_order'] ?? 99),
|
||||
'is_active' => 1,
|
||||
]);
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/categories');
|
||||
}
|
||||
|
||||
public function edit(int $id): string
|
||||
{
|
||||
return $this->render('categories_form', [
|
||||
'category' => model(\App\Models\CategoryModel::class)->find($id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
$post = $this->request->getPost();
|
||||
model(\App\Models\CategoryModel::class)->update($id, [
|
||||
'name' => $post['name'],
|
||||
'tagline' => $post['tagline'] ?? null,
|
||||
'icon' => $post['icon'] ?? 'tool',
|
||||
'seo_title' => $post['seo_title'] ?? null,
|
||||
'seo_description' => $post['seo_description'] ?? null,
|
||||
'sort_order' => (int) ($post['sort_order'] ?? 99),
|
||||
]);
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/categories');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Models\AnalyticsEventModel;
|
||||
use App\Models\JobModel;
|
||||
|
||||
/** Admin overview: counts, queue health, recent activity. */
|
||||
final class Dashboard extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$jobs = model(JobModel::class);
|
||||
$day = model(AnalyticsEventModel::class)->countsSince(1);
|
||||
|
||||
return $this->render('dashboard', [
|
||||
'toolCount' => service('finder')->countTools(),
|
||||
'guideCount' => $this->guidesModel()->countPublished(),
|
||||
'queued' => $jobs->countQueued(),
|
||||
'processing' => $jobs->countProcessing(),
|
||||
'failedToday' => $jobs->where('status', 'failed')->where('created_at >=', gmdate('Y-m-d H:i:s', time() - 86400))->countAllResults(),
|
||||
'viewsToday' => $day['tool_view'] ?? 0,
|
||||
'startsToday' => $day['tool_start'] ?? 0,
|
||||
'searches' => model(AnalyticsEventModel::class)->recentSearches(8),
|
||||
'recentJobs' => $jobs->orderBy('created_at', 'DESC')->limit(8)->findAll(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/** Guides CRUD with markdown body editing. */
|
||||
final class GuidesAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
return $this->render('guides_index', ['guides' => $this->guidesModel()->orderBy('published_at', 'DESC')->findAll()]);
|
||||
}
|
||||
|
||||
public function create(): string
|
||||
{
|
||||
return $this->render('guides_form', ['guide' => null]);
|
||||
}
|
||||
|
||||
public function store(): ResponseInterface
|
||||
{
|
||||
$post = $this->request->getPost();
|
||||
$body = (string) ($post['body_md'] ?? '');
|
||||
|
||||
$this->guidesModel()->insert([
|
||||
'slug' => $post['slug'],
|
||||
'title' => $post['title'],
|
||||
'excerpt' => $post['excerpt'] ?? '',
|
||||
'body_md' => $body,
|
||||
'tool_slugs' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['tool_slugs'] ?? ''))))),
|
||||
'seo_title' => $post['seo_title'] ?? null,
|
||||
'seo_description' => $post['seo_description'] ?? null,
|
||||
'reading_minutes' => max(1, (int) ceil(str_word_count(strip_tags($body)) / 220)),
|
||||
'status' => $post['status'] === 'published' ? 'published' : 'draft',
|
||||
'published_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/guides');
|
||||
}
|
||||
|
||||
public function edit(int $id): string
|
||||
{
|
||||
return $this->render('guides_form', ['guide' => $this->guidesModel()->find($id)]);
|
||||
}
|
||||
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
$post = $this->request->getPost();
|
||||
$body = (string) ($post['body_md'] ?? '');
|
||||
|
||||
$this->guidesModel()->update($id, [
|
||||
'title' => $post['title'],
|
||||
'excerpt' => $post['excerpt'] ?? '',
|
||||
'body_md' => $body,
|
||||
'tool_slugs' => json_encode(array_filter(array_map('trim', explode(',', (string) ($post['tool_slugs'] ?? ''))))),
|
||||
'seo_title' => $post['seo_title'] ?? null,
|
||||
'seo_description' => $post['seo_description'] ?? null,
|
||||
'reading_minutes' => max(1, (int) ceil(str_word_count(strip_tags($body)) / 220)),
|
||||
'status' => $post['status'] === 'published' ? 'published' : 'draft',
|
||||
]);
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/guides');
|
||||
}
|
||||
|
||||
public function delete(int $id): ResponseInterface
|
||||
{
|
||||
$this->guidesModel()->delete($id);
|
||||
|
||||
return redirect()->to('/admin/guides');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Libraries\Pipeline;
|
||||
use App\Models\JobModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/** Queue inspection + retry. */
|
||||
final class JobsAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$status = (string) ($this->request->getGet('status') ?? '');
|
||||
|
||||
$jobs = model(JobModel::class)->orderBy('created_at', 'DESC')->limit(100);
|
||||
if ($status !== '' && in_array($status, ['queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'], true)) {
|
||||
$jobs->where('status', $status);
|
||||
}
|
||||
|
||||
return $this->render('jobs_index', [
|
||||
'jobs' => $jobs->findAll(),
|
||||
'filter' => $status,
|
||||
'counts' => [
|
||||
'queued' => model(JobModel::class)->countQueued(),
|
||||
'processing' => model(JobModel::class)->countProcessing(),
|
||||
'failed' => model(JobModel::class)->where('status', 'failed')->countAllResults(),
|
||||
'completed' => model(JobModel::class)->where('status', 'completed')->countAllResults(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry(string $id): ResponseInterface
|
||||
{
|
||||
$job = model(JobModel::class)->find($id);
|
||||
if ($job === null) {
|
||||
throw new \CodeIgniter\Exceptions\PageNotFoundException();
|
||||
}
|
||||
|
||||
Pipeline::failJob($id, 'superseded by retry');
|
||||
$clone = $job;
|
||||
$clone['status'] = 'queued';
|
||||
$clone['stage'] = 'queued';
|
||||
$clone['progress'] = 0;
|
||||
$clone['attempts'] = 0;
|
||||
$clone['error'] = null;
|
||||
$clone['output_file'] = null;
|
||||
$clone['output_size'] = 0;
|
||||
$clone['created_at'] = gmdate('Y-m-d H:i:s');
|
||||
$clone['started_at'] = null;
|
||||
$clone['completed_at'] = null;
|
||||
$clone['expires_at'] = gmdate('Y-m-d H:i:s', time() + config('Site')->retentionHours * 3600);
|
||||
unset($clone['id']);
|
||||
model(JobModel::class)->insert($clone, false);
|
||||
|
||||
return redirect()->to('/admin/jobs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
/**
|
||||
* SEO quality dashboard: automated audit of the whole indexable surface.
|
||||
*
|
||||
* Critical checks (block publishing in ToolsAdmin too):
|
||||
* unique titles / descriptions, length ranges, H1 present, canonical
|
||||
* slug hygiene, FAQ presence for content tools, duplicate detection.
|
||||
*/
|
||||
final class SeoAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$tools = service('finder')->all();
|
||||
$guides = model(\App\Models\GuideModel::class)->published(1000);
|
||||
|
||||
$issues = [];
|
||||
$seenTitles = [];
|
||||
$seenDescs = [];
|
||||
|
||||
foreach ($tools as $tool) {
|
||||
$slug = $tool['slug'];
|
||||
$title = (string) $tool['seo_title'];
|
||||
$desc = (string) $tool['seo_description'];
|
||||
|
||||
if (trim($title) === '') {
|
||||
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing SEO title'];
|
||||
} elseif (mb_strlen($title) > 65) {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Title longer than 65 chars (' . mb_strlen($title) . ')'];
|
||||
}
|
||||
if (trim($desc) === '') {
|
||||
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing meta description'];
|
||||
} elseif (mb_strlen($desc) < 70 || mb_strlen($desc) > 320) {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description outside 70–320 chars (' . mb_strlen($desc) . ')'];
|
||||
}
|
||||
if (trim((string) $tool['h1']) === '') {
|
||||
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Missing H1'];
|
||||
}
|
||||
if ($tool['faqs'] === [] && ! in_array($tool['kind'], ['text'], true)) {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'No FAQ content'];
|
||||
}
|
||||
if ($tool['related_slugs'] === [] && count(service('relatedTools')->forTool($tool, 4)) < 3) {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Thin internal linking (<3 related tools)'];
|
||||
}
|
||||
if (! preg_match('/^[a-z0-9]+$/', (string) ($tool['primary_input_format'] ?? '')) && $tool['operation'] === 'convert') {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Converter without primary format pair'];
|
||||
}
|
||||
|
||||
$tKey = mb_strtolower($title);
|
||||
if ($tKey !== '' && isset($seenTitles[$tKey])) {
|
||||
$issues[] = ['severity' => 'critical', 'page' => '/' . $slug, 'check' => 'Duplicate title with /' . $seenTitles[$tKey]];
|
||||
}
|
||||
$seenTitles[$tKey] = $slug;
|
||||
|
||||
$dKey = mb_substr(mb_strtolower($desc), 0, 120);
|
||||
if ($dKey !== '' && isset($seenDescs[$dKey])) {
|
||||
// near-duplicate short descriptions are common across generated converters — flag only exact dupes
|
||||
if (mb_strtolower((string) $tool['short_description']) === mb_strtolower((string) $desc)) {
|
||||
$issues[] = ['severity' => 'warning', 'page' => '/' . $slug, 'check' => 'Description identical to /' . $seenDescs[$dKey]];
|
||||
}
|
||||
}
|
||||
$seenDescs[$dKey] = $slug;
|
||||
}
|
||||
|
||||
foreach ($guides as $g) {
|
||||
if ((string) $g['excerpt'] === '' || (string) $g['body_md'] === '') {
|
||||
$issues[] = ['severity' => 'critical', 'page' => '/blog/' . $g['slug'], 'check' => 'Guide missing excerpt/body'];
|
||||
}
|
||||
}
|
||||
|
||||
usort($issues, static fn ($a, $b) => [$b['severity'], $a['page']] <=> [$a['severity'], $b['page']]);
|
||||
$criticalCount = count(array_filter($issues, static fn ($i) => $i['severity'] === 'critical'));
|
||||
|
||||
return $this->render('seo_index', [
|
||||
'issues' => $issues,
|
||||
'criticalCount' => $criticalCount,
|
||||
'toolCount' => count($tools),
|
||||
'guideCount' => count($guides),
|
||||
'sitemapUrl' => base_url('/sitemap.xml'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Models\SettingModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/** Runtime feature flags & retention overrides. */
|
||||
final class SettingsAdmin extends AdminBase
|
||||
{
|
||||
private const FLAGS = [
|
||||
'ytdlp_enabled' => 'Enable YouTube video/audio downloading (requires yt-dlp binary + legal review)',
|
||||
'ads_enabled' => 'Display advertising slots',
|
||||
'maintenance' => 'Maintenance mode notice on tool pages',
|
||||
];
|
||||
|
||||
public function index(): string
|
||||
{
|
||||
return $this->render('settings_index', [
|
||||
'flags' => self::FLAGS,
|
||||
'values' => model(SettingModel::class)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): ResponseInterface
|
||||
{
|
||||
$model = model(SettingModel::class);
|
||||
$posted = (array) $this->request->getPost();
|
||||
|
||||
foreach (array_keys(self::FLAGS) as $key) {
|
||||
$model->put($key, isset($posted[$key]) ? '1' : '0');
|
||||
}
|
||||
if (! empty($posted['retention_hours'])) {
|
||||
$hours = max(1, min(72, (int) $posted['retention_hours']));
|
||||
$model->put('retention_hours', (string) $hours);
|
||||
}
|
||||
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/settings');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
/**
|
||||
* System health: binaries, storage, queue depth, worker heartbeat.
|
||||
* Workers touch the heartbeat cache key every pass (see supervisor config).
|
||||
*/
|
||||
final class SystemAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$site = config('Site');
|
||||
|
||||
$binaries = [];
|
||||
foreach ($site->binaries as $name => $path) {
|
||||
$binaries[$name] = [
|
||||
'path' => $path,
|
||||
'ok' => $path !== '' && is_executable($path),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->render('system_index', [
|
||||
'phpVersion' => PHP_VERSION,
|
||||
'ciVersion' => \CodeIgniter\CodeIgniter::CI_VERSION,
|
||||
'environment' => ENVIRONMENT,
|
||||
'dbOk' => $this->checkDb(),
|
||||
'diskFreeMb' => (int) round(disk_free_space(WRITEPATH) / 1048576),
|
||||
'storageFiles' => count(glob(\App\Libraries\Pipeline::storageDir() . '/*') ?: []),
|
||||
'incoming' => count(glob(\App\Libraries\Pipeline::incomingDir() . '/*') ?: []),
|
||||
'workerBeat' => cache()->get('tv_worker_heartbeat'),
|
||||
'ytDlpEnabled' => service('pipeline')->youtubeEnabled(),
|
||||
'retentionHrs' => $site->retentionHours,
|
||||
'maxUploadMb' => $site->maxUploadMb,
|
||||
] + ['binaries' => $binaries]);
|
||||
}
|
||||
|
||||
private function checkDb(): bool
|
||||
{
|
||||
try {
|
||||
db_connect()->query('SELECT 1');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/** Tools CRUD: list, create (with generated SEO content), edit, delete. */
|
||||
final class ToolsAdmin extends AdminBase
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
|
||||
$query = $this->toolsModel()->orderBy('name', 'ASC')->limit(300);
|
||||
if ($q !== '') {
|
||||
$query->like('name', $q)->orLike('slug', $q);
|
||||
}
|
||||
|
||||
return $this->render('tools_index', [
|
||||
'tools' => $query->findAll(),
|
||||
'categories' => $this->categories(),
|
||||
'q' => $q,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): string
|
||||
{
|
||||
return $this->render('tools_form', [
|
||||
'tool' => null,
|
||||
'categories' => $this->categories(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(): ResponseInterface
|
||||
{
|
||||
return $this->save(null);
|
||||
}
|
||||
|
||||
public function edit(int $id): string
|
||||
{
|
||||
$tool = $this->toolsModel()->find($id) ?? throw new \CodeIgniter\Exceptions\PageNotFoundException();
|
||||
|
||||
return $this->render('tools_form', [
|
||||
'tool' => $tool,
|
||||
'categories' => $this->categories(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id): ResponseInterface
|
||||
{
|
||||
return $this->save($id);
|
||||
}
|
||||
|
||||
private function save(?int $id): ResponseInterface
|
||||
{
|
||||
$post = $this->request->getPost();
|
||||
$catId = (int) ($post['category_id'] ?? 0);
|
||||
|
||||
try {
|
||||
$row = $this->toolFromInput((array) $post, $catId);
|
||||
} catch (\Throwable $e) {
|
||||
session()->setFlashdata('error', 'Could not build the tool: ' . $e->getMessage());
|
||||
|
||||
return redirect()->back()->withInput();
|
||||
}
|
||||
|
||||
// pre-publish SEO validation (critical checks only)
|
||||
$problems = [];
|
||||
if (trim((string) $row['seo_title']) === '') {
|
||||
$problems[] = 'SEO title missing';
|
||||
}
|
||||
if (mb_strlen((string) $row['seo_description']) < 50) {
|
||||
$problems[] = 'SEO description too short (<50 chars)';
|
||||
}
|
||||
if (trim((string) $row['h1']) === '') {
|
||||
$problems[] = 'H1 missing';
|
||||
}
|
||||
if ($problems !== [] && ($row['status'] ?? '') === 'active') {
|
||||
session()->setFlashdata('error', 'Blocked from publishing: ' . implode('; ', $problems));
|
||||
session()->setFlashdata('form_data', $post);
|
||||
|
||||
return redirect()->to($id ? '/admin/tools/' . $id . '/edit' : '/admin/tools/new')->withInput();
|
||||
}
|
||||
|
||||
if ($id !== null) {
|
||||
$this->toolsModel()->update($id, $row);
|
||||
session()->setFlashdata('success', 'Tool updated.');
|
||||
} else {
|
||||
$this->toolsModel()->insert($row);
|
||||
session()->setFlashdata('success', 'Tool created.');
|
||||
}
|
||||
|
||||
return redirect()->to('/admin/tools');
|
||||
}
|
||||
|
||||
public function delete(int $id): ResponseInterface
|
||||
{
|
||||
$this->toolsModel()->delete($id);
|
||||
service('cache')->clear();
|
||||
|
||||
return redirect()->to('/admin/tools');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user