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,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();
}
}