- Neues Sparkbooth-Upload-Feature: Endpoint /api/sparkbooth/upload (Token-basiert pro Galerie), Controller Api/SparkboothUploadController, Migration 2026_01_21_000001_add_upload_fields_to_galleries_table.php mit Upload-Flags/Token/Expiry;
Galerie-Modell und Factory/Seeder entsprechend erweitert.
- Filament: Neue Setup-Seite SparkboothSetup (mit View) zur schnellen Galerie- und Token-Erstellung inkl. QR/Endpoint/Snippet;
Galerie-Link-Views nutzen jetzt simple-qrcode (Composer-Dependency hinzugefügt) und bieten PNG-Download.
- Galerie-Tabelle: Slug/Pfad-Spalten entfernt, Action „Link-Details“ mit Modal; Created-at-Spalte hinzugefügt.
- Zugriffshärtung: Galerie-IDs in API (ImageController, Download/Print) geprüft; GalleryAccess/Middleware + Gallery-Modell/Slug-UUID
eingeführt; GalleryAccess-Inertia-Seite.
- UI/UX: LoadingSpinner/StyledImageDisplay verbessert, Delete-Confirm, Übersetzungen ergänzt.
289 lines
8.5 KiB
PHP
289 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Models\Gallery;
|
|
use App\Settings\GeneralSettings;
|
|
use Carbon\Carbon;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class EnsureGalleryAccess
|
|
{
|
|
public const SESSION_GRANTED_BASE = 'gallery_access_granted';
|
|
|
|
public const SESSION_GRANTED_AT_BASE = 'gallery_access_granted_at';
|
|
|
|
public const COOKIE_GRANTED_AT_BASE = 'gallery_access_granted_at';
|
|
|
|
/**
|
|
* Handle an incoming request.
|
|
*
|
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
|
*/
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
$settings = app(GeneralSettings::class);
|
|
$gallery = $this->resolveGallery($request);
|
|
|
|
if ($this->requestedGalleryMissing($request, $gallery)) {
|
|
return $this->missingGalleryResponse($request);
|
|
}
|
|
|
|
if ($this->isGalleryExpired($gallery, $settings)) {
|
|
return $this->deny($request, __('api.gallery.expired'), $gallery);
|
|
}
|
|
|
|
$hasValidAccess = $this->hasValidAccess($request, $gallery, $settings);
|
|
$hasExistingGrant = (bool) ($this->sessionGrantedAt($request, $gallery) ?? $this->cookieGrantedAt($request, $gallery));
|
|
|
|
if ($hasValidAccess) {
|
|
return $next($request);
|
|
}
|
|
|
|
if ($this->accessDuration($gallery, $settings) && $hasExistingGrant) {
|
|
return $this->deny($request, __('api.gallery.expired'), $gallery);
|
|
}
|
|
|
|
if (! $this->requiresPassword($gallery, $settings) || ! $this->passwordHash($gallery, $settings)) {
|
|
if ($this->accessDuration($gallery, $settings) && ! $hasExistingGrant) {
|
|
self::grantForGallery($request, $gallery, $settings);
|
|
}
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
return $this->deny($request, __('api.gallery.password_required'), $gallery);
|
|
}
|
|
|
|
public static function grantForGallery(Request $request, ?Gallery $gallery, GeneralSettings $settings, ?Carbon $grantedAt = null): void
|
|
{
|
|
$grantedAt = $grantedAt ?: Carbon::now();
|
|
$suffix = self::keySuffix($gallery);
|
|
|
|
if ($request->hasSession()) {
|
|
$request->session()->put(self::SESSION_GRANTED_BASE.'_'.$suffix, true);
|
|
$request->session()->put(self::SESSION_GRANTED_AT_BASE.'_'.$suffix, $grantedAt->toIso8601String());
|
|
}
|
|
|
|
$cookieMinutes = self::calculateCookieLifetime($gallery, $settings, $grantedAt);
|
|
|
|
cookie()->queue(
|
|
cookie(
|
|
self::COOKIE_GRANTED_AT_BASE.'_'.$suffix,
|
|
$grantedAt->toIso8601String(),
|
|
$cookieMinutes,
|
|
path: '/',
|
|
secure: config('session.secure', false),
|
|
httpOnly: true,
|
|
raw: false,
|
|
sameSite: config('session.same_site', 'lax')
|
|
)
|
|
);
|
|
}
|
|
|
|
private function hasValidAccess(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool
|
|
{
|
|
if ($this->sessionAccessValid($request, $gallery, $settings)) {
|
|
return true;
|
|
}
|
|
|
|
$grantedAt = $this->cookieGrantedAt($request, $gallery);
|
|
|
|
if (! $grantedAt) {
|
|
return false;
|
|
}
|
|
|
|
$duration = $this->accessDuration($gallery, $settings);
|
|
|
|
if ($duration) {
|
|
return ! $grantedAt->copy()->addMinutes($duration)->isPast();
|
|
}
|
|
|
|
return (bool) $grantedAt;
|
|
}
|
|
|
|
private function sessionAccessValid(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool
|
|
{
|
|
if (! $request->hasSession()) {
|
|
return false;
|
|
}
|
|
|
|
$suffix = self::keySuffix($gallery);
|
|
|
|
if (! $request->session()->get(self::SESSION_GRANTED_BASE.'_'.$suffix, false)) {
|
|
return false;
|
|
}
|
|
|
|
$grantedAt = $this->sessionGrantedAt($request, $gallery);
|
|
|
|
if (! $grantedAt) {
|
|
return true;
|
|
}
|
|
|
|
$duration = $this->accessDuration($gallery, $settings);
|
|
|
|
if ($duration) {
|
|
$expiresAt = $grantedAt->copy()->addMinutes($duration);
|
|
|
|
if ($expiresAt->isPast()) {
|
|
$request->session()->forget([
|
|
self::SESSION_GRANTED_BASE.'_'.$suffix,
|
|
self::SESSION_GRANTED_AT_BASE.'_'.$suffix,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function sessionGrantedAt(Request $request, ?Gallery $gallery): ?Carbon
|
|
{
|
|
if (! $request->hasSession()) {
|
|
return null;
|
|
}
|
|
|
|
$value = $request->session()->get(self::SESSION_GRANTED_AT_BASE.'_'.self::keySuffix($gallery));
|
|
|
|
if (! $value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (\Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function cookieGrantedAt(Request $request, ?Gallery $gallery): ?Carbon
|
|
{
|
|
$value = $request->cookie(self::COOKIE_GRANTED_AT_BASE.'_'.self::keySuffix($gallery));
|
|
|
|
if (! $value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$value = Crypt::decryptString($value);
|
|
} catch (\Exception) {
|
|
// Cookie might already be decrypted by web middleware.
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (\Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function isGalleryExpired(?Gallery $gallery, GeneralSettings $settings): bool
|
|
{
|
|
$expiresAt = $this->expiresAt($gallery, $settings);
|
|
|
|
return $expiresAt !== null && Carbon::now()->greaterThanOrEqualTo($expiresAt);
|
|
}
|
|
|
|
private function deny(Request $request, string $message, ?Gallery $gallery = null): Response
|
|
{
|
|
if ($request->expectsJson()) {
|
|
return response()->json(['message' => $message], Response::HTTP_FORBIDDEN);
|
|
}
|
|
|
|
$routeName = $gallery ? 'gallery.access.show' : 'gallery.access.default';
|
|
|
|
return redirect()
|
|
->route($routeName, $gallery)
|
|
->with('gallery_access_message', $message);
|
|
}
|
|
|
|
private static function calculateCookieLifetime(?Gallery $gallery, GeneralSettings $settings, Carbon $grantedAt): int
|
|
{
|
|
$expiresAt = $gallery?->expires_at ?? $settings->gallery_expires_at;
|
|
|
|
if ($expiresAt) {
|
|
$expiresAt = Carbon::parse($expiresAt);
|
|
|
|
return max(1, $grantedAt->diffInMinutes($expiresAt));
|
|
}
|
|
|
|
$duration = $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes;
|
|
|
|
if ($duration) {
|
|
return max(1, $duration);
|
|
}
|
|
|
|
return 24 * 60;
|
|
}
|
|
|
|
private function resolveGallery(Request $request): ?Gallery
|
|
{
|
|
$routeGallery = $request->route('gallery');
|
|
|
|
if ($routeGallery instanceof Gallery) {
|
|
return $routeGallery;
|
|
}
|
|
|
|
$slug = is_string($routeGallery) ? $routeGallery : $request->query('gallery');
|
|
|
|
if (! $slug) {
|
|
return Gallery::first();
|
|
}
|
|
|
|
return Gallery::where('slug', $slug)->first();
|
|
}
|
|
|
|
private function requestedGalleryMissing(Request $request, ?Gallery $gallery): bool
|
|
{
|
|
if ($gallery) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) ($request->route('gallery') || $request->query('gallery'));
|
|
}
|
|
|
|
private function missingGalleryResponse(Request $request): Response
|
|
{
|
|
if ($request->expectsJson()) {
|
|
return response()->json(['message' => 'Gallery not found.'], Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
abort(404, 'Gallery not found');
|
|
}
|
|
|
|
private static function keySuffix(?Gallery $gallery): string
|
|
{
|
|
return $gallery ? 'gallery_'.$gallery->id : 'global';
|
|
}
|
|
|
|
private function expiresAt(?Gallery $gallery, GeneralSettings $settings): ?Carbon
|
|
{
|
|
$value = $gallery?->expires_at ?? $settings->gallery_expires_at;
|
|
|
|
if (! $value) {
|
|
return null;
|
|
}
|
|
|
|
return Carbon::parse($value);
|
|
}
|
|
|
|
private function accessDuration(?Gallery $gallery, GeneralSettings $settings): ?int
|
|
{
|
|
return $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes;
|
|
}
|
|
|
|
private function requiresPassword(?Gallery $gallery, GeneralSettings $settings): bool
|
|
{
|
|
return (bool) ($gallery?->require_password ?? $settings->require_gallery_password);
|
|
}
|
|
|
|
private function passwordHash(?Gallery $gallery, GeneralSettings $settings): ?string
|
|
{
|
|
return $gallery?->password_hash ?? $settings->gallery_password_hash;
|
|
}
|
|
}
|