app/Models/StatusPage.php
Rama main · réplica de lectura del repositorio original.
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* A public (or unlisted, or private) status page.
*
* Composed of "items" which can be either a Project (live link — all its
* monitors appear) or a single Monitor. Individual monitors inside a project
* can be hidden per status page via excludedMonitors.
*
* @property int $id
* @property int $user_id
* @property int|null $team_id
* @property string $name
* @property string $slug
* @property string $visibility public | unlisted | private
* @property string|null $access_key
* @property string|null $description
* @property string|null $footer_text
* @property string|null $logo_path
* @property string $theme
* @property string|null $custom_css
* @property string|null $custom_domain
* @property \Illuminate\Support\Carbon|null $domain_verified_at
*/
class StatusPage extends Model
{
/** @use HasFactory<\Database\Factories\StatusPageFactory> */
use HasFactory;
/**
* Themes available for status pages. Each entry is [theme_id => display_name].
* These must also be enabled in resources/css/app.css @plugin block.
*
* @var array<string, string>
*/
public const AVAILABLE_THEMES = [
'business' => 'Business (Dark)',
'corporate' => 'Corporate (Light)',
'light' => 'Light',
'dark' => 'Dark',
'cupcake' => 'Cupcake',
'emerald' => 'Emerald',
'dracula' => 'Dracula',
'night' => 'Night',
'winter' => 'Winter',
'nord' => 'Nord',
'sunset' => 'Sunset',
];
protected $fillable = [
'user_id',
'team_id',
'name',
'slug',
'description',
'footer_text',
'visibility',
'access_key',
'logo_path',
'theme',
'custom_css',
'custom_domain',
'domain_verified_at',
];
protected function casts(): array
{
return [
'domain_verified_at' => 'datetime',
];
}
/**
* Generate a random access key for unlisted pages
*/
public static function generateAccessKey(): string
{
return Str::random(48);
}
/**
* Generate a deterministic verification token for the current domain.
* The user must add this as a TXT record at _easymonitor-verify.{domain}.
*/
public function domainVerificationToken(): string
{
if (! $this->custom_domain) {
return '';
}
return 'easymonitor-verify-'.substr(
hash_hmac('sha256', $this->custom_domain.'|'.$this->id, config('app.key')),
0,
32
);
}
/**
* The hostname where the verification TXT record should be set.
*/
public function domainVerificationHost(): string
{
return '_easymonitor-verify.'.$this->custom_domain;
}
/**
* Verify the custom domain by looking up the TXT record.
* Marks domain_verified_at on success.
*/
public function verifyCustomDomain(): bool
{
if (! $this->custom_domain) {
return false;
}
$expected = $this->domainVerificationToken();
$records = @dns_get_record($this->domainVerificationHost(), DNS_TXT);
if (! is_array($records)) {
return false;
}
foreach ($records as $record) {
$value = $record['txt'] ?? ($record['entries'][0] ?? '');
if (is_string($value) && trim($value) === $expected) {
$this->update(['domain_verified_at' => now()]);
return true;
}
}
return false;
}
public function isDomainVerified(): bool
{
return $this->custom_domain && $this->domain_verified_at !== null;
}
/**
* Build the publicly-fetchable URL for the logo, regardless of which
* storage disk holds it (local 'public' disk, R2, S3, etc.).
*
* Returns null if no logo is set.
*/
public function logoUrl(): ?string
{
if (! $this->logo_path) {
return null;
}
$default = config('filesystems.default');
$disk = $default === 'local' ? 'public' : $default;
try {
return \Illuminate\Support\Facades\Storage::disk($disk)->url($this->logo_path);
} catch (\Throwable) {
return null;
}
}
/**
* The canonical public URL for this status page.
*
* Uses the verified custom domain when set, otherwise falls back to the
* app-domain slug route. For unlisted pages the secret key is appended.
*/
public function publicUrl(): string
{
if ($this->isDomainVerified()) {
$base = 'https://'.$this->custom_domain;
} else {
$base = route('public.status', $this->slug);
}
if ($this->visibility === 'unlisted' && $this->access_key) {
return $base.'?key='.$this->access_key;
}
return $base;
}
/**
* Resolve which theme name to apply on `data-theme`.
*
* If custom_css contains a DaisyUI `@plugin "daisyui/theme" { name: "X"; ... }`
* block, "X" is used. Otherwise the configured theme is used.
*/
public function effectiveTheme(): string
{
if ($this->custom_css && preg_match('/@plugin\s+["\']daisyui\/theme["\']\s*\{[^}]*?name:\s*["\']([^"\']+)["\']\s*;/s', $this->custom_css, $m)) {
return $m[1];
}
return $this->theme ?: 'business';
}
/**
* Return CSS that's safe to inject inside a <style> tag at runtime.
*
* If the user pasted a DaisyUI `@plugin "daisyui/theme" { ... }` block
* (a build-time directive that browsers can't execute), strip the wrapper
* and metadata, then re-emit the variable definitions under the
* matching [data-theme="..."] selector.
*
* Every `<` is emitted as the CSS escape `\3c ` so the value can never
* close the surrounding <style> element (stored XSS via `</style><script>`).
* Inside CSS strings the escape is equivalent to a literal `<`; outside
* strings `<` was never valid CSS anyway.
*/
public function renderableCustomCss(): string
{
if (! $this->custom_css) {
return '';
}
$css = $this->custom_css;
// Find DaisyUI theme block(s) and convert each to a data-theme selector.
$css = preg_replace_callback(
'/@plugin\s+["\']daisyui\/theme["\']\s*\{(.+?)\n\}/s',
function ($matches) {
$body = $matches[1];
$themeName = 'business';
if (preg_match('/name:\s*["\']([^"\']+)["\']\s*;/', $body, $nameMatch)) {
$themeName = $nameMatch[1];
}
// Strip DaisyUI plugin metadata lines.
$body = preg_replace('/^\s*(name|default|prefersdark|color-scheme):[^;]*;\s*$/m', '', $body);
$body = trim($body);
return '[data-theme="'.$themeName.'"] { '.$body.' }';
},
$css
);
return str_replace('<', '\\3c ', $css);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
/**
* Items attached to this page (projects or individual monitors)
*/
public function items(): HasMany
{
return $this->hasMany(StatusPageItem::class)->orderBy('sort_order');
}
/**
* Monitors hidden on this page (even if inside a linked project)
*/
public function excludedMonitors(): BelongsToMany
{
return $this->belongsToMany(Monitor::class, 'status_page_excluded_monitors')
->withTimestamps();
}
/**
* Incidents published on this page
*/
public function incidents(): HasMany
{
return $this->hasMany(StatusPageIncident::class)->latest();
}
/**
* Resolve all monitors visible on this page, grouped by project item.
*
* Returns a collection of sections: each section has an optional project
* (null for standalone-monitor items) and a collection of monitors.
*
* @return \Illuminate\Support\Collection<int, array{label: string, project: ?Project, monitors: \Illuminate\Support\Collection}>
*/
public function resolveSections(): \Illuminate\Support\Collection
{
$excludedIds = $this->excludedMonitors()->pluck('monitors.id')->all();
return $this->items->map(function (StatusPageItem $item) use ($excludedIds) {
if ($item->type === 'project' && $item->project) {
$monitors = $item->project->monitors()
->with('latestCheckResult')
->whereNotIn('id', $excludedIds)
->where('is_active', true)
->orderBy('id')
->get();
return [
'label' => $item->label ?: $item->project->name,
'project' => $item->project,
'monitors' => $monitors,
];
}
if ($item->type === 'monitor' && $item->monitor) {
return [
'label' => $item->label ?: $item->monitor->name,
'project' => null,
'monitors' => collect([$item->monitor->loadMissing('latestCheckResult')]),
];
}
return null;
})->filter()->values();
}
/**
* Aggregate status across all visible monitors.
*
* Returns operational | degraded | outage | unknown.
*/
public function aggregateStatus(): string
{
$monitors = $this->resolveSections()
->flatMap(fn ($section) => $section['monitors']);
if ($monitors->isEmpty()) {
return 'unknown';
}
$down = $monitors->where('status', 'down')->count();
$up = $monitors->where('status', 'up')->count();
$total = $monitors->count();
if ($down === 0 && $up > 0) {
return 'operational';
}
if ($down === $total) {
return 'outage';
}
if ($down > 0) {
return 'degraded';
}
return 'unknown';
}
/**
* Get currently active incidents (unresolved)
*
* @return \Illuminate\Database\Eloquent\Collection<int, StatusPageIncident>
*/
public function activeIncidents(): \Illuminate\Database\Eloquent\Collection
{
return $this->incidents()
->whereNull('resolved_at')
->where('type', 'incident')
->get();
}
/**
* Get upcoming maintenance windows
*
* @return \Illuminate\Database\Eloquent\Collection<int, StatusPageIncident>
*/
public function upcomingMaintenance(): \Illuminate\Database\Eloquent\Collection
{
return $this->incidents()
->where('type', 'maintenance')
->whereIn('status', ['scheduled', 'in_progress'])
->whereNull('resolved_at')
->orderBy('scheduled_for')
->get();
}
}