added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system.

This commit is contained in:
Codex Agent
2025-11-10 16:23:09 +01:00
parent ba9e64dfcb
commit 447a90a742
123 changed files with 6398 additions and 153 deletions

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Models\EventMediaAsset;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class CheckUploadQueuesCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:check-upload-queues {--force : Execute even if another health check is running}';
protected $description = 'Inspect upload-related queues and flag stalled or overloaded workers.';
public function handle(QueueManager $queueManager): int
{
$lockSeconds = (int) config('storage-monitor.queue_health.lock_seconds', 120);
$lock = $this->acquireCommandLock('storage:queue-health', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('Queue health check already running.');
return self::SUCCESS;
}
$connection = config('queue.default');
$thresholds = config('storage-monitor.queue_health.thresholds', []);
if (empty($thresholds)) {
$thresholds = [
'default' => ['warning' => 100, 'critical' => 300],
'media-storage' => ['warning' => 200, 'critical' => 500],
'media-security' => ['warning' => 50, 'critical' => 150],
];
}
try {
$queueSummaries = [];
$alerts = [];
foreach ($thresholds as $queueName => $limits) {
$size = $this->readQueueSize($queueManager, $connection, (string) $queueName);
$failed = $this->countFailedJobs((string) $queueName);
$severity = $this->determineQueueSeverity($size, $limits);
if ($severity !== 'ok') {
$alerts[] = [
'queue' => $queueName,
'type' => 'size',
'severity' => $severity,
'size' => $size,
];
}
if ($failed > 0) {
$alerts[] = [
'queue' => $queueName,
'type' => 'failed_jobs',
'severity' => $failed >= 10 ? 'critical' : 'warning',
'failed' => $failed,
];
}
$queueSummaries[] = [
'queue' => $queueName,
'size' => $size,
'failed' => $failed,
'severity' => $severity,
'limits' => $limits,
];
}
$stalledMinutes = max(0, (int) config('storage-monitor.queue_health.stalled_minutes', 10));
$stalledAssets = 0;
if ($stalledMinutes > 0) {
$stalledAssets = EventMediaAsset::query()
->where('status', 'pending')
->where('created_at', '<=', now()->subMinutes($stalledMinutes))
->count();
if ($stalledAssets > 0) {
$alerts[] = [
'type' => 'pending_assets',
'severity' => 'warning',
'older_than_minutes' => $stalledMinutes,
'count' => $stalledAssets,
];
}
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'connection' => $connection,
'queues' => $queueSummaries,
'alerts' => $alerts,
'stalled_assets' => $stalledAssets,
];
$cacheTtl = max(1, (int) config('storage-monitor.queue_health.cache_minutes', 10));
Cache::put('storage:queue-health:last', $snapshot, now()->addMinutes($cacheTtl));
Log::channel('storage-jobs')->info('Upload queue health snapshot generated', [
'queues' => count($queueSummaries),
'alerts' => count($alerts),
]);
$this->info(sprintf(
'Checked %d queue(s); %d alert(s).',
count($queueSummaries),
count($alerts)
));
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
private function readQueueSize(QueueManager $manager, ?string $connection, string $queue): int
{
try {
return $manager->connection($connection)->size($queue);
} catch (\Throwable $exception) {
Log::channel('storage-jobs')->warning('Unable to read queue size', [
'queue' => $queue,
'connection' => $connection,
'message' => $exception->getMessage(),
]);
return -1;
}
}
private function countFailedJobs(string $queue): int
{
$table = config('queue.failed.table', 'failed_jobs');
if (! $this->failedJobsTableExists($table)) {
return 0;
}
return (int) DB::table($table)->where('queue', $queue)->count();
}
private function failedJobsTableExists(string $table): bool
{
static $cache = [];
if (array_key_exists($table, $cache)) {
return $cache[$table];
}
return $cache[$table] = Schema::hasTable($table);
}
private function determineQueueSeverity(int $size, array $limits): string
{
if ($size < 0) {
return 'unknown';
}
$critical = (int) ($limits['critical'] ?? 0);
$warning = (int) ($limits['warning'] ?? 0);
if ($critical > 0 && $size >= $critical) {
return 'critical';
}
if ($warning > 0 && $size >= $warning) {
return 'warning';
}
return 'ok';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothProvisioner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DeactivateExpiredPhotoboothAccounts extends Command
{
protected $signature = 'photobooth:cleanup-expired';
protected $description = 'Disable Photobooth FTP accounts that have passed their expiry date.';
public function handle(PhotoboothProvisioner $provisioner): int
{
$total = 0;
Event::query()
->where('photobooth_enabled', true)
->whereNotNull('photobooth_expires_at')
->where('photobooth_expires_at', '<=', now())
->chunkById(50, function ($events) use (&$total, $provisioner) {
foreach ($events as $event) {
try {
$provisioner->disable($event);
$total++;
} catch (\Throwable $exception) {
Log::error('Failed to disable expired photobooth account', [
'event_id' => $event->id,
'message' => $exception->getMessage(),
]);
}
}
});
$this->info(sprintf('Photobooth cleanup complete (%d accounts disabled).', $total));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Log;
class DispatchStorageArchiveCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:archive-pending
{--event= : Limit processing to a specific event ID}
{--force : Run even if another dispatcher instance is active}';
protected $description = 'Queue archive jobs for events whose galleries expired or were manually archived.';
public function handle(): int
{
$lockSeconds = (int) config('storage-monitor.archive.lock_seconds', 1800);
$lock = $this->acquireCommandLock('storage:archive-dispatcher', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('Another archive dispatcher run is already executing.');
return self::SUCCESS;
}
$eventLockTtl = (int) config('storage-monitor.archive.event_lock_seconds', 3600);
$graceDays = max(0, (int) config('storage-monitor.archive.grace_days', 3));
$cutoff = now()->subDays($graceDays);
$chunkSize = max(1, (int) config('storage-monitor.archive.chunk', 25));
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
$eventId = $this->option('event');
$dispatched = 0;
try {
$query = Event::query()
->with('eventPackages:id,event_id,gallery_expires_at')
->whereHas('mediaAssets', function ($builder) {
$builder->where('status', '!=', 'archived');
});
if ($eventId) {
$query->whereKey($eventId);
} else {
$query->where(function ($builder) use ($cutoff) {
$builder->where('status', 'archived')
->orWhereHas('eventPackages', function ($packages) use ($cutoff) {
$packages->whereNotNull('gallery_expires_at')
->where('gallery_expires_at', '<=', $cutoff);
});
});
}
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
foreach ($events as $event) {
if ($dispatched >= $maxDispatch) {
return false;
}
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
if ($eventLock === false) {
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
'event_id' => $event->id,
]);
continue;
}
try {
ArchiveEventMediaAssets::dispatch($event->id);
$dispatched++;
Log::channel('storage-jobs')->info('Archive job dispatched', [
'event_id' => $event->id,
'queue' => 'media-storage',
]);
} finally {
if ($eventLock instanceof Lock) {
$eventLock->release();
}
}
}
return null;
});
$this->info(sprintf('Dispatched %d archive job(s).', $dispatched));
Log::channel('storage-jobs')->info('Archive dispatch run finished', [
'dispatched' => $dispatched,
'event_limit' => $eventId,
]);
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Models\EventMediaAsset;
use App\Models\MediaStorageTarget;
use App\Services\Storage\StorageHealthService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class MonitorStorageCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:monitor {--force : Execute even if another run is still in progress}';
protected $description = 'Collect storage capacity statistics and cache them for dashboards & alerts.';
public function __construct(private readonly StorageHealthService $storageHealth)
{
parent::__construct();
}
public function handle(): int
{
$lockSeconds = (int) config('storage-monitor.monitor.lock_seconds', 300);
$lock = $this->acquireCommandLock('storage:monitor', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('storage:monitor is already running on another worker.');
return self::SUCCESS;
}
try {
$targets = MediaStorageTarget::active()->get();
if ($targets->isEmpty()) {
$this->info('No media storage targets available.');
return self::SUCCESS;
}
$assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds();
$alerts = [];
$snapshotTargets = [];
foreach ($targets as $target) {
$capacity = $this->storageHealth->getCapacity($target);
$assets = $assetStats[$target->id] ?? [
'total' => 0,
'bytes' => 0,
'by_status' => [],
];
$severity = $this->determineCapacitySeverity($capacity, $thresholds);
if ($severity !== 'ok') {
$alerts[] = [
'target' => $target->key,
'type' => 'capacity',
'severity' => $severity,
'percentage' => $capacity['percentage'] ?? null,
'status' => $capacity['status'] ?? null,
];
}
$failedCount = $assets['by_status']['failed']['count'] ?? 0;
if ($failedCount > 0) {
$alerts[] = [
'target' => $target->key,
'type' => 'failed_assets',
'severity' => 'warning',
'failed' => $failedCount,
];
}
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
'name' => $target->name,
'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity,
'assets' => $assets,
];
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets,
'alerts' => $alerts,
];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
Cache::put('storage:monitor:last', $snapshot, now()->addMinutes($ttlMinutes));
Log::channel('storage-jobs')->info('Storage monitor snapshot generated', [
'targets' => count($snapshotTargets),
'alerts' => count($alerts),
]);
$this->info(sprintf(
'Storage monitor finished: %d targets, %d alerts.',
count($snapshotTargets),
count($alerts)
));
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
/**
* @return array<int, array>
*/
private function buildAssetStatistics(): array
{
return EventMediaAsset::query()
->selectRaw('media_storage_target_id, status, COUNT(*) as total_count, COALESCE(SUM(size_bytes), 0) as total_bytes')
->groupBy('media_storage_target_id', 'status')
->get()
->groupBy('media_storage_target_id')
->map(function ($rows) {
$byStatus = [];
$totalCount = 0;
$totalBytes = 0;
foreach ($rows as $row) {
$count = (int) ($row->total_count ?? 0);
$bytes = (int) ($row->total_bytes ?? 0);
$totalCount += $count;
$totalBytes += $bytes;
$byStatus[$row->status] = [
'count' => $count,
'bytes' => $bytes,
];
}
ksort($byStatus);
return [
'total' => $totalCount,
'bytes' => $totalBytes,
'by_status' => $byStatus,
];
})
->all();
}
private function capacityThresholds(): array
{
$warning = (int) config('storage-monitor.capacity_thresholds.warning', 75);
$critical = (int) config('storage-monitor.capacity_thresholds.critical', 90);
if ($warning > $critical) {
[$warning, $critical] = [$critical, $warning];
}
return [
'warning' => $warning,
'critical' => $critical,
];
}
private function determineCapacitySeverity(array $capacity, array $thresholds): string
{
$status = $capacity['status'] ?? 'ok';
if ($status !== 'ok') {
return $status;
}
$percentage = $capacity['percentage'] ?? null;
if ($percentage === null) {
return 'unknown';
}
if ($percentage >= ($thresholds['critical'] ?? 95)) {
return 'critical';
}
if ($percentage >= ($thresholds['warning'] ?? 75)) {
return 'warning';
}
return 'ok';
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothIngestService;
use Illuminate\Console\Command;
class PhotoboothIngestCommand extends Command
{
protected $signature = 'photobooth:ingest {--event= : Restrict ingestion to a single event ID}
{--max-files= : Maximum files to import per event in this run}';
protected $description = 'Ingest pending Photobooth uploads from the FTP mount into event storage.';
public function handle(PhotoboothIngestService $ingestService): int
{
$eventId = $this->option('event');
$maxFiles = $this->option('max-files');
$processedTotal = 0;
$skippedTotal = 0;
$query = Event::query()
->where('photobooth_enabled', true)
->whereNotNull('photobooth_path');
if ($eventId) {
$query->whereKey($eventId);
}
$query->chunkById(25, function ($events) use ($ingestService, $maxFiles, &$processedTotal, &$skippedTotal) {
foreach ($events as $event) {
$summary = $ingestService->ingest($event, $maxFiles ? (int) $maxFiles : null);
$processedTotal += $summary['processed'] ?? 0;
$skippedTotal += $summary['skipped'] ?? 0;
$this->line(sprintf(
'Event #%d (%s): %d imported, %d skipped',
$event->id,
$event->slug ?? 'event',
$summary['processed'] ?? 0,
$summary['skipped'] ?? 0
));
}
});
$this->info(sprintf('Photobooth ingest finished. Processed: %d, Skipped: %d', $processedTotal, $skippedTotal));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Help\HelpSyncService;
use Illuminate\Console\Command;
class SyncHelpCenter extends Command
{
protected $signature = 'help:sync';
protected $description = 'Compile markdown help articles into cached JSON bundles.';
public function handle(HelpSyncService $service): int
{
$result = $service->sync();
foreach ($result as $audience => $locales) {
foreach ($locales as $locale => $count) {
$this->components->info(sprintf('Synced %d %s/%s articles', $count, $audience, $locale));
}
}
$this->components->success('Help center cache updated.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Concerns;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
trait InteractsWithCacheLocks
{
/**
* Attempt to acquire a cache-backed lock for the given command.
*
* @return \Illuminate\Contracts\Cache\Lock|false|null Returns the lock when acquired, false if another process holds it, null if locks are unsupported.
*/
protected function acquireCommandLock(string $name, int $seconds, bool $force = false): Lock|false|null
{
try {
$lock = Cache::lock($name, $seconds);
if ($force) {
$lock->forceRelease();
}
if ($lock->get()) {
return $lock;
}
return false;
} catch (\BadMethodCallException $exception) {
Log::channel('storage-jobs')->debug('Cache store does not support locks for command', [
'lock' => $name,
'store' => config('cache.default'),
'exception' => $exception->getMessage(),
]);
return null;
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Filament\Resources\CoolifyActionLogs;
use App\Filament\Resources\CoolifyActionLogs\Pages\ManageCoolifyActionLogs;
use App\Models\CoolifyActionLog;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class CoolifyActionLogResource extends Resource
{
protected static ?string $model = CoolifyActionLog::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static string|UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 90;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Timestamp')
->sortable()
->dateTime(),
Tables\Columns\TextColumn::make('user.name')
->label('User')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('service_id')
->label('Service')
->searchable()
->copyable()
->limit(30),
Tables\Columns\BadgeColumn::make('action')
->label('Action')
->colors([
'warning' => 'restart',
'info' => 'redeploy',
'gray' => 'logs',
])
->sortable(),
Tables\Columns\TextColumn::make('status_code')
->label('HTTP')
->sortable(),
])
->filters([
//
])
->recordActions([
Tables\Actions\ViewAction::make(),
])
->toolbarActions([
//
]);
}
public static function getPages(): array
{
return [
'index' => ManageCoolifyActionLogs::route('/'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\CoolifyActionLogs\Pages;
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
use Filament\Resources\Pages\ManageRecords;
class ManageCoolifyActionLogs extends ManageRecords
{
protected static string $resource = CoolifyActionLogResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings\Pages;
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
use Filament\Resources\Pages\EditRecord;
class EditPhotoboothSetting extends EditRecord
{
protected static string $resource = PhotoboothSettingResource::class;
protected function getHeaderActions(): array
{
return [];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings\Pages;
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
use App\Models\PhotoboothSetting;
use Filament\Resources\Pages\ListRecords;
class ListPhotoboothSettings extends ListRecords
{
protected static string $resource = PhotoboothSettingResource::class;
public function mount(): void
{
parent::mount();
PhotoboothSetting::current();
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings;
use App\Filament\Resources\PhotoboothSettings\Pages\EditPhotoboothSetting;
use App\Filament\Resources\PhotoboothSettings\Pages\ListPhotoboothSettings;
use App\Filament\Resources\PhotoboothSettings\Schemas\PhotoboothSettingForm;
use App\Filament\Resources\PhotoboothSettings\Schemas\PhotoboothSettingInfolist;
use App\Filament\Resources\PhotoboothSettings\Tables\PhotoboothSettingsTable;
use App\Models\PhotoboothSetting;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class PhotoboothSettingResource extends Resource
{
protected static ?string $model = PhotoboothSetting::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 95;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform_management');
}
public static function form(Schema $schema): Schema
{
return PhotoboothSettingForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return PhotoboothSettingInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PhotoboothSettingsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPhotoboothSettings::route('/'),
'edit' => EditPhotoboothSetting::route('/{record}/edit'),
];
}
public static function canCreate(?Model $record = null): bool
{
return false;
}
public static function canDelete(?Model $record = null): bool
{
return false;
}
public static function canDeleteAny(): bool
{
return false;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings\Schemas;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PhotoboothSettingForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make(__('FTP-Verbindung'))
->description(__('Globale Parameter für den vsftpd-Container.'))
->schema([
TextInput::make('ftp_port')
->numeric()
->required()
->minValue(1)
->maxValue(65535)
->helperText(__('Standard: Port 2121 innerhalb des internen Netzwerks.')),
TextInput::make('rate_limit_per_minute')
->label(__('Uploads pro Minute'))
->numeric()
->required()
->minValue(1)
->maxValue(200)
->helperText(__('Harte Rate-Limits für Photobooth-Clients.')),
TextInput::make('expiry_grace_days')
->label(__('Ablauf (Tage nach Eventende)'))
->numeric()
->required()
->minValue(0)
->maxValue(14),
])->columns(3),
Section::make(__('Sicherheit & Steuerung'))
->schema([
Toggle::make('require_ftps')
->label(__('FTPS erzwingen'))
->helperText(__('Aktivieren, wenn nur verschlüsselte FTP-Verbindungen erlaubt sein sollen.')),
TagsInput::make('allowed_ip_ranges')
->label(__('Erlaubte IP-Ranges (optional)'))
->placeholder('10.0.0.0/24')
->helperText(__('Liste optionaler CIDR-Ranges für Control-Service Allowlisting.')),
TextInput::make('control_service_base_url')
->label(__('Control-Service URL'))
->url()
->maxLength(191)
->helperText(__('REST-Endpunkt des Provisioning-Sidecars (z. B. http://control:8080).')),
TextInput::make('control_service_token_identifier')
->label(__('Token Referenz'))
->maxLength(191)
->helperText(__('Bezeichner des Secrets im Secrets-Store (keine Klartext-Tokens speichern).')),
])->columns(2),
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings\Schemas;
use Filament\Schemas\Schema;
class PhotoboothSettingInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
//
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\PhotoboothSettings\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class PhotoboothSettingsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('ftp_port')
->label(__('Port'))
->sortable(),
Tables\Columns\TextColumn::make('rate_limit_per_minute')
->label(__('Uploads/Minute'))
->sortable(),
Tables\Columns\TextColumn::make('expiry_grace_days')
->label(__('Ablauf +Tage'))
->sortable(),
Tables\Columns\IconColumn::make('require_ftps')
->label(__('FTPS'))
->boolean(),
Tables\Columns\TextColumn::make('updated_at')
->since()
->label(__('Aktualisiert')),
])
->recordActions([
Tables\Actions\EditAction::make(),
])
->headerActions([])
->bulkActions([]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Models\CoolifyActionLog;
use App\Services\Coolify\CoolifyClient;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Arr;
class CoolifyDeployments extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Infrastructure';
protected static ?string $title = 'Infrastructure Controls';
protected string $view = 'filament.super-admin.pages.coolify-deployments';
public array $services = [];
public array $recentLogs = [];
public ?string $coolifyWebUrl = null;
public function mount(CoolifyClient $client): void
{
$this->coolifyWebUrl = config('coolify.web_url');
$this->refreshServices($client);
$this->refreshLogs();
}
public function restart(string $serviceId): void
{
$this->performAction($serviceId, 'restart');
}
public function redeploy(string $serviceId): void
{
$this->performAction($serviceId, 'redeploy');
}
protected function performAction(string $serviceId, string $action): void
{
$client = app(CoolifyClient::class);
if (! $this->isKnownService($serviceId)) {
Notification::make()
->danger()
->title('Unknown service')
->body("The service ID {$serviceId} is not configured.")
->send();
return;
}
try {
$action === 'restart'
? $client->restartService($serviceId, auth()->user())
: $client->redeployService($serviceId, auth()->user());
Notification::make()
->success()
->title(ucfirst($action).' requested')
->body("Coolify accepted the {$action} action for {$serviceId}.")
->send();
} catch (\Throwable $exception) {
Notification::make()
->danger()
->title('Coolify request failed')
->body($exception->getMessage())
->send();
}
$this->refreshServices($client);
$this->refreshLogs();
}
protected function refreshServices(CoolifyClient $client): void
{
$serviceMap = config('coolify.services', []);
$results = [];
foreach ($serviceMap as $label => $id) {
try {
$status = $client->serviceStatus($id);
$results[] = [
'label' => ucfirst($label),
'service_id' => $id,
'status' => Arr::get($status, 'data.status', 'unknown'),
];
} catch (\Throwable $e) {
$results[] = [
'label' => ucfirst($label),
'service_id' => $id,
'status' => 'error',
];
}
}
$this->services = $results;
}
protected function refreshLogs(): void
{
$this->recentLogs = CoolifyActionLog::query()
->with('user')
->latest()
->limit(5)
->get()
->map(fn ($log) => [
'created_at' => $log->created_at->diffForHumans(),
'user' => $log->user?->name ?? 'System',
'service_id' => $log->service_id,
'action' => $log->action,
'status_code' => $log->status_code,
])
->toArray();
}
protected function isKnownService(string $serviceId): bool
{
return in_array($serviceId, array_values(config('coolify.services', [])), true);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Widgets;
use App\Services\Coolify\CoolifyClient;
use Filament\Widgets\Widget;
use Illuminate\Support\Arr;
class CoolifyPlatformHealth extends Widget
{
protected string $view = 'filament.widgets.coolify-platform-health';
protected ?string $pollingInterval = '60s';
protected function getViewData(): array
{
return [
'services' => $this->loadServices(),
];
}
protected function loadServices(): array
{
$client = app(CoolifyClient::class);
$serviceMap = config('coolify.services', []);
$results = [];
foreach ($serviceMap as $label => $serviceId) {
try {
$status = $client->serviceStatus($serviceId);
$results[] = [
'label' => ucfirst($label),
'service_id' => $serviceId,
'status' => Arr::get($status, 'data.status', 'unknown'),
'cpu' => Arr::get($status, 'data.metrics.cpu_percent'),
'memory' => Arr::get($status, 'data.metrics.memory_percent'),
'last_deploy' => Arr::get($status, 'data.last_deployment.finished_at'),
];
} catch (\Throwable $exception) {
$results[] = [
'label' => ucfirst($label),
'service_id' => $serviceId,
'status' => 'unreachable',
'error' => $exception->getMessage(),
];
}
}
if (empty($results)) {
return [
[
'label' => 'Coolify',
'service_id' => '-',
'status' => 'unconfigured',
'error' => 'Set COOLIFY_SERVICE_IDS in .env to enable monitoring.',
],
];
}
return $results;
}
}

View File

@@ -1251,6 +1251,8 @@ class EventPublicController extends BaseController
'photos.emotion_id',
'photos.task_id',
'photos.guest_name',
'photos.created_at',
'photos.ingest_source',
'tasks.title as task_title',
])
->where('photos.event_id', $eventId)
@@ -1258,7 +1260,9 @@ class EventPublicController extends BaseController
->limit(60);
// MyPhotos filter
if ($filter === 'myphotos' && $deviceId !== 'anon') {
if ($filter === 'photobooth') {
$query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH);
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
$query->where('guest_name', $deviceId);
}
@@ -1276,6 +1280,8 @@ class EventPublicController extends BaseController
$r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe');
}
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
return $r;
});
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
@@ -1495,6 +1501,7 @@ class EventPublicController extends BaseController
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId),

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Support\Help\HelpRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;
use RuntimeException;
class HelpController extends Controller
{
public function __construct(private readonly HelpRepository $repository) {}
public function index(Request $request): JsonResponse
{
[$audience, $locale] = $this->resolveContext($request);
$articles = $this->getArticles($audience, $locale)
->map(fn ($article) => Arr::only($article, config('help.list_fields')))
->values();
return response()->json([
'data' => $articles,
]);
}
public function show(Request $request, string $slug): JsonResponse
{
[$audience, $locale] = $this->resolveContext($request);
$article = $this->getArticle($audience, $locale, $slug);
abort_if(! $article, 404, 'Help article not found.');
return response()->json([
'data' => $article,
]);
}
/**
* @return array{string, string}
*/
private function resolveContext(Request $request): array
{
$this->attemptTokenAuthentication($request);
$audience = Str::of($request->string('audience', 'guest'))->lower()->value();
$locale = Str::of($request->string('locale', config('help.default_locale')))->lower()->value();
if ($audience === 'admin' && ! $request->user()) {
abort(401, 'Authentication required for admin help content.');
}
if (! in_array($audience, config('help.audiences', []), true)) {
abort(400, 'Invalid audience supplied.');
}
return [$audience, $locale];
}
private function attemptTokenAuthentication(Request $request): void
{
if ($request->user()) {
return;
}
$bearer = $request->bearerToken();
if (! $bearer) {
return;
}
$token = PersonalAccessToken::findToken($bearer);
if (! $token) {
return;
}
$user = $token->tokenable;
if (! $user) {
return;
}
if (method_exists($user, 'withAccessToken')) {
$user->withAccessToken($token);
}
Auth::setUser($user);
$request->setUserResolver(fn () => $user);
}
private function getArticles(string $audience, string $locale)
{
try {
return $this->repository->list($audience, $locale);
} catch (RuntimeException $e) {
$fallback = config('help.fallback_locale');
if ($locale === $fallback) {
throw $e;
}
return $this->repository->list($audience, $fallback);
}
}
private function getArticle(string $audience, string $locale, string $slug): ?array
{
try {
$article = $this->repository->find($audience, $locale, $slug);
} catch (RuntimeException $e) {
$fallback = config('help.fallback_locale');
if ($locale !== $fallback) {
return $this->repository->find($audience, $fallback, $slug);
}
throw $e;
}
return $article;
}
}

View File

@@ -153,14 +153,12 @@ class PhotoController extends Controller
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
$photoAttributes = [
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'file_path' => $path,
'thumbnail_path' => $thumbnailPath,
'width' => null, // Filled below
'height' => null,
@@ -168,7 +166,17 @@ class PhotoController extends Controller
'uploader_id' => null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
];
if (Photo::supportsFilenameColumn()) {
$photoAttributes['filename'] = $filename;
}
if (Photo::hasColumn('path')) {
$photoAttributes['path'] = $path;
}
$photo = Photo::create($photoAttributes);
// Record primary asset metadata
$checksum = hash_file('sha256', $file->getRealPath());
@@ -663,19 +671,27 @@ class PhotoController extends Controller
$thumbnailPath = $thumbnailRelative;
}
// Create photo record
$photo = Photo::create([
$photoAttributes = [
'event_id' => $event->id,
'filename' => $filename,
'original_name' => $request->original_name,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'path' => $path,
'file_path' => $path,
'thumbnail_path' => $thumbnailPath,
'status' => 'pending',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
'ingest_source' => Photo::SOURCE_TENANT_ADMIN,
];
if (Photo::supportsFilenameColumn()) {
$photoAttributes['filename'] = $filename;
}
if (Photo::hasColumn('path')) {
$photoAttributes['path'] = $path;
}
$photo = Photo::create($photoAttributes);
[$width, $height] = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PhotoboothController extends Controller
{
public function __construct(private readonly PhotoboothProvisioner $provisioner) {}
public function show(Request $request, Event $event): PhotoboothStatusResource
{
$this->assertEventBelongsToTenant($request, $event);
return $this->resource($event);
}
public function enable(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->enable($event);
return response()->json([
'message' => __('Photobooth-Zugang aktiviert.'),
'data' => $this->resource($updated),
]);
}
public function rotate(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->rotate($event);
return response()->json([
'message' => __('Zugangsdaten neu generiert.'),
'data' => $this->resource($updated),
]);
}
public function disable(Request $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('tenant');
$updated = $this->provisioner->disable($event);
return response()->json([
'message' => __('Photobooth-Zugang deaktiviert.'),
'data' => $this->resource($updated),
]);
}
protected function resource(Event $event): PhotoboothStatusResource
{
return PhotoboothStatusResource::make([
'event' => $event->fresh(),
'settings' => PhotoboothSetting::current(),
]);
}
protected function assertEventBelongsToTenant(Request $request, Event $event): void
{
$tenantId = (int) $request->attributes->get('tenant_id');
if ($tenantId !== (int) $event->tenant_id) {
abort(403, 'Event gehört nicht zu diesem Tenant.');
}
}
}

View File

@@ -34,6 +34,7 @@ class PhotoResource extends JsonResource
'is_liked' => false,
'uploaded_at' => $this->created_at->toISOString(),
'uploader_name' => $this->guest_name ?? null,
'ingest_source' => $this->ingest_source,
'event' => [
'id' => $this->event->id,
'name' => $this->event->name,
@@ -57,4 +58,4 @@ class PhotoResource extends JsonResource
{
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources\Tenant;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PhotoboothStatusResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$payload = $this->resolvePayload();
/** @var Event $event */
$event = $payload['event'];
/** @var PhotoboothSetting $settings */
$settings = $payload['settings'];
$password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password;
return [
'enabled' => (bool) $event->photobooth_enabled,
'status' => $event->photobooth_status,
'username' => $event->photobooth_username,
'password' => $password,
'path' => $event->photobooth_path,
'ftp_url' => $this->buildFtpUrl($event, $settings, $password),
'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(),
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
'ftp' => [
'host' => config('photobooth.ftp.host'),
'port' => $settings->ftp_port,
'require_ftps' => (bool) $settings->require_ftps,
],
];
}
/**
* @return array{event: Event, settings: PhotoboothSetting}
*/
protected function resolvePayload(): array
{
$resource = $this->resource;
if ($resource instanceof Event) {
return [
'event' => $resource,
'settings' => PhotoboothSetting::current(),
];
}
return [
'event' => $resource['event'] ?? $resource,
'settings' => $resource['settings'] ?? PhotoboothSetting::current(),
];
}
protected function buildFtpUrl(Event $event, PhotoboothSetting $settings, ?string $password): ?string
{
$host = config('photobooth.ftp.host');
$username = $event->photobooth_username;
if (! $host || ! $username || ! $password) {
return null;
}
$scheme = $settings->require_ftps ? 'ftps' : 'ftp';
$port = $settings->ftp_port ?: config('photobooth.ftp.port', 21);
return sprintf('%s://%s:%s@%s:%d', $scheme, $username, $password, $host, $port);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CoolifyActionLog extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'response' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -3,11 +3,13 @@
namespace App\Models;
use App\Services\EventJoinTokenService;
use Illuminate\Contracts\Encryption\DecryptException;
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\Facades\Crypt;
class Event extends Model
{
@@ -22,6 +24,13 @@ class Event extends Model
'is_active' => 'boolean',
'name' => 'array',
'description' => 'array',
'photobooth_enabled' => 'boolean',
'photobooth_expires_at' => 'datetime',
'photobooth_metadata' => 'array',
];
protected $hidden = [
'photobooth_password_encrypted',
];
protected static function booted(): void
@@ -159,4 +168,26 @@ class Event extends Model
$this->attributes['settings'] = json_encode($value ?? []);
}
public function getPhotoboothPasswordAttribute(): ?string
{
$encrypted = $this->attributes['photobooth_password_encrypted'] ?? null;
if (! $encrypted) {
return null;
}
try {
return Crypt::decryptString($encrypted);
} catch (DecryptException) {
return null;
}
}
public function setPhotoboothPasswordAttribute(?string $value): void
{
$this->attributes['photobooth_password_encrypted'] = $value
? Crypt::encryptString($value)
: null;
}
}

View File

@@ -6,17 +6,29 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\EventMediaAsset;
use Illuminate\Support\Facades\Schema;
use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation;
use Znck\Eloquent\Traits\BelongsToThrough;
class Photo extends Model
{
use HasFactory;
use BelongsToThrough;
use HasFactory;
public const SOURCE_GUEST_PWA = 'guest_pwa';
public const SOURCE_TENANT_ADMIN = 'tenant_admin';
public const SOURCE_PHOTOBOOTH = 'photobooth';
public const SOURCE_UNKNOWN = 'unknown';
protected static ?array $columnCache = null;
protected $table = 'photos';
protected $guarded = [];
protected $casts = [
'is_featured' => 'boolean',
'metadata' => 'array',
@@ -26,6 +38,7 @@ class Photo extends Model
protected $attributes = [
'security_scan_status' => 'pending',
'ingest_source' => self::SOURCE_GUEST_PWA,
];
public function mediaAsset(): BelongsTo
@@ -63,6 +76,20 @@ class Photo extends Model
return $this->hasMany(PhotoLike::class);
}
public static function supportsFilenameColumn(): bool
{
return static::hasColumn('filename');
}
public static function hasColumn(string $column): bool
{
if (static::$columnCache === null) {
static::$columnCache = Schema::getColumnListing((new self)->getTable());
}
return in_array($column, static::$columnCache, true);
}
public function tenant(): BelongsToThroughRelation
{
return $this->belongsToThrough(

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class PhotoboothSetting extends Model
{
protected $guarded = [];
protected $casts = [
'require_ftps' => 'boolean',
'allowed_ip_ranges' => 'array',
];
protected static function booted(): void
{
static::saved(fn () => static::flushCache());
static::deleted(fn () => static::flushCache());
}
public static function current(): self
{
return Cache::remember('photobooth.settings', now()->addMinutes(10), function () {
$defaults = [
'ftp_port' => config('photobooth.ftp.port', 2121),
'rate_limit_per_minute' => config('photobooth.rate_limit_per_minute', 20),
'expiry_grace_days' => config('photobooth.expiry_grace_days', 1),
'require_ftps' => false,
'allowed_ip_ranges' => null,
'control_service_base_url' => config('photobooth.control_service.base_url'),
'control_service_token_identifier' => null,
];
return static::query()->firstOrCreate(['id' => 1], $defaults);
});
}
public static function flushCache(): void
{
Cache::forget('photobooth.settings');
}
public function ftpHost(): ?string
{
return config('photobooth.ftp.host');
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Providers\Filament;
use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Blog\Resources\PostResource;
use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource;
use App\Filament\Resources\LegalPageResource;
use App\Filament\Widgets\CoolifyPlatformHealth;
use App\Filament\Widgets\CreditAlertsWidget;
use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\RevenueTrendWidget;
@@ -59,6 +61,7 @@ class SuperAdminPanelProvider extends PanelProvider
TopTenantsByRevenue::class,
TopTenantsByUploads::class,
\App\Filament\Widgets\StorageCapacityWidget::class,
CoolifyPlatformHealth::class,
])
->middleware([
EncryptCookies::class,
@@ -84,6 +87,7 @@ class SuperAdminPanelProvider extends PanelProvider
PostResource::class,
CategoryResource::class,
LegalPageResource::class,
CoolifyActionLogResource::class,
])
->authGuard('web');

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Services\Coolify;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CoolifyClient
{
public function __construct(private readonly HttpFactory $http) {}
public function serviceStatus(string $serviceId): array
{
return $this->cached("coolify.service.$serviceId", fn () => $this->get("/services/{$serviceId}"), 30);
}
public function recentDeployments(string $serviceId, int $limit = 5): array
{
return $this->cached("coolify.deployments.$serviceId", function () use ($serviceId, $limit) {
$response = $this->get("/services/{$serviceId}/deployments?per_page={$limit}");
return Arr::get($response, 'data', []);
}, 60);
}
public function restartService(string $serviceId, ?Authenticatable $actor = null): array
{
return $this->dispatchAction($serviceId, 'restart', function () use ($serviceId) {
return $this->post("/services/{$serviceId}/actions/restart");
}, $actor);
}
public function redeployService(string $serviceId, ?Authenticatable $actor = null): array
{
return $this->dispatchAction($serviceId, 'redeploy', function () use ($serviceId) {
return $this->post("/services/{$serviceId}/actions/redeploy");
}, $actor);
}
protected function cached(string $key, callable $callback, int $seconds): mixed
{
return Cache::remember($key, now()->addSeconds($seconds), $callback);
}
protected function get(string $path): array
{
$response = $this->request()->get($path);
if ($response->failed()) {
$this->logFailure('GET', $path, $response);
throw new RequestException($response);
}
return $response->json() ?? [];
}
protected function post(string $path, array $payload = []): array
{
$response = $this->request()->post($path, $payload);
if ($response->failed()) {
$this->logFailure('POST', $path, $response);
throw new RequestException($response);
}
return $response->json() ?? [];
}
protected function request(): PendingRequest
{
$baseUrl = config('coolify.api.base_url');
$token = config('coolify.api.token');
$timeout = config('coolify.api.timeout', 5);
if (! $baseUrl || ! $token) {
throw new \RuntimeException('Coolify API is not configured.');
}
return $this->http
->baseUrl($baseUrl)
->timeout($timeout)
->acceptJson()
->withToken($token);
}
protected function logFailure(string $method, string $path, \Illuminate\Http\Client\Response $response): void
{
Log::error('[Coolify] API request failed', [
'method' => $method,
'path' => $path,
'status' => $response->status(),
'body' => $response->body(),
]);
}
protected function dispatchAction(string $serviceId, string $action, callable $callback, ?Authenticatable $actor = null): array
{
$payload = [];
try {
$response = $callback();
} catch (\Throwable $exception) {
$this->logAction($serviceId, $action, $payload, [
'error' => $exception->getMessage(),
], null, $actor);
throw $exception;
}
$this->logAction($serviceId, $action, $payload, $response, $response['status'] ?? null, $actor);
return $response;
}
protected function logAction(
string $serviceId,
string $action,
array $payload,
array $response,
?int $status,
?Authenticatable $actor = null,
): void {
CoolifyActionLog::create([
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
'service_id' => $serviceId,
'action' => $action,
'payload' => $payload,
'response' => $response,
'status_code' => $status,
]);
}
}
use App\Models\CoolifyActionLog;
use Illuminate\Contracts\Auth\Authenticatable;

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Services\Help;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use RuntimeException;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Yaml\Yaml;
class HelpSyncService
{
private MarkdownConverter $converter;
public function __construct(private readonly Filesystem $files)
{
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$this->converter = new MarkdownConverter($environment);
}
/**
* @return array<string, array<string, int>>
*/
public function sync(): array
{
$sourcePath = base_path(config('help.source_path'));
if (! $this->files->exists($sourcePath)) {
throw new RuntimeException('Help source directory not found: '.$sourcePath);
}
$articles = collect();
foreach (config('help.audiences', []) as $audience) {
$audiencePath = $sourcePath.DIRECTORY_SEPARATOR.$audience;
if (! $this->files->isDirectory($audiencePath)) {
continue;
}
$files = $this->files->allFiles($audiencePath);
/** @var SplFileInfo $file */
foreach ($files as $file) {
if ($file->getExtension() !== 'md') {
continue;
}
$parsed = $this->parseFile($file);
$articles->push($parsed);
}
}
$disk = config('help.disk');
$compiledPath = trim(config('help.compiled_path'), '/');
$written = [];
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
[$audience, $locale] = explode('::', $key);
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
Cache::forget($this->cacheKey($audience, $locale));
$written[$audience][$locale] = $group->count();
}
return $written;
}
private function parseFile(SplFileInfo $file): array
{
$contents = $this->files->get($file->getPathname());
if (! Str::startsWith($contents, "---\n")) {
throw new RuntimeException('Missing front matter in '.$file->getPathname());
}
$pattern = '/^---\s*\n(?P<yaml>.*?)-{3}\s*\n(?P<body>.*)$/s';
if (! preg_match($pattern, $contents, $matches)) {
throw new RuntimeException('Unable to parse front matter for '.$file->getPathname());
}
$frontMatter = Yaml::parse(trim($matches['yaml'] ?? '')) ?? [];
$frontMatter = array_map(static fn ($value) => $value ?? null, $frontMatter);
$this->validateFrontMatter($frontMatter, $file->getPathname());
$body = trim($matches['body'] ?? '');
$html = trim($this->converter->convert($body)->getContent());
$updatedAt = now()->setTimestamp($file->getMTime())->toISOString();
return array_merge($frontMatter, [
'body_markdown' => $body,
'body_html' => $html,
'source_path' => $file->getRelativePathname(),
'updated_at' => $updatedAt,
]);
}
private function validateFrontMatter(array $frontMatter, string $path): void
{
$required = config('help.required_front_matter', []);
foreach ($required as $key) {
if (! Arr::exists($frontMatter, $key)) {
throw new RuntimeException(sprintf('Missing front matter key "%s" in %s', $key, $path));
}
}
$audiences = config('help.audiences', []);
$audience = $frontMatter['audience'];
if (! in_array($audience, $audiences, true)) {
throw new RuntimeException(sprintf('Invalid audience "%s" in %s', $audience, $path));
}
}
private function cacheKey(string $audience, string $locale): string
{
return sprintf('help.%s.%s', $audience, $locale);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services\Photobooth;
use App\Models\PhotoboothSetting;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ControlServiceClient
{
public function __construct(private readonly HttpFactory $httpFactory) {}
public function provisionUser(array $payload, ?PhotoboothSetting $settings = null): array
{
return $this->send('post', '/users', $payload, $settings);
}
public function rotateUser(string $username, array $payload = [], ?PhotoboothSetting $settings = null): array
{
return $this->send('post', "/users/{$username}/rotate", $payload, $settings);
}
public function deleteUser(string $username, ?PhotoboothSetting $settings = null): array
{
return $this->send('delete', "/users/{$username}", [], $settings);
}
public function syncConfig(array $payload, ?PhotoboothSetting $settings = null): array
{
return $this->send('post', '/config', $payload, $settings);
}
protected function send(string $method, string $path, array $payload = [], ?PhotoboothSetting $settings = null): array
{
$response = $this->request($settings)->{$method}($path, $payload);
if ($response->failed()) {
$message = sprintf('Photobooth control request failed for %s', $path);
Log::error($message, [
'path' => $path,
'payload' => Arr::except($payload, ['password']),
'status' => $response->status(),
'body' => $response->json() ?? $response->body(),
]);
throw new RequestException($response);
}
return $response->json() ?? [];
}
protected function request(?PhotoboothSetting $settings = null): PendingRequest
{
$settings ??= PhotoboothSetting::current();
$baseUrl = $settings->control_service_base_url ?? config('photobooth.control_service.base_url');
$token = config('photobooth.control_service.token');
if (! $baseUrl || ! $token) {
throw new RuntimeException('Photobooth control service is not configured.');
}
$timeout = config('photobooth.control_service.timeout', 5);
return $this->httpFactory
->baseUrl($baseUrl)
->timeout($timeout)
->withToken($token)
->acceptJson();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services\Photobooth;
use App\Models\Event;
class CredentialGenerator
{
public function __construct(
private readonly int $usernameLength = 8,
private readonly int $passwordLength = 8,
private readonly string $usernamePrefix = 'pb'
) {}
public function generateUsername(Event $event): string
{
$maxLength = min(10, max(6, $this->usernameLength));
$prefix = substr($this->usernamePrefix, 0, $maxLength - 3);
$tenantMarker = strtoupper(substr($event->tenant?->slug ?? $event->tenant?->name ?? 'x', 0, 1));
$remaining = $maxLength - strlen($prefix) - 1;
$randomSegment = $this->randomSegment(max(3, $remaining));
return strtoupper($prefix.$tenantMarker.substr($randomSegment, 0, $remaining));
}
public function generatePassword(): string
{
$length = min(8, max(6, $this->passwordLength));
return $this->randomSegment($length);
}
protected function randomSegment(int $length): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$poolSize = strlen($alphabet);
$value = '';
for ($i = 0; $i < $length; $i++) {
$value .= $alphabet[random_int(0, $poolSize - 1)];
}
return $value;
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Services\Photobooth;
use App\Jobs\ProcessPhotoSecurityScan;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Photo;
use App\Services\Packages\PackageLimitEvaluator;
use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ImageHelper;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PhotoboothIngestService
{
private bool $hasFilenameColumn;
private bool $hasPathColumn;
public function __construct(
private readonly EventStorageManager $storageManager,
private readonly PackageLimitEvaluator $packageLimitEvaluator,
private readonly PackageUsageTracker $packageUsageTracker,
) {
$this->hasFilenameColumn = Photo::supportsFilenameColumn();
$this->hasPathColumn = Schema::hasColumn('photos', 'path');
}
public function ingest(Event $event, ?int $maxFiles = null): array
{
$tenant = $event->tenant;
if (! $tenant) {
return ['processed' => 0, 'skipped' => 0];
}
$importDisk = config('photobooth.import.disk', 'photobooth');
$basePath = ltrim((string) $event->photobooth_path, '/');
if (str_starts_with($basePath, 'photobooth/')) {
$basePath = substr($basePath, strlen('photobooth/'));
}
$disk = Storage::disk($importDisk);
if ($basePath === '' || ! $disk->directoryExists($basePath)) {
return ['processed' => 0, 'skipped' => 0];
}
$allowedExtensions = config('photobooth.import.allowed_extensions', ['jpg', 'jpeg', 'png', 'webp']);
$limit = $maxFiles ?? (int) config('photobooth.import.max_files_per_run', 50);
$files = collect($disk->files($basePath))
->filter(fn ($file) => $this->isAllowedFile($file, $allowedExtensions))
->sort()
->take($limit);
if ($files->isEmpty()) {
return ['processed' => 0, 'skipped' => 0];
}
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget']);
$tenant->refresh();
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
if ($violation !== null) {
Log::warning('[Photobooth] Upload blocked due to package violation', [
'event_id' => $event->id,
'code' => $violation['code'],
]);
return ['processed' => 0, 'skipped' => $files->count()];
}
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event);
$disk = $this->storageManager->getHotDiskForEvent($event);
$processed = 0;
$skipped = 0;
foreach ($files as $file) {
if ($this->reachedPhotoLimit($eventPackage)) {
break;
}
try {
$result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file);
if ($result) {
$processed++;
Storage::disk($importDisk)->delete($file);
} else {
$skipped++;
}
} catch (\Throwable $exception) {
$skipped++;
Log::error('[Photobooth] Failed to ingest file', [
'event_id' => $event->id,
'file' => $file,
'message' => $exception->getMessage(),
]);
}
}
return ['processed' => $processed, 'skipped' => $skipped];
}
protected function importFile(
Event $event,
?EventPackage $eventPackage,
string $destinationDisk,
string $importDisk,
string $file,
): bool {
$stream = Storage::disk($importDisk)->readStream($file);
if (! $stream) {
return false;
}
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'jpg');
$filename = Str::uuid().'.'.$extension;
$eventSlug = $event->slug ?? 'event-'.$event->id;
$destinationPath = "events/{$eventSlug}/photos/{$filename}";
try {
Storage::disk($destinationDisk)->put($destinationPath, $stream);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82);
$thumbnailToStore = $thumbnailRelative ?? $destinationPath;
$size = Storage::disk($destinationDisk)->size($destinationPath);
$mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg';
$originalName = basename($file);
$photo = null;
DB::transaction(function () use (
&$photo,
$event,
$eventPackage,
$destinationDisk,
$destinationPath,
$thumbnailRelative,
$thumbnailToStore,
$mimeType,
$size,
$filename,
$originalName,
) {
$payload = [
'event_id' => $event->id,
'emotion_id' => $this->resolveEmotionId($event),
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
'file_path' => $destinationPath,
'thumbnail_path' => $thumbnailToStore,
'status' => 'pending',
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
'ip_address' => null,
];
if ($this->hasFilenameColumn) {
$payload['filename'] = $filename;
}
if ($this->hasPathColumn) {
$payload['path'] = $destinationPath;
}
$photo = Photo::create($payload);
$asset = $this->storageManager->recordAsset($event, $destinationDisk, $destinationPath, [
'variant' => 'original',
'mime_type' => $mimeType,
'size_bytes' => $size,
'checksum' => null,
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
]);
if ($thumbnailRelative) {
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
'variant' => 'thumbnail',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
]);
}
$photo->update(['media_asset_id' => $asset->id]);
$dimensions = @getimagesize(Storage::disk($destinationDisk)->path($destinationPath));
if ($dimensions !== false) {
$photo->update([
'width' => Arr::get($dimensions, 0),
'height' => Arr::get($dimensions, 1),
]);
}
if ($eventPackage) {
$previousUsed = $eventPackage->used_photos;
$eventPackage->increment('used_photos');
$eventPackage->refresh();
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
}
});
ProcessPhotoSecurityScan::dispatch($photo->id);
return true;
}
protected function isAllowedFile(string $file, array $extensions): bool
{
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: '');
return $extension !== '' && in_array($extension, $extensions, true);
}
protected function reachedPhotoLimit(?EventPackage $eventPackage): bool
{
if (! $eventPackage || ! $eventPackage->package) {
return false;
}
$limit = $eventPackage->package->max_photos;
return $limit !== null
&& $limit > 0
&& $eventPackage->used_photos >= $limit;
}
protected function resolveEmotionId(Event $event): ?int
{
if (! Photo::hasColumn('emotion_id')) {
return null;
}
$existing = $event->photos()->value('emotion_id');
if ($existing) {
return $existing;
}
return Emotion::query()->value('id');
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services\Photobooth;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PhotoboothProvisioner
{
public function __construct(
private readonly ControlServiceClient $client,
private readonly CredentialGenerator $credentialGenerator
) {}
public function enable(Event $event, ?PhotoboothSetting $settings = null): Event
{
$settings ??= PhotoboothSetting::current();
$event->loadMissing('tenant');
return DB::transaction(function () use ($event, $settings) {
$username = $this->generateUniqueUsername($event, $settings);
$password = $this->credentialGenerator->generatePassword();
$path = $this->buildPath($event);
$expiresAt = $this->resolveExpiry($event, $settings);
$payload = [
'username' => $username,
'password' => $password,
'path' => $path,
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
'expires_at' => $expiresAt?->toIso8601String(),
'ftp_port' => $settings->ftp_port,
];
$this->client->provisionUser($payload, $settings);
$event->forceFill([
'photobooth_enabled' => true,
'photobooth_username' => $username,
'photobooth_password' => $password,
'photobooth_path' => $path,
'photobooth_expires_at' => $expiresAt,
'photobooth_status' => 'active',
'photobooth_last_provisioned_at' => now(),
'photobooth_metadata' => [
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
],
])->save();
return tap($event->refresh(), function (Event $refreshed) use ($password) {
$refreshed->setAttribute('plain_photobooth_password', $password);
});
});
}
public function rotate(Event $event, ?PhotoboothSetting $settings = null): Event
{
$settings ??= PhotoboothSetting::current();
if (! $event->photobooth_enabled || ! $event->photobooth_username) {
return $this->enable($event, $settings);
}
return DB::transaction(function () use ($event, $settings) {
$password = $this->credentialGenerator->generatePassword();
$expiresAt = $this->resolveExpiry($event, $settings);
$payload = [
'password' => $password,
'expires_at' => $expiresAt?->toIso8601String(),
'rate_limit_per_minute' => $settings->rate_limit_per_minute,
];
$this->client->rotateUser($event->photobooth_username, $payload, $settings);
$event->forceFill([
'photobooth_password' => $password,
'photobooth_expires_at' => $expiresAt,
'photobooth_status' => 'active',
'photobooth_last_provisioned_at' => now(),
])->save();
return tap($event->refresh(), function (Event $refreshed) use ($password) {
$refreshed->setAttribute('plain_photobooth_password', $password);
});
});
}
public function disable(Event $event, ?PhotoboothSetting $settings = null): Event
{
if (! $event->photobooth_username) {
return $event;
}
$settings ??= PhotoboothSetting::current();
return DB::transaction(function () use ($event, $settings) {
try {
$this->client->deleteUser($event->photobooth_username, $settings);
} catch (\Throwable $exception) {
Log::warning('Photobooth account deletion failed', [
'event_id' => $event->id,
'username' => $event->photobooth_username,
'message' => $exception->getMessage(),
]);
}
$event->forceFill([
'photobooth_enabled' => false,
'photobooth_status' => 'inactive',
'photobooth_username' => null,
'photobooth_password' => null,
'photobooth_path' => null,
'photobooth_expires_at' => null,
'photobooth_last_deprovisioned_at' => now(),
])->save();
return $event->refresh();
});
}
protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface
{
$eventEnd = $event->date ? Carbon::parse($event->date) : now();
$graceDays = max(0, (int) $settings->expiry_grace_days);
return $eventEnd->copy()
->endOfDay()
->addDays($graceDays);
}
protected function generateUniqueUsername(Event $event, PhotoboothSetting $settings): string
{
$maxAttempts = 10;
for ($i = 0; $i < $maxAttempts; $i++) {
$username = $this->credentialGenerator->generateUsername($event);
$exists = Event::query()
->where('photobooth_username', $username)
->whereKeyNot($event->getKey())
->exists();
if (! $exists) {
return strtolower($username);
}
}
return strtolower('pb'.Str::random(5));
}
protected function buildPath(Event $event): string
{
$tenantKey = $event->tenant?->slug ?? $event->tenant_id;
return trim((string) $tenantKey, '/').'/'.$event->getKey();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Support\Help;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class HelpRepository
{
public function __construct(private readonly CacheRepository $cache) {}
public function list(string $audience, string $locale): Collection
{
$key = $this->cacheKey($audience, $locale);
return $this->cache->remember($key, now()->addMinutes(10), fn () => $this->load($audience, $locale));
}
public function find(string $audience, string $locale, string $slug): ?array
{
return $this->list($audience, $locale)
->first(fn ($article) => Arr::get($article, 'slug') === $slug);
}
private function load(string $audience, string $locale): Collection
{
$disk = config('help.disk');
$compiledPath = trim(config('help.compiled_path'), '/');
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
if (! Storage::disk($disk)->exists($path)) {
throw new RuntimeException(sprintf('Help cache missing for %s/%s. Run help:sync.', $audience, $locale));
}
try {
$contents = Storage::disk($disk)->get($path);
} catch (FileNotFoundException $e) {
throw new RuntimeException($e->getMessage());
}
$decoded = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
return collect($decoded);
}
private function cacheKey(string $audience, string $locale): string
{
return sprintf('help.%s.%s', $audience, $locale);
}
}