- Added public gallery API with token-expiry enforcement, branding payload, cursor pagination, and per-photo download stream (app/Http/Controllers/Api/EventPublicController.php:1, routes/api.php:16). 410 is returned when the package gallery duration has lapsed.
- Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1).
- Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1).
- Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1).
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@@ -17,6 +18,9 @@ use App\Services\Storage\EventStorageManager;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Models\Photo;
|
||||
use App\Models\EventMediaAsset;
|
||||
|
||||
class EventPublicController extends BaseController
|
||||
{
|
||||
@@ -103,6 +107,45 @@ class EventPublicController extends BaseController
|
||||
return [$event, $joinToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return JsonResponse|array{0: Event, 1: EventJoinToken}
|
||||
*/
|
||||
private function resolveGalleryEvent(Request $request, string $token): JsonResponse|array
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$eventRecord, $joinToken] = $result;
|
||||
|
||||
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
|
||||
|
||||
if (! $event) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'event_not_found',
|
||||
'message' => 'The event associated with this gallery could not be located.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||
|
||||
if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'gallery_expired',
|
||||
'message' => 'The gallery is no longer available for this event.',
|
||||
'expired_at' => $expiresAt->toIso8601String(),
|
||||
],
|
||||
], Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
return [$event, $joinToken];
|
||||
}
|
||||
|
||||
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
|
||||
{
|
||||
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
|
||||
@@ -175,6 +218,251 @@ class EventPublicController extends BaseController
|
||||
|
||||
return $path; // fallback as-is
|
||||
}
|
||||
|
||||
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null;
|
||||
|
||||
return $primary ?? (reset($value) ?: $fallback);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function buildGalleryBranding(Event $event): array
|
||||
{
|
||||
$defaultPrimary = '#f43f5e';
|
||||
$defaultSecondary = '#fb7185';
|
||||
$defaultBackground = '#ffffff';
|
||||
|
||||
$eventBranding = Arr::get($event->settings, 'branding', []);
|
||||
$tenantBranding = Arr::get($event->tenant?->settings, 'branding', []);
|
||||
|
||||
return [
|
||||
'primary_color' => Arr::get($eventBranding, 'primary_color')
|
||||
?? Arr::get($tenantBranding, 'primary_color')
|
||||
?? $defaultPrimary,
|
||||
'secondary_color' => Arr::get($eventBranding, 'secondary_color')
|
||||
?? Arr::get($tenantBranding, 'secondary_color')
|
||||
?? $defaultSecondary,
|
||||
'background_color' => Arr::get($eventBranding, 'background_color')
|
||||
?? Arr::get($tenantBranding, 'background_color')
|
||||
?? $defaultBackground,
|
||||
];
|
||||
}
|
||||
|
||||
private function encodeGalleryCursor(Photo $photo): string
|
||||
{
|
||||
return base64_encode(json_encode([
|
||||
'id' => $photo->id,
|
||||
'created_at' => $photo->created_at?->toIso8601String(),
|
||||
]));
|
||||
}
|
||||
|
||||
private function decodeGalleryCursor(?string $cursor): ?array
|
||||
{
|
||||
if (! $cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode(base64_decode($cursor, true) ?: '', true);
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
'id' => (int) $decoded['id'],
|
||||
'created_at' => Carbon::parse($decoded['created_at']),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function makeGalleryPhotoResource(Photo $photo, string $token): array
|
||||
{
|
||||
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
|
||||
$full = $this->toPublicUrl($photo->file_path ?? null);
|
||||
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'thumbnail_url' => $thumbnail,
|
||||
'full_url' => $full,
|
||||
'download_url' => route('api.v1.gallery.photos.download', [
|
||||
'token' => $token,
|
||||
'photo' => $photo->id,
|
||||
]),
|
||||
'likes_count' => $photo->likes_count,
|
||||
'guest_name' => $photo->guest_name,
|
||||
'created_at' => $photo->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
public function gallery(Request $request, string $token)
|
||||
{
|
||||
$locale = $request->query('locale', app()->getLocale());
|
||||
|
||||
$resolved = $this->resolveGalleryEvent($request, $token);
|
||||
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||||
[$event] = $resolved;
|
||||
|
||||
$branding = $this->buildGalleryBranding($event);
|
||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||
|
||||
return response()->json([
|
||||
'event' => [
|
||||
'id' => $event->id,
|
||||
'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'),
|
||||
'slug' => $event->slug,
|
||||
'description' => $this->translateLocalized($event->description, $locale, ''),
|
||||
'gallery_expires_at' => $expiresAt?->toIso8601String(),
|
||||
],
|
||||
'branding' => $branding,
|
||||
]);
|
||||
}
|
||||
|
||||
public function galleryPhotos(Request $request, string $token)
|
||||
{
|
||||
$resolved = $this->resolveGalleryEvent($request, $token);
|
||||
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||||
[$event] = $resolved;
|
||||
|
||||
$limit = (int) $request->query('limit', 30);
|
||||
$limit = max(1, min($limit, 60));
|
||||
|
||||
$cursor = $this->decodeGalleryCursor($request->query('cursor'));
|
||||
|
||||
$query = Photo::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($cursor) {
|
||||
/** @var Carbon $cursorTime */
|
||||
$cursorTime = $cursor['created_at'];
|
||||
$cursorId = $cursor['id'];
|
||||
|
||||
$query->where(function ($inner) use ($cursorTime, $cursorId) {
|
||||
$inner->where('created_at', '<', $cursorTime)
|
||||
->orWhere(function ($nested) use ($cursorTime, $cursorId) {
|
||||
$nested->where('created_at', '=', $cursorTime)
|
||||
->where('id', '<', $cursorId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$photos = $query->limit($limit + 1)->get();
|
||||
|
||||
$hasMore = $photos->count() > $limit;
|
||||
$items = $photos->take($limit);
|
||||
|
||||
$nextCursor = null;
|
||||
|
||||
if ($hasMore) {
|
||||
$cursorPhoto = $photos->slice($limit, 1)->first();
|
||||
if ($cursorPhoto instanceof Photo) {
|
||||
$nextCursor = $this->encodeGalleryCursor($cursorPhoto);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(),
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function galleryPhotoDownload(Request $request, string $token, int $photo)
|
||||
{
|
||||
$resolved = $this->resolveGalleryEvent($request, $token);
|
||||
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||||
[$event] = $resolved;
|
||||
|
||||
$record = Photo::with('mediaAsset')
|
||||
->where('id', $photo)
|
||||
->where('event_id', $event->id)
|
||||
->where('status', 'approved')
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_not_found',
|
||||
'message' => 'The requested photo is no longer available.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
||||
|
||||
if ($asset) {
|
||||
$disk = $asset->disk ?? config('filesystems.default');
|
||||
$path = $asset->path ?? $record->file_path;
|
||||
|
||||
try {
|
||||
if ($path && Storage::disk($disk)->exists($path)) {
|
||||
$stream = Storage::disk($disk)->readStream($path);
|
||||
|
||||
if ($stream) {
|
||||
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension);
|
||||
$mime = $asset->mime_type ?? 'image/jpeg';
|
||||
|
||||
return response()->streamDownload(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}, $filename, [
|
||||
'Content-Type' => $mime,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gallery photo download failed', [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $record->id,
|
||||
'disk' => $asset->disk ?? null,
|
||||
'path' => $asset->path ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$publicUrl = $this->toPublicUrl($record->file_path ?? null);
|
||||
|
||||
if ($publicUrl) {
|
||||
return redirect()->away($publicUrl);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_unavailable',
|
||||
'message' => 'The requested photo could not be downloaded.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
public function event(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, [
|
||||
|
||||
132
app/Http/Controllers/Tenant/EventPhotoArchiveController.php
Normal file
132
app/Http/Controllers/Tenant/EventPhotoArchiveController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\Photo;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipArchive;
|
||||
|
||||
class EventPhotoArchiveController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Event $event): StreamedResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || (int) $user->tenant_id !== (int) $event->tenant_id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$photos = Photo::query()
|
||||
->with('mediaAsset')
|
||||
->where('event_id', $event->id)
|
||||
->where('status', 'approved')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
if ($photos->isEmpty()) {
|
||||
abort(404, 'No approved photos available for this event.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
|
||||
|
||||
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
abort(500, 'Unable to generate archive.');
|
||||
}
|
||||
|
||||
foreach ($photos as $photo) {
|
||||
$filename = $this->buildFilename($event, $photo);
|
||||
|
||||
$asset = $photo->mediaAsset ?? EventMediaAsset::query()
|
||||
->where('photo_id', $photo->id)
|
||||
->where('variant', 'original')
|
||||
->first();
|
||||
|
||||
$added = false;
|
||||
|
||||
if ($asset && $asset->path) {
|
||||
$added = $this->addDiskFileToArchive($zip, $asset->disk ?? config('filesystems.default'), $asset->path, $filename);
|
||||
}
|
||||
|
||||
if (! $added && $photo->file_path) {
|
||||
$added = $this->addDiskFileToArchive($zip, config('filesystems.default'), $photo->file_path, $filename);
|
||||
}
|
||||
|
||||
if (! $added) {
|
||||
Log::warning('Skipping photo in archive build', [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$downloadName = sprintf('fotospiel-event-%s-photos.zip', $event->slug ?? $event->id);
|
||||
|
||||
return response()->streamDownload(function () use ($tempPath) {
|
||||
$stream = fopen($tempPath, 'rb');
|
||||
|
||||
if (! $stream) {
|
||||
throw new FileNotFoundException('Archive could not be opened.');
|
||||
}
|
||||
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
unlink($tempPath);
|
||||
}, $downloadName, [
|
||||
'Content-Type' => 'application/zip',
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildFilename(Event $event, Photo $photo): string
|
||||
{
|
||||
$timestamp = $photo->created_at?->format('Ymd_His') ?? now()->format('Ymd_His');
|
||||
$extension = pathinfo($photo->file_path ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
|
||||
return sprintf('%s-photo-%d.%s', $timestamp, $photo->id, $extension);
|
||||
}
|
||||
|
||||
private function addDiskFileToArchive(ZipArchive $zip, ?string $diskName, string $path, string $filename): bool
|
||||
{
|
||||
$disk = $diskName ? Storage::disk($diskName) : Storage::disk(config('filesystems.default'));
|
||||
|
||||
try {
|
||||
if (method_exists($disk, 'path')) {
|
||||
$absolute = $disk->path($path);
|
||||
|
||||
if (is_file($absolute)) {
|
||||
return $zip->addFile($absolute, $filename);
|
||||
}
|
||||
}
|
||||
|
||||
$stream = $disk->readStream($path);
|
||||
|
||||
if ($stream) {
|
||||
$contents = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
if ($contents !== false) {
|
||||
return $zip->addFromString($filename, $contents);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to add file to archive', [
|
||||
'disk' => $diskName,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user