implemented a lot of security measures
This commit is contained in:
@@ -30,8 +30,10 @@ class BackfillThumbnails extends Command
|
||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||
if ($made) {
|
||||
$url = Storage::url($made);
|
||||
DB::table('photos')->where('id', $r->id)->update(['thumbnail_path' => $url, 'updated_at' => now()]);
|
||||
DB::table('photos')->where('id', $r->id)->update([
|
||||
'thumbnail_path' => $made,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$count++;
|
||||
$this->line("Photo {$r->id}: thumb created");
|
||||
}
|
||||
@@ -50,4 +52,3 @@ class BackfillThumbnails extends Command
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\EventMediaAsset;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PhotoShareLink;
|
||||
use App\Jobs\ProcessPhotoSecurityScan;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\EventTasksCacheService;
|
||||
@@ -43,6 +44,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
class EventPublicController extends BaseController
|
||||
{
|
||||
private const SIGNED_URL_TTL_SECONDS = 1800;
|
||||
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventJoinTokenService $joinTokenService,
|
||||
@@ -756,6 +758,7 @@ class EventPublicController extends BaseController
|
||||
$topPhotoRow = DB::table('photos')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->where('photos.event_id', $eventId)
|
||||
->where('photos.status', 'approved')
|
||||
->orderByDesc('photos.likes_count')
|
||||
->orderByDesc('photos.created_at')
|
||||
->select([
|
||||
@@ -775,7 +778,8 @@ class EventPublicController extends BaseController
|
||||
'likes' => (int) $topPhotoRow->likes_count,
|
||||
'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
|
||||
'created_at' => $topPhotoRow->created_at,
|
||||
'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
|
||||
'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $topPhotoRow->id, 'thumbnail')
|
||||
?? $this->resolveSignedFallbackUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
|
||||
] : null;
|
||||
|
||||
$trendingEmotionRow = DB::table('photos')
|
||||
@@ -809,6 +813,7 @@ class EventPublicController extends BaseController
|
||||
$feed = DB::table('photos')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->where('photos.event_id', $eventId)
|
||||
->where('photos.status', 'approved')
|
||||
->orderByDesc('photos.created_at')
|
||||
->limit(12)
|
||||
->get([
|
||||
@@ -826,7 +831,8 @@ class EventPublicController extends BaseController
|
||||
'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
|
||||
'likes' => (int) $row->likes_count,
|
||||
'created_at' => $row->created_at,
|
||||
'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path),
|
||||
'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $row->id, 'thumbnail')
|
||||
?? $this->resolveSignedFallbackUrl($row->thumbnail_path ?: $row->file_path),
|
||||
])
|
||||
->values();
|
||||
|
||||
@@ -846,38 +852,20 @@ class EventPublicController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
private function toPublicUrl(?string $path): ?string
|
||||
private function resolveSignedFallbackUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
// Already absolute URL
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
|
||||
// If already signed or absolute, return as-is
|
||||
if (str_contains($path, 'signature=')
|
||||
|| str_starts_with($path, 'http://')
|
||||
|| str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
// Already a public storage URL
|
||||
if (str_starts_with($path, '/storage/')) {
|
||||
return $path;
|
||||
}
|
||||
if (str_starts_with($path, 'storage/')) {
|
||||
return '/'.$path;
|
||||
}
|
||||
|
||||
// Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...')
|
||||
if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/') || str_starts_with($path, 'branding/')) {
|
||||
return Storage::url($path);
|
||||
}
|
||||
|
||||
// Absolute server paths pointing into storage/app/public (Linux/Windows)
|
||||
$normalized = str_replace('\\', '/', $path);
|
||||
$needle = '/storage/app/public/';
|
||||
if (str_contains($normalized, $needle)) {
|
||||
$rel = substr($normalized, strpos($normalized, $needle) + strlen($needle));
|
||||
|
||||
return '/storage/'.ltrim($rel, '/');
|
||||
}
|
||||
|
||||
return $path; // fallback as-is
|
||||
return null;
|
||||
}
|
||||
|
||||
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
|
||||
@@ -1063,7 +1051,7 @@ class EventPublicController extends BaseController
|
||||
: 'emoticon';
|
||||
}
|
||||
|
||||
$logoValue = $logoMode === 'upload' ? $this->toPublicUrl($logoRawValue) : $logoRawValue;
|
||||
$logoValue = $logoMode === 'upload' ? $this->makeSignedBrandingUrl($logoRawValue) : $logoRawValue;
|
||||
|
||||
$buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style'];
|
||||
if (! in_array($buttonStyle, ['filled', 'outline'], true)) {
|
||||
@@ -1187,7 +1175,7 @@ class EventPublicController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
|
||||
private function makeSignedGalleryAssetUrlForId(string $token, int $photoId, string $variant): ?string
|
||||
{
|
||||
if (! in_array($variant, ['thumbnail', 'full'], true)) {
|
||||
return null;
|
||||
@@ -1198,12 +1186,152 @@ class EventPublicController extends BaseController
|
||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||
[
|
||||
'token' => $token,
|
||||
'photo' => $photo->id,
|
||||
'photo' => $photoId,
|
||||
'variant' => $variant,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
|
||||
{
|
||||
return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant);
|
||||
}
|
||||
|
||||
private function makeSignedBrandingUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$normalized = ltrim($path, '/');
|
||||
if (str_starts_with($normalized, 'storage/')) {
|
||||
$normalized = substr($normalized, strlen('storage/'));
|
||||
}
|
||||
|
||||
if (! $this->isAllowedBrandingPath($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'api.v1.branding.asset',
|
||||
now()->addSeconds(self::BRANDING_SIGNED_TTL_SECONDS),
|
||||
['path' => $normalized]
|
||||
);
|
||||
}
|
||||
|
||||
public function brandingAsset(Request $request, string $path)
|
||||
{
|
||||
$cleanPath = ltrim($path, '/');
|
||||
|
||||
if ($cleanPath === '' || str_contains($cleanPath, '..') || ! $this->isAllowedBrandingPath($cleanPath)) {
|
||||
return ApiError::response(
|
||||
'branding_not_found',
|
||||
'Branding Asset Not Found',
|
||||
'The requested branding asset could not be found.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
$diskName = config('filesystems.default', 'public');
|
||||
|
||||
try {
|
||||
$storage = Storage::disk($diskName);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Branding asset disk unavailable', [
|
||||
'path' => $cleanPath,
|
||||
'disk' => $diskName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ApiError::response(
|
||||
'branding_not_found',
|
||||
'Branding Asset Not Found',
|
||||
'The requested branding asset could not be found.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
if (! $storage->exists($cleanPath)) {
|
||||
return ApiError::response(
|
||||
'branding_not_found',
|
||||
'Branding Asset Not Found',
|
||||
'The requested branding asset could not be found.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['path' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$stream = $storage->readStream($cleanPath);
|
||||
if (! $stream) {
|
||||
throw new \RuntimeException('Unable to read branding asset stream.');
|
||||
}
|
||||
|
||||
$mime = $storage->mimeType($cleanPath) ?: 'application/octet-stream';
|
||||
$size = null;
|
||||
try {
|
||||
$size = $storage->size($cleanPath);
|
||||
} catch (\Throwable $e) {
|
||||
$size = null;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => 'private, max-age='.self::BRANDING_SIGNED_TTL_SECONDS,
|
||||
];
|
||||
|
||||
if ($size) {
|
||||
$headers['Content-Length'] = $size;
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}, 200, $headers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Branding asset stream error', [
|
||||
'path' => $cleanPath,
|
||||
'disk' => $diskName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ApiError::response(
|
||||
'branding_not_found',
|
||||
'Branding Asset Not Found',
|
||||
'The requested branding asset could not be loaded.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['path' => $path]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function isAllowedBrandingPath(string $path): bool
|
||||
{
|
||||
if ($path === '' || str_contains($path, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedPrefixes = [
|
||||
'branding/',
|
||||
'events/',
|
||||
'tenant-branding/',
|
||||
];
|
||||
|
||||
foreach ($allowedPrefixes as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
@@ -1786,12 +1914,6 @@ class EventPublicController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$fallbackUrl = $this->toPublicUrl($record->file_path ?? null);
|
||||
|
||||
if ($fallbackUrl) {
|
||||
return redirect()->away($fallbackUrl);
|
||||
}
|
||||
|
||||
return ApiError::response(
|
||||
'photo_unavailable',
|
||||
'Photo Unavailable',
|
||||
@@ -2372,6 +2494,7 @@ class EventPublicController extends BaseController
|
||||
'tasks.title as task_title',
|
||||
])
|
||||
->where('photos.event_id', $eventId)
|
||||
->where('photos.status', 'approved')
|
||||
->orderByDesc('photos.created_at')
|
||||
->limit(60);
|
||||
|
||||
@@ -2385,9 +2508,11 @@ class EventPublicController extends BaseController
|
||||
if ($since) {
|
||||
$query->where('photos.created_at', '>', $since);
|
||||
}
|
||||
$rows = $query->get()->map(function ($r) use ($fallbacks) {
|
||||
$r->file_path = $this->toPublicUrl((string) ($r->file_path ?? ''));
|
||||
$r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? ''));
|
||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
||||
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||||
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
||||
|
||||
// Localize task title if present
|
||||
if ($r->task_title) {
|
||||
@@ -2424,54 +2549,13 @@ class EventPublicController extends BaseController
|
||||
|
||||
public function photo(Request $request, int $id)
|
||||
{
|
||||
$row = DB::table('photos')
|
||||
->join('events', 'photos.event_id', '=', 'events.id')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->select([
|
||||
'photos.id',
|
||||
'photos.event_id',
|
||||
'photos.file_path',
|
||||
'photos.thumbnail_path',
|
||||
'photos.likes_count',
|
||||
'photos.emotion_id',
|
||||
'photos.task_id',
|
||||
'photos.created_at',
|
||||
'tasks.title as task_title',
|
||||
'events.default_locale',
|
||||
])
|
||||
->where('photos.id', $id)
|
||||
->where('events.status', 'published')
|
||||
->first();
|
||||
if (! $row) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
$row->file_path = $this->toPublicUrl((string) ($row->file_path ?? ''));
|
||||
$row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
|
||||
|
||||
$event = (object) [
|
||||
'id' => $row->event_id,
|
||||
'default_locale' => $row->default_locale ?? null,
|
||||
];
|
||||
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
if ($row->task_title) {
|
||||
$row->task_title = $this->firstLocalizedValue($row->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||||
}
|
||||
|
||||
unset($row->default_locale);
|
||||
|
||||
return response()->json($row)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('X-Content-Locale', $locale)
|
||||
->header('Vary', 'Accept-Language, X-Locale');
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
public function like(Request $request, int $id)
|
||||
@@ -2612,7 +2696,7 @@ class EventPublicController extends BaseController
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'photo' => ['required', 'image', 'max:12288'], // 12 MB
|
||||
'photo' => ['required', 'image', 'mimes:jpeg,jpg,png,webp,heic,heif,gif', 'max:12288'], // 12 MB, block SVG/other image types
|
||||
'emotion_id' => ['nullable', 'integer'],
|
||||
'emotion_slug' => ['nullable', 'string'],
|
||||
'task_id' => ['nullable', 'integer'],
|
||||
@@ -2660,7 +2744,7 @@ class EventPublicController extends BaseController
|
||||
'thumbnail_path' => $thumbUrl,
|
||||
'likes_count' => 0,
|
||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||
'status' => 'approved',
|
||||
'status' => 'pending',
|
||||
|
||||
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||
@@ -2734,6 +2818,8 @@ class EventPublicController extends BaseController
|
||||
->where('id', $photoId)
|
||||
->update(['media_asset_id' => $asset->id]);
|
||||
|
||||
ProcessPhotoSecurityScan::dispatch($photoId);
|
||||
|
||||
if ($eventPackage) {
|
||||
$previousUsed = (int) $eventPackage->used_photos;
|
||||
$eventPackage->increment('used_photos');
|
||||
@@ -2743,8 +2829,8 @@ class EventPublicController extends BaseController
|
||||
|
||||
$response = response()->json([
|
||||
'id' => $photoId,
|
||||
'file_path' => $url,
|
||||
'thumbnail_path' => $thumbUrl,
|
||||
'status' => 'pending',
|
||||
'message' => 'Photo uploaded and pending review.',
|
||||
], 201);
|
||||
|
||||
$this->recordTokenEvent(
|
||||
|
||||
@@ -53,6 +53,18 @@ class ProfileDataExportController extends Controller
|
||||
return back()->with('error', __('profile.export.messages.not_available'));
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download($export->path, sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')));
|
||||
$disk = 'local';
|
||||
|
||||
if (! Storage::disk($disk)->exists($export->path)) {
|
||||
return back()->with('error', __('profile.export.messages.not_available'));
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download(
|
||||
$export->path,
|
||||
sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')),
|
||||
[
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class ContentSecurityPolicy
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$scriptNonce = base64_encode(random_bytes(16));
|
||||
$styleNonce = null;
|
||||
$styleNonce = base64_encode(random_bytes(16));
|
||||
|
||||
$request->attributes->set('csp_script_nonce', $scriptNonce);
|
||||
$request->attributes->set('csp_style_nonce', $styleNonce);
|
||||
@@ -45,7 +45,7 @@ class ContentSecurityPolicy
|
||||
|
||||
$styleSources = [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'nonce-{$styleNonce}'",
|
||||
'https:',
|
||||
];
|
||||
|
||||
@@ -97,7 +97,9 @@ class ContentSecurityPolicy
|
||||
$imgSources[] = $matomoOrigin;
|
||||
}
|
||||
|
||||
if (app()->environment(['local', 'development']) || config('app.debug')) {
|
||||
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||
|
||||
if ($isDev) {
|
||||
$devHosts = [
|
||||
'http://fotospiel-app.test:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
@@ -134,6 +136,7 @@ class ContentSecurityPolicy
|
||||
'form-action' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
'object-src' => ["'none'"],
|
||||
'frame-ancestors' => ["'self'"],
|
||||
];
|
||||
|
||||
$csp = collect($directives)
|
||||
|
||||
38
app/Http/Middleware/ResponseSecurityHeaders.php
Normal file
38
app/Http/Middleware/ResponseSecurityHeaders.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ResponseSecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
||||
$headers = [
|
||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-Frame-Options' => 'SAMEORIGIN',
|
||||
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
|
||||
];
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
if (! $response->headers->has($name)) {
|
||||
$response->headers->set($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->isSecure() && ! app()->environment(['local', 'testing'])) {
|
||||
$hsts = 'max-age=31536000; includeSubDomains';
|
||||
if (! $response->headers->has('Strict-Transport-Security')) {
|
||||
$response->headers->set('Strict-Transport-Security', $hsts);
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ class PhotoResource extends JsonResource
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$showSensitive = $this->event->tenant_id === $tenantId;
|
||||
$fullUrl = $this->getFullUrl();
|
||||
$thumbnailUrl = $this->getThumbnailUrl();
|
||||
$fullUrl = $this->getSignedUrl('full');
|
||||
$thumbnailUrl = $this->getSignedUrl('thumbnail');
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
@@ -46,26 +46,26 @@ class PhotoResource extends JsonResource
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full image URL
|
||||
* Get signed URL for variant
|
||||
*/
|
||||
private function getFullUrl(): ?string
|
||||
private function getSignedUrl(string $variant): ?string
|
||||
{
|
||||
if (empty($this->filename)) {
|
||||
if (empty($this->id) || empty($this->event?->slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
|
||||
}
|
||||
$route = $variant === 'thumbnail'
|
||||
? 'api.v1.gallery.photos.asset'
|
||||
: 'api.v1.gallery.photos.asset';
|
||||
|
||||
/**
|
||||
* Get thumbnail URL
|
||||
*/
|
||||
private function getThumbnailUrl(): ?string
|
||||
{
|
||||
if (empty($this->filename)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
|
||||
return \URL::temporarySignedRoute(
|
||||
$route,
|
||||
now()->addMinutes(30),
|
||||
[
|
||||
'token' => $this->event->slug, // tenant/admin views are trusted; token not used server-side for signed validation
|
||||
'photo' => $this->id,
|
||||
'variant' => $variant === 'thumbnail' ? 'thumbnail' : 'full',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +67,22 @@ class ProcessPhotoSecurityScan implements ShouldQueue
|
||||
|
||||
$existingMeta = $photo->security_meta ?? [];
|
||||
|
||||
$photo->forceFill([
|
||||
$update = [
|
||||
'security_scan_status' => $status,
|
||||
'security_scan_message' => $message,
|
||||
'security_scanned_at' => now(),
|
||||
'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata),
|
||||
])->save();
|
||||
];
|
||||
|
||||
if (in_array($status, ['clean', 'skipped'], true) && $photo->status === 'pending') {
|
||||
$update['status'] = 'approved';
|
||||
}
|
||||
|
||||
if ($status === 'infected') {
|
||||
$update['status'] = 'rejected';
|
||||
}
|
||||
|
||||
$photo->forceFill($update)->save();
|
||||
|
||||
if ($status === 'infected') {
|
||||
Log::alert('[PhotoSecurity] Infected photo detected', [
|
||||
|
||||
@@ -58,7 +58,19 @@ class BlogPost extends Model
|
||||
|
||||
public function bannerUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->banner ? asset(Storage::url($this->banner)) : '');
|
||||
return Attribute::get(function () {
|
||||
if (! $this->banner) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = ltrim($this->banner, '/');
|
||||
|
||||
return \URL::temporarySignedRoute(
|
||||
'api.v1.branding.asset',
|
||||
now()->addMinutes(30),
|
||||
['path' => $path]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function contentHtml(): Attribute
|
||||
|
||||
@@ -178,6 +178,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('paddle-webhook', function (Request $request) {
|
||||
return Limit::perMinute(30)->by('paddle:'.$request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('gift-lookup', function (Request $request) {
|
||||
$code = strtoupper((string) $request->query('code'));
|
||||
$ip = $request->ip() ?? 'unknown';
|
||||
|
||||
Reference in New Issue
Block a user