Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions app/Actions/CronJob/SyncCronJobs.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ private function syncUserCronJobs(Server $server, string $user): void
->where('user', $user)
->get();

// Filter only server-level cronjobs (site_id = null) for status updates
$serverLevelCronJobs = $vitoCronJobs->where('site_id', null);

if (empty($crontabOutput)) {
Expand Down Expand Up @@ -135,7 +134,6 @@ private function syncUserCronJobs(Server $server, string $user): void
'user' => $user,
'command' => $cronJobData['command'],
'frequency' => $cronJobData['frequency'],
'hidden' => false,
'status' => $cronJobData['commented'] ? CronjobStatus::DISABLED : CronjobStatus::READY,
]);
}
Expand Down
4 changes: 2 additions & 2 deletions app/Actions/Service/Manage.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ public function disable(Service $service): void

private function validate(Service $service): void
{
if (! $service->handler()->unit()) {
if (! $service->handler()->canBeManaged()) {
throw ValidationException::withMessages([
'service' => __('This service does not have a systemd unit configured.'),
'service' => __('This service cannot be managed.'),
]);
}
}
Expand Down
7 changes: 7 additions & 0 deletions app/Actions/Site/DeleteSite.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Actions\Site;

use App\Events\SiteDeletedEvent;
use App\Exceptions\SSHError;
use App\Models\Service;
use App\Models\Site;
Expand Down Expand Up @@ -83,6 +84,10 @@ public function delete(Site $site, array $input): void
*/
private function deleteRow(Site $site): void
{
$server = $site->server;
$siteId = $site->id;
$domain = $site->domain;

try {
$site->delete();
} catch (Throwable $e) {
Expand All @@ -95,6 +100,8 @@ private function deleteRow(Site $site): void

throw $e;
}

SiteDeletedEvent::dispatch($server, $siteId, $domain);
}

/**
Expand Down
28 changes: 28 additions & 0 deletions app/Actions/Site/UpdateSiteStats.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Actions\Site;

use App\Jobs\Site\CleanupSiteStatsJob;
use App\Jobs\Site\WriteSiteStatsConfJob;
use App\Models\Site;

class UpdateSiteStats
{
public function disable(Site $site): void
{
$site->jsonUpdate('type_data', 'stats_disabled', true);

if ($site->server->service('log_analysis')) {
dispatch(new CleanupSiteStatsJob($site->server, $site->id));
}
}

public function enable(Site $site): void
{
$site->jsonForget('type_data', 'stats_disabled');

if ($site->server->service('log_analysis')) {
dispatch(new WriteSiteStatsConfJob($site));
}
}
}
224 changes: 224 additions & 0 deletions app/Actions/SiteStats/GetSiteStats.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

namespace App\Actions\SiteStats;

use App\Exceptions\SSHError;
use App\Helpers\SSH;
use App\Models\Site;
use App\Services\LogAnalysis\GoAccess\GoAccess;
use App\Support\Testing\SSHFake;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;

class GetSiteStats
{
/**
* @return array{months: array<int, string>, month: string, summary: array<int, array<string, mixed>>, detail: ?array<string, mixed>}
*
* @throws SSHError
*/
public function get(Site $site, ?string $month = null): array
{
$ssh = $site->server->ssh();

$status = Cache::remember(
"site-stats-status:{$site->id}",
10,
fn (): ?array => $this->readJson($ssh, $site, 'status.json'),
);
$token = (string) ($status['last_run_finished_at'] ?? 'none');

return Cache::remember(
"site-stats:{$site->id}:".($month ?? 'current').":{$token}",
300,
function () use ($ssh, $site, $month, $status): array {
$summary = $this->readJson($ssh, $site, 'summary.json') ?? [];
$summary = collect($summary)
->filter(fn ($row): bool => is_array($row) && isset($row['month']))
->sortBy('month')
->values()
->all();

$months = collect($summary)->pluck('month')->sortDesc()->values()->all();
$selected = $month && in_array($month, $months, true)
? $month
: ($months[0] ?? Carbon::now()->format('Y-m'));

return [
'months' => $months,
'month' => $selected,
'summary' => $summary,
'detail' => $this->detail($ssh, $site, $selected, $status),
'status' => $status ? [
'last_success_at' => $status['last_success_at'] ?? null,
'last_run_finished_at' => $status['last_run_finished_at'] ?? null,
'exit_code' => $status['exit_code'] ?? null,
'error' => $this->sanitizeError($status['error'] ?? null),
] : null,
];
}
);
}

private function sanitizeError(mixed $error): ?string
{
if (! is_string($error) || $error === '') {
return null;
}

$clean = trim((string) preg_replace('/[[:cntrl:]]+/', ' ', $error));

return $clean === '' ? null : mb_substr($clean, 0, 200);
}

/**
* @param ?array<string, mixed> $status
* @return ?array<string, mixed>
*
* @throws SSHError
*/
private function detail(SSH|SSHFake $ssh, Site $site, string $month, ?array $status): ?array
{
$report = $this->readMonth($ssh, $site, $month);
if ($report === null) {
return null;
}

return [
'generated_at' => $status['last_success_at'] ?? null,
'totals' => [
'visitors' => (int) ($report['general']['unique_visitors'] ?? 0),
'hits' => (int) ($report['general']['total_requests'] ?? 0),
'bandwidth' => (int) ($report['general']['bandwidth'] ?? 0),
],
'daily' => $this->daily($report, $month),
'top_pages' => $this->panel($report, 'requests', 12),
'referrers' => $this->panel($report, 'referrers', 20),
'status_codes' => $this->statusCodes($report),
'not_found' => $this->panel($report, 'not_found', 20),
];
}

/**
* @param array<string, mixed> $report
* @return array<int, array<string, mixed>>
*/
private function daily(array $report, string $month): array
{
$byDate = [];
foreach ($report['visitors']['data'] ?? [] as $row) {
$byDate[$this->formatDate((string) ($row['data'] ?? ''))] = [
'visitors' => (int) ($row['visitors']['count'] ?? 0),
'hits' => (int) ($row['hits']['count'] ?? 0),
'bandwidth' => (int) ($row['bytes']['count'] ?? 0),
];
}

if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
return [];
}

$start = Carbon::createFromFormat('Y-m', $month)->startOfMonth();

$days = [];
for ($day = 1; $day <= $start->daysInMonth; $day++) {
$date = $start->copy()->day($day)->format('Y-m-d');
$days[] = array_merge(
['date' => $date],
$byDate[$date] ?? ['visitors' => 0, 'hits' => 0, 'bandwidth' => 0]
);
}

return $days;
}

/**
* @param array<string, mixed> $report
* @return array<int, array<string, mixed>>
*/
private function panel(array $report, string $key, int $limit): array
{
return collect($report[$key]['data'] ?? [])
->take($limit)
->map(fn (array $row): array => [
'name' => (string) ($row['data'] ?? ''),
'hits' => (int) ($row['hits']['count'] ?? 0),
'visitors' => (int) ($row['visitors']['count'] ?? 0),
'bandwidth' => (int) ($row['bytes']['count'] ?? 0),
])
->values()
->all();
}

/**
* @param array<string, mixed> $report
* @return array<int, array<string, mixed>>
*/
private function statusCodes(array $report): array
{
$codes = [];
foreach ($report['status_codes']['data'] ?? [] as $row) {
$children = $row['items'] ?? [$row];
foreach ($children as $child) {
$codes[] = [
'name' => (string) ($child['data'] ?? ''),
'hits' => (int) ($child['hits']['count'] ?? 0),
];
}
}

return $codes;
}

/**
* @return ?array<string, mixed>
*
* @throws SSHError
*/
private function readJson(SSH|SSHFake $ssh, Site $site, string $file): ?array
{
return $this->cat($ssh, GoAccess::BASE_DIR."/data/{$site->id}/{$file}");
}

/**
* @return ?array<string, mixed>
*
* @throws SSHError
*/
private function readMonth(SSH|SSHFake $ssh, Site $site, string $month): ?array
{
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
return null;
}

$report = $this->cat($ssh, GoAccess::BASE_DIR."/data/{$site->id}/{$month}/report.json");

return ($report !== null && isset($report['general'])) ? $report : null;
}

/**
* @return ?array<string, mixed>
*
* @throws SSHError
*/
private function cat(SSH|SSHFake $ssh, string $path): ?array
{
$out = trim($ssh->exec('cat '.escapeshellarg($path).' 2>/dev/null || echo ""'));
if ($out === '') {
return null;
}

$decoded = json_decode($out, true, 512, JSON_INVALID_UTF8_SUBSTITUTE);

return is_array($decoded) ? $decoded : null;
}

private function formatDate(string $value): string
{
if (preg_match('/^\d{8}$/', $value) === 1) {
return Carbon::createFromFormat('Ymd', $value)->format('Y-m-d');
}

return $value;
}
}
65 changes: 65 additions & 0 deletions app/Actions/SiteStats/RenderSiteStatsConf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Actions\SiteStats;

use App\Models\Site;
use Exception;

class RenderSiteStatsConf
{
/**
* @throws Exception
*/
public function render(Site $site, ?string $webserverId = null, ?int $retentionMonths = null): string
{
$domain = $this->safeDomain($site);
$caddy = ($webserverId ?? $this->resolveWebserverId($site)) === 'caddy';
$retention = $retentionMonths ?? (int) ($site->server->service('log_analysis')?->type_data['data_retention'] ?? 12);

$vars = [
'SITE_ID' => (string) $site->id,
'DOMAIN' => $domain,
'LOG_FORMAT' => $caddy ? 'CADDY' : 'COMBINED',
'LIVE_LOG' => $caddy ? "/var/log/caddy/{$domain}-access.log" : "/var/log/nginx/{$domain}-access.log",
'LOG_GLOB' => $caddy ? "/var/log/caddy/{$domain}-access*.log*" : "/var/log/nginx/{$domain}-access.log*",
'RETENTION_MONTHS' => (string) $retention,
'SSH_USER' => $this->safeSshUser($site),
];

$lines = [];
foreach ($vars as $key => $value) {
$lines[] = $key."='".$value."'";
}

return implode("\n", $lines)."\n";
}

private function resolveWebserverId(Site $site): string
{
$webserver = $site->server->webserver();

return $webserver ? $webserver->handler()::id() : 'nginx';
}

private function safeDomain(Site $site): string
{
$domain = (string) $site->domain;

if (! preg_match('/^[A-Za-z0-9.\-]+$/', $domain)) {
throw new Exception('Unsafe site domain for stats processing.');
}

return $domain;
}

private function safeSshUser(Site $site): string
{
$user = (string) $site->server->getSshUser();

if (! preg_match('/^[A-Za-z0-9_.\-]+$/', $user)) {
throw new Exception('Unsafe SSH user for stats processing.');
}

return $user;
}
}
14 changes: 14 additions & 0 deletions app/Actions/SiteStats/ResyncGoAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Actions\SiteStats;

use App\Jobs\Site\ResyncGoAccessJob;
use App\Models\Server;

class ResyncGoAccess
{
public function handle(Server $server): void
{
dispatch(new ResyncGoAccessJob($server));
}
}
Loading
Loading