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:
184
app/Console/Commands/CheckUploadQueuesCommand.php
Normal file
184
app/Console/Commands/CheckUploadQueuesCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php
Normal file
42
app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/DispatchStorageArchiveCommand.php
Normal file
106
app/Console/Commands/DispatchStorageArchiveCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
app/Console/Commands/MonitorStorageCommand.php
Normal file
194
app/Console/Commands/MonitorStorageCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
51
app/Console/Commands/PhotoboothIngestCommand.php
Normal file
51
app/Console/Commands/PhotoboothIngestCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/SyncHelpCenter.php
Normal file
28
app/Console/Commands/SyncHelpCenter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Concerns/InteractsWithCacheLocks.php
Normal file
40
app/Console/Concerns/InteractsWithCacheLocks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
//
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
127
app/Filament/SuperAdmin/Pages/CoolifyDeployments.php
Normal file
127
app/Filament/SuperAdmin/Pages/CoolifyDeployments.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
app/Filament/Widgets/CoolifyPlatformHealth.php
Normal file
62
app/Filament/Widgets/CoolifyPlatformHealth.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
129
app/Http/Controllers/Api/HelpController.php
Normal file
129
app/Http/Controllers/Api/HelpController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
79
app/Http/Controllers/Api/Tenant/PhotoboothController.php
Normal file
79
app/Http/Controllers/Api/Tenant/PhotoboothController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
app/Http/Resources/Tenant/PhotoboothStatusResource.php
Normal file
76
app/Http/Resources/Tenant/PhotoboothStatusResource.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
app/Models/CoolifyActionLog.php
Normal file
24
app/Models/CoolifyActionLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
49
app/Models/PhotoboothSetting.php
Normal file
49
app/Models/PhotoboothSetting.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
138
app/Services/Coolify/CoolifyClient.php
Normal file
138
app/Services/Coolify/CoolifyClient.php
Normal 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;
|
||||
129
app/Services/Help/HelpSyncService.php
Normal file
129
app/Services/Help/HelpSyncService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
75
app/Services/Photobooth/ControlServiceClient.php
Normal file
75
app/Services/Photobooth/ControlServiceClient.php
Normal 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();
|
||||
}
|
||||
}
|
||||
46
app/Services/Photobooth/CredentialGenerator.php
Normal file
46
app/Services/Photobooth/CredentialGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
263
app/Services/Photobooth/PhotoboothIngestService.php
Normal file
263
app/Services/Photobooth/PhotoboothIngestService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
163
app/Services/Photobooth/PhotoboothProvisioner.php
Normal file
163
app/Services/Photobooth/PhotoboothProvisioner.php
Normal 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();
|
||||
}
|
||||
}
|
||||
54
app/Support/Help/HelpRepository.php
Normal file
54
app/Support/Help/HelpRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user