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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user