feat: Add SEMSTORM domain input and SEO panel links

- Added optional SEMSTORM domain input field in site creation and editing forms.
- Introduced SEO panel links in site dashboard and edit pages.
- Created a new cron job for SEMSTORM data synchronization.
- Implemented database migrations for cron logs and site SEO metrics.
- Developed SiteSeoSyncService to handle SEMSTORM data fetching and storage.
- Added logging functionality for cron events.
- Created a new LogController to display cron logs with filtering options.
- Added SEO statistics dashboard with visual representation of metrics.
- Implemented site SEO metrics model for data retrieval and manipulation.
This commit is contained in:
2026-02-20 23:49:40 +01:00
parent 3d3432866c
commit e9a3602576
29 changed files with 1611 additions and 56 deletions

View File

@@ -3,13 +3,17 @@
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Config;
use App\Core\Controller;
use App\Helpers\Logger;
use App\Helpers\Validator;
use App\Models\Article;
use App\Models\Site;
use App\Models\SiteSeoMetric;
use App\Models\Topic;
use App\Models\GlobalTopic;
use App\Services\InstallerService;
use App\Services\SiteSeoSyncService;
use App\Services\WordPressService;
class SiteController extends Controller
@@ -63,6 +67,7 @@ class SiteController extends Controller
$siteId = Site::create([
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
@@ -135,6 +140,7 @@ class SiteController extends Controller
Site::update((int) $id, [
'name' => $this->input('name'),
'url' => rtrim($this->input('url'), '/'),
'semstorm_domain' => $this->input('semstorm_domain') ?: null,
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
@@ -207,6 +213,174 @@ class SiteController extends Controller
]);
}
public function seoPanel(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->flash('danger', 'Strona nie znaleziona.');
$this->redirect('/sites');
return;
}
$seoMetrics = SiteSeoMetric::findBySite((int) $id, 12);
$seoLatest = SiteSeoMetric::latestForSite((int) $id);
$this->view('sites/seo', [
'site' => $site,
'seoMetrics' => $seoMetrics,
'seoLatest' => $seoLatest,
]);
}
public function syncSeoMetrics(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->flash('danger', 'Strona nie znaleziona.');
$this->redirect('/sites');
return;
}
$sync = new SiteSeoSyncService();
$result = $sync->syncSite($site, null, true);
if (!empty($result['success'])) {
$this->flash('success', (string) ($result['message'] ?? 'Pobrano dane SEO.'));
} else {
$this->flash('danger', (string) ($result['message'] ?? 'Nie udalo sie pobrac danych SEO.'));
}
$this->redirect("/sites/{$id}/seo");
}
public function syncSeoByToken(): void
{
$configuredToken = (string) Config::get('SEO_TRIGGER_TOKEN', '');
$providedToken = (string) $this->input('token', '');
if ($providedToken === '') {
$providedToken = (string) ($_SERVER['HTTP_X_SEO_TOKEN'] ?? '');
}
if ($configuredToken === '') {
Logger::warning('Token SEO trigger called, but SEO_TRIGGER_TOKEN is not configured.', 'semstorm');
$this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503);
return;
}
if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) {
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
Logger::warning("Invalid SEO token attempt from {$ip}", 'semstorm');
$this->json(['success' => false, 'message' => 'Forbidden'], 403);
return;
}
$force = ((int) $this->input('force', 0)) === 1;
$siteId = (int) $this->input('site_id', 0);
$sync = new SiteSeoSyncService();
$saved = 0;
$skipped = 0;
$failed = 0;
$details = [];
if ($siteId > 0) {
$site = Site::find($siteId);
if (!$site) {
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.'], 404);
return;
}
$result = $sync->syncSite($site, null, $force);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
} elseif (!$force) {
$metricMonth = (new \DateTimeImmutable('first day of this month'))->format('Y-m-01');
$site = Site::findNextDueForSeoSync($metricMonth);
if (!$site) {
$this->json([
'success' => true,
'message' => 'SEMSTORM sync: brak stron do synchronizacji w tym miesiacu.',
'saved' => 0,
'skipped' => 0,
'failed' => 0,
'force' => false,
'details' => [],
], 200);
return;
}
$result = $sync->syncSite($site, null, false);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
} else {
$sites = Site::findActive();
foreach ($sites as $site) {
$result = $sync->syncSite($site, null, $force);
$status = (string) ($result['status'] ?? 'error');
if ($status === 'saved') {
$saved++;
} elseif ($status === 'skipped') {
$skipped++;
} else {
$failed++;
}
$details[] = [
'site_id' => (int) $site['id'],
'site_name' => (string) $site['name'],
'status' => $status,
'message' => (string) ($result['message'] ?? ''),
];
}
}
$this->json([
'success' => $failed === 0,
'message' => "SEMSTORM sync: saved={$saved}, skipped={$skipped}, failed={$failed}",
'saved' => $saved,
'skipped' => $skipped,
'failed' => $failed,
'force' => $force,
'details' => $details,
], 200);
}
public function enablePrettyPermalinks(string $id): void
{
Auth::requireLogin();