implemented a lot of security measures

This commit is contained in:
Codex Agent
2025-12-09 20:29:32 +01:00
parent 4bdb93c171
commit 928d28fcaf
21 changed files with 953 additions and 134 deletions

View File

@@ -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(