- Galerien sind nun eine Entität - es kann mehrere geben

- 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.
This commit is contained in:
2025-12-04 07:52:50 +01:00
parent 52dc61ca16
commit f5da8ed877
49 changed files with 2243 additions and 165 deletions

View File

@@ -0,0 +1,288 @@
<?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;
}
}