implemented a lot of security measures
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user