implemented a lot of security measures
This commit is contained in:
@@ -102,6 +102,10 @@ PADDLE_CONSOLE_URL=
|
|||||||
# Sanctum / SPA auth
|
# Sanctum / SPA auth
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
||||||
SANCTUM_TOKEN_PREFIX=
|
SANCTUM_TOKEN_PREFIX=
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,X-Locale,X-Device-Id
|
||||||
|
CORS_SUPPORTS_CREDENTIALS=false
|
||||||
JOIN_TOKEN_FAILURE_LIMIT=10
|
JOIN_TOKEN_FAILURE_LIMIT=10
|
||||||
JOIN_TOKEN_FAILURE_DECAY=5
|
JOIN_TOKEN_FAILURE_DECAY=5
|
||||||
JOIN_TOKEN_ACCESS_LIMIT=120
|
JOIN_TOKEN_ACCESS_LIMIT=120
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ class BackfillThumbnails extends Command
|
|||||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||||
if ($made) {
|
if ($made) {
|
||||||
$url = Storage::url($made);
|
DB::table('photos')->where('id', $r->id)->update([
|
||||||
DB::table('photos')->where('id', $r->id)->update(['thumbnail_path' => $url, 'updated_at' => now()]);
|
'thumbnail_path' => $made,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
$count++;
|
$count++;
|
||||||
$this->line("Photo {$r->id}: thumb created");
|
$this->line("Photo {$r->id}: thumb created");
|
||||||
}
|
}
|
||||||
@@ -50,4 +52,3 @@ class BackfillThumbnails extends Command
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Models\EventMediaAsset;
|
|||||||
use App\Models\GuestNotification;
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\PhotoShareLink;
|
use App\Models\PhotoShareLink;
|
||||||
|
use App\Jobs\ProcessPhotoSecurityScan;
|
||||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\EventTasksCacheService;
|
use App\Services\EventTasksCacheService;
|
||||||
@@ -43,6 +44,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
{
|
{
|
||||||
private const SIGNED_URL_TTL_SECONDS = 1800;
|
private const SIGNED_URL_TTL_SECONDS = 1800;
|
||||||
|
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EventJoinTokenService $joinTokenService,
|
private readonly EventJoinTokenService $joinTokenService,
|
||||||
@@ -756,6 +758,7 @@ class EventPublicController extends BaseController
|
|||||||
$topPhotoRow = DB::table('photos')
|
$topPhotoRow = DB::table('photos')
|
||||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
->where('photos.event_id', $eventId)
|
->where('photos.event_id', $eventId)
|
||||||
|
->where('photos.status', 'approved')
|
||||||
->orderByDesc('photos.likes_count')
|
->orderByDesc('photos.likes_count')
|
||||||
->orderByDesc('photos.created_at')
|
->orderByDesc('photos.created_at')
|
||||||
->select([
|
->select([
|
||||||
@@ -775,7 +778,8 @@ class EventPublicController extends BaseController
|
|||||||
'likes' => (int) $topPhotoRow->likes_count,
|
'likes' => (int) $topPhotoRow->likes_count,
|
||||||
'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
|
'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
|
||||||
'created_at' => $topPhotoRow->created_at,
|
'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;
|
] : null;
|
||||||
|
|
||||||
$trendingEmotionRow = DB::table('photos')
|
$trendingEmotionRow = DB::table('photos')
|
||||||
@@ -809,6 +813,7 @@ class EventPublicController extends BaseController
|
|||||||
$feed = DB::table('photos')
|
$feed = DB::table('photos')
|
||||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
->where('photos.event_id', $eventId)
|
->where('photos.event_id', $eventId)
|
||||||
|
->where('photos.status', 'approved')
|
||||||
->orderByDesc('photos.created_at')
|
->orderByDesc('photos.created_at')
|
||||||
->limit(12)
|
->limit(12)
|
||||||
->get([
|
->get([
|
||||||
@@ -826,7 +831,8 @@ class EventPublicController extends BaseController
|
|||||||
'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
|
'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
|
||||||
'likes' => (int) $row->likes_count,
|
'likes' => (int) $row->likes_count,
|
||||||
'created_at' => $row->created_at,
|
'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();
|
->values();
|
||||||
|
|
||||||
@@ -846,38 +852,20 @@ class EventPublicController extends BaseController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toPublicUrl(?string $path): ?string
|
private function resolveSignedFallbackUrl(?string $path): ?string
|
||||||
{
|
{
|
||||||
if (! $path) {
|
if (! $path) {
|
||||||
return null;
|
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;
|
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/...')
|
return null;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
|
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
|
||||||
@@ -1063,7 +1051,7 @@ class EventPublicController extends BaseController
|
|||||||
: 'emoticon';
|
: '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'];
|
$buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style'];
|
||||||
if (! in_array($buttonStyle, ['filled', 'outline'], true)) {
|
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)) {
|
if (! in_array($variant, ['thumbnail', 'full'], true)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1198,12 +1186,152 @@ class EventPublicController extends BaseController
|
|||||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||||
[
|
[
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'photo' => $photo->id,
|
'photo' => $photoId,
|
||||||
'variant' => $variant,
|
'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
|
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||||||
{
|
{
|
||||||
return URL::temporarySignedRoute(
|
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(
|
return ApiError::response(
|
||||||
'photo_unavailable',
|
'photo_unavailable',
|
||||||
'Photo Unavailable',
|
'Photo Unavailable',
|
||||||
@@ -2372,6 +2494,7 @@ class EventPublicController extends BaseController
|
|||||||
'tasks.title as task_title',
|
'tasks.title as task_title',
|
||||||
])
|
])
|
||||||
->where('photos.event_id', $eventId)
|
->where('photos.event_id', $eventId)
|
||||||
|
->where('photos.status', 'approved')
|
||||||
->orderByDesc('photos.created_at')
|
->orderByDesc('photos.created_at')
|
||||||
->limit(60);
|
->limit(60);
|
||||||
|
|
||||||
@@ -2385,9 +2508,11 @@ class EventPublicController extends BaseController
|
|||||||
if ($since) {
|
if ($since) {
|
||||||
$query->where('photos.created_at', '>', $since);
|
$query->where('photos.created_at', '>', $since);
|
||||||
}
|
}
|
||||||
$rows = $query->get()->map(function ($r) use ($fallbacks) {
|
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
||||||
$r->file_path = $this->toPublicUrl((string) ($r->file_path ?? ''));
|
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||||
$r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? ''));
|
?? $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
|
// Localize task title if present
|
||||||
if ($r->task_title) {
|
if ($r->task_title) {
|
||||||
@@ -2424,25 +2549,6 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
public function photo(Request $request, int $id)
|
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(
|
return ApiError::response(
|
||||||
'photo_not_found',
|
'photo_not_found',
|
||||||
'Photo Not Found',
|
'Photo Not Found',
|
||||||
@@ -2451,28 +2557,6 @@ class EventPublicController extends BaseController
|
|||||||
['photo_id' => $id]
|
['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');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function like(Request $request, int $id)
|
public function like(Request $request, int $id)
|
||||||
{
|
{
|
||||||
@@ -2612,7 +2696,7 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$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_id' => ['nullable', 'integer'],
|
||||||
'emotion_slug' => ['nullable', 'string'],
|
'emotion_slug' => ['nullable', 'string'],
|
||||||
'task_id' => ['nullable', 'integer'],
|
'task_id' => ['nullable', 'integer'],
|
||||||
@@ -2660,7 +2744,7 @@ class EventPublicController extends BaseController
|
|||||||
'thumbnail_path' => $thumbUrl,
|
'thumbnail_path' => $thumbUrl,
|
||||||
'likes_count' => 0,
|
'likes_count' => 0,
|
||||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||||
'status' => 'approved',
|
'status' => 'pending',
|
||||||
|
|
||||||
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||||
@@ -2734,6 +2818,8 @@ class EventPublicController extends BaseController
|
|||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
->update(['media_asset_id' => $asset->id]);
|
->update(['media_asset_id' => $asset->id]);
|
||||||
|
|
||||||
|
ProcessPhotoSecurityScan::dispatch($photoId);
|
||||||
|
|
||||||
if ($eventPackage) {
|
if ($eventPackage) {
|
||||||
$previousUsed = (int) $eventPackage->used_photos;
|
$previousUsed = (int) $eventPackage->used_photos;
|
||||||
$eventPackage->increment('used_photos');
|
$eventPackage->increment('used_photos');
|
||||||
@@ -2743,8 +2829,8 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
$response = response()->json([
|
$response = response()->json([
|
||||||
'id' => $photoId,
|
'id' => $photoId,
|
||||||
'file_path' => $url,
|
'status' => 'pending',
|
||||||
'thumbnail_path' => $thumbUrl,
|
'message' => 'Photo uploaded and pending review.',
|
||||||
], 201);
|
], 201);
|
||||||
|
|
||||||
$this->recordTokenEvent(
|
$this->recordTokenEvent(
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ class ProfileDataExportController extends Controller
|
|||||||
return back()->with('error', __('profile.export.messages.not_available'));
|
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
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$scriptNonce = base64_encode(random_bytes(16));
|
$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_script_nonce', $scriptNonce);
|
||||||
$request->attributes->set('csp_style_nonce', $styleNonce);
|
$request->attributes->set('csp_style_nonce', $styleNonce);
|
||||||
@@ -45,7 +45,7 @@ class ContentSecurityPolicy
|
|||||||
|
|
||||||
$styleSources = [
|
$styleSources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
"'unsafe-inline'",
|
"'nonce-{$styleNonce}'",
|
||||||
'https:',
|
'https:',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,7 +97,9 @@ class ContentSecurityPolicy
|
|||||||
$imgSources[] = $matomoOrigin;
|
$imgSources[] = $matomoOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app()->environment(['local', 'development']) || config('app.debug')) {
|
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||||
|
|
||||||
|
if ($isDev) {
|
||||||
$devHosts = [
|
$devHosts = [
|
||||||
'http://fotospiel-app.test:5173',
|
'http://fotospiel-app.test:5173',
|
||||||
'http://127.0.0.1:5173',
|
'http://127.0.0.1:5173',
|
||||||
@@ -134,6 +136,7 @@ class ContentSecurityPolicy
|
|||||||
'form-action' => ["'self'"],
|
'form-action' => ["'self'"],
|
||||||
'base-uri' => ["'self'"],
|
'base-uri' => ["'self'"],
|
||||||
'object-src' => ["'none'"],
|
'object-src' => ["'none'"],
|
||||||
|
'frame-ancestors' => ["'self'"],
|
||||||
];
|
];
|
||||||
|
|
||||||
$csp = collect($directives)
|
$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');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
$showSensitive = $this->event->tenant_id === $tenantId;
|
$showSensitive = $this->event->tenant_id === $tenantId;
|
||||||
$fullUrl = $this->getFullUrl();
|
$fullUrl = $this->getSignedUrl('full');
|
||||||
$thumbnailUrl = $this->getThumbnailUrl();
|
$thumbnailUrl = $this->getSignedUrl('thumbnail');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return url("storage/events/{$this->event->slug}/photos/{$this->filename}");
|
$route = $variant === 'thumbnail'
|
||||||
}
|
? 'api.v1.gallery.photos.asset'
|
||||||
|
: 'api.v1.gallery.photos.asset';
|
||||||
|
|
||||||
/**
|
return \URL::temporarySignedRoute(
|
||||||
* Get thumbnail URL
|
$route,
|
||||||
*/
|
now()->addMinutes(30),
|
||||||
private function getThumbnailUrl(): ?string
|
[
|
||||||
{
|
'token' => $this->event->slug, // tenant/admin views are trusted; token not used server-side for signed validation
|
||||||
if (empty($this->filename)) {
|
'photo' => $this->id,
|
||||||
return null;
|
'variant' => $variant === 'thumbnail' ? 'thumbnail' : 'full',
|
||||||
}
|
]
|
||||||
|
);
|
||||||
return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,12 +67,22 @@ class ProcessPhotoSecurityScan implements ShouldQueue
|
|||||||
|
|
||||||
$existingMeta = $photo->security_meta ?? [];
|
$existingMeta = $photo->security_meta ?? [];
|
||||||
|
|
||||||
$photo->forceFill([
|
$update = [
|
||||||
'security_scan_status' => $status,
|
'security_scan_status' => $status,
|
||||||
'security_scan_message' => $message,
|
'security_scan_message' => $message,
|
||||||
'security_scanned_at' => now(),
|
'security_scanned_at' => now(),
|
||||||
'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata),
|
'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') {
|
if ($status === 'infected') {
|
||||||
Log::alert('[PhotoSecurity] Infected photo detected', [
|
Log::alert('[PhotoSecurity] Infected photo detected', [
|
||||||
|
|||||||
@@ -58,7 +58,19 @@ class BlogPost extends Model
|
|||||||
|
|
||||||
public function bannerUrl(): Attribute
|
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
|
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) {
|
RateLimiter::for('gift-lookup', function (Request $request) {
|
||||||
$code = strtoupper((string) $request->query('code'));
|
$code = strtoupper((string) $request->query('code'));
|
||||||
$ip = $request->ip() ?? 'unknown';
|
$ip = $request->ip() ?? 'unknown';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use App\Http\Middleware\EnsureTenantAdminToken;
|
|||||||
use App\Http\Middleware\EnsureTenantCollaboratorToken;
|
use App\Http\Middleware\EnsureTenantCollaboratorToken;
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\HandleAppearance;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\ResponseSecurityHeaders;
|
||||||
use App\Http\Middleware\SetLocaleFromUser;
|
use App\Http\Middleware\SetLocaleFromUser;
|
||||||
use App\Http\Middleware\TenantIsolation;
|
use App\Http\Middleware\TenantIsolation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
@@ -68,13 +69,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\App\Http\Middleware\SetLocale::class,
|
\App\Http\Middleware\SetLocale::class,
|
||||||
SetLocaleFromUser::class,
|
SetLocaleFromUser::class,
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
|
ResponseSecurityHeaders::class,
|
||||||
\App\Http\Middleware\ContentSecurityPolicy::class,
|
\App\Http\Middleware\ContentSecurityPolicy::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
\App\Http\Middleware\RequestTimingMiddleware::class,
|
\App\Http\Middleware\RequestTimingMiddleware::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->api(append: []);
|
$middleware->api(append: [
|
||||||
|
ResponseSecurityHeaders::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
74
config/cors.php
Normal file
74
config/cors.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$originFromUrl = static function (?string $url): ?string {
|
||||||
|
if (! $url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($url);
|
||||||
|
if (! $parts || ! isset($parts['scheme'], $parts['host'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = strtolower($parts['scheme'].'://'.$parts['host']);
|
||||||
|
|
||||||
|
if (isset($parts['port'])) {
|
||||||
|
$origin .= ':'.$parts['port'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
$envOrigins = array_filter(array_map('trim', explode(',', (string) env('CORS_ALLOWED_ORIGINS', ''))));
|
||||||
|
$appOrigin = $originFromUrl(env('APP_URL'));
|
||||||
|
$devOrigins = env('APP_ENV') === 'production'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'https://localhost:5173',
|
||||||
|
'https://127.0.0.1:5173',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'https://localhost:3000',
|
||||||
|
'https://127.0.0.1:3000',
|
||||||
|
];
|
||||||
|
|
||||||
|
$allowedOrigins = array_values(array_unique(array_filter(array_merge(
|
||||||
|
$envOrigins,
|
||||||
|
[$appOrigin],
|
||||||
|
$devOrigins
|
||||||
|
))));
|
||||||
|
|
||||||
|
$allowedMethods = array_filter(array_map('trim', explode(',', (string) env('CORS_ALLOWED_METHODS', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'))));
|
||||||
|
$allowedHeaders = array_filter(array_map('trim', explode(',', (string) env('CORS_ALLOWED_HEADERS', 'Content-Type,Authorization,X-Requested-With,X-Locale,X-Device-Id'))));
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure cross-origin settings for API and sanctum routes. Origins are
|
||||||
|
| env-driven to match the front-proxy allowlist (nginx/traefik).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => $allowedMethods === [] ? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] : $allowedMethods,
|
||||||
|
|
||||||
|
'allowed_origins' => $allowedOrigins === [] ? ['http://localhost', 'http://127.0.0.1'] : $allowedOrigins,
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => $allowedHeaders === [] ? ['Content-Type', 'Authorization', 'X-Requested-With'] : $allowedHeaders,
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => (bool) env('CORS_SUPPORTS_CREDENTIALS', false),
|
||||||
|
|
||||||
|
];
|
||||||
40
docs/process/changes/2025-12-08-security-review-kickoff.md
Normal file
40
docs/process/changes/2025-12-08-security-review-kickoff.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Security Review Kickoff — 2025-12-08
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Started structured security review spanning marketing/public API, Guest PWA, Event Admin, payments/webhooks, and media pipeline/storage.
|
||||||
|
- Needed a tracking checklist plus logging conventions for findings and follow-ups.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
- Added review plan with scope, workstreams, and checklists: `docs/process/todo/security-review-dec-2025.md`.
|
||||||
|
- Defined evidence logging: future session notes in `docs/process/changes/`, remediation tasks as Issues (label `TODO`) or `docs/process/todo/` entries linked to `SEC-*` tickets where applicable.
|
||||||
|
- Mapped roles/trust boundaries and inventoried marketing/public API route surfaces (locale-prefixed marketing group with throttled contact forms and signed gift voucher print; guest PWA view routes; Event Admin SPA catch-all; checkout/paddle webhook exposure; API v1 marketing + public event endpoints with throttles; tenant API protected by `auth:sanctum`, `tenant.collaborator`, `tenant.isolation`, `throttle:tenant-api`, plus `tenant.admin` on sensitive routes).
|
||||||
|
- Captured environment/header defaults: `.env.example` ships with `APP_DEBUG=true` (must be false in prod) and HTTP APP_URL; session defaults `same_site=lax`, `SESSION_ENCRYPT=false`, secure cookie flag unset; CORS uses Laravel defaults (all origins/methods, credentials=false) on `api/*`; CSP middleware added to web group sets nonce-based script-src but leaves style-src with `'unsafe-inline'` and broad https/data allowances; skips CSP when debug/local.
|
||||||
|
- Added data class/retention sketch: media storage (variants via signed URLs; tenant-governed retention), join tokens/access logs and planned hashing, auth/session tokens (Sanctum/PATs, OAuth), PII (contact form, member lists, billing contact), billing identifiers (Paddle/Stripe/PayPal), logs/analytics (Matomo consent plan, request timing middleware).
|
||||||
|
- Captured seeded test identities: Super admin (`ADMIN_EMAIL`/`ADMIN_PASSWORD`, defaults `admin@example.com` / `ChangeMe123!`), tenant admin demo (`tenant-demo@fotospiel.app` / `Demo1234!`), guest join token from demo event (`W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd`), with seed commands (`migrate --seed` or targeted seeders).
|
||||||
|
- Noted env assumptions for dynamic testing: set HTTPS `APP_URL`, secure/samesite cookies, adjust `SANCTUM_STATEFUL_DOMAINS` for SPA/PWA origins, ensure storage disks and `storage:link` ready, supply webhook secrets/URLs, and run queues for media/security jobs.
|
||||||
|
- Mapped role→data/retention and drafted dynamic testing harness scope (marketing/API abuse checks, guest PWA upload/share/cache tests, event admin CRUD/IDOR checks, webhook replay for freshness/idempotency, media pipeline AV/EXIF tests).
|
||||||
|
- Converted the testing outline into actionable checklists per surface (marketing/API, guest PWA, event admin, webhooks/billing, media pipeline, cross-cutting headers/CSRF/rate limits).
|
||||||
|
- Added guest upload gating plan (scan-before-publish, auto-approve when clean; optional manual moderation flag; signed URLs/private storage, API changes).
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- [ ] Start executing checklists (begin with marketing/API headers/CSP/CORS + guest PWA upload/share flows) and log findings with PoCs and severities.
|
||||||
|
|
||||||
|
## Findings — Marketing/API headers & Guest PWA upload/share (2025-12-08)
|
||||||
|
|
||||||
|
**Marketing/API headers & CORS**
|
||||||
|
- Missing security headers: no HSTS, Referrer-Policy, Permissions-Policy, or frame-ancestors/X-Frame-Options on responses; CSP middleware only for web group and skipped in debug/local (prod only). → Recommend adding a response headers middleware to set HSTS (HTTPS-only), Referrer-Policy (`strict-origin-when-cross-origin`), Permissions-Policy (disable camera/mic/geolocation unless required), and frame-ancestors (`'none'` or app domain).
|
||||||
|
- CSP style-src still uses `'unsafe-inline'` with broad `https:`/`data:` allowances; plan in `SEC-FE-01` covers nonce/hash rollout. Needs prioritization to remove inline allowance and tighten connect/img/font lists.
|
||||||
|
- CORS was default-open; added env-driven allowlist in `config/cors.php` (origin list from `CORS_ALLOWED_ORIGINS`, app URL origin, dev hosts) and `.env.example` defaults. Prod should set exact domains; credentials stay off unless explicitly enabled.
|
||||||
|
|
||||||
|
**Guest PWA upload/share**
|
||||||
|
- Upload validation allows `image` types including SVG; files are stored and URLs returned via `Storage::url` without sanitisation. SVGs can carry script and would be served from first-party origin, enabling stored XSS via shared links/gallery. → Recommend rejecting SVG (or sanitising server-side) for guest uploads.
|
||||||
|
- No antivirus/EXIF scrubbing triggered in upload flow: `GuestPhotoUploaded` listener only sends notifications; `security` config not wired. → Recommend enqueueing AV/EXIF jobs on upload before marking `approved`/serving assets.
|
||||||
|
- Uploaded assets are immediately marked `status='approved'` and URLs returned (likely public if disk is public). Confirm disk visibility; if public, anyone with URL can fetch regardless of token. → Recommend using signed URLs/private visibility and deferring publication until approval/security scan succeeds.
|
||||||
|
|
||||||
|
**Mitigations implemented (2025-12-08)**
|
||||||
|
- CORS tightened: new env-driven allowlist in `config/cors.php` (`CORS_ALLOWED_ORIGINS` etc.), defaults to localhost; prod should set exact domains to match Traefik/nginx.
|
||||||
|
- Security headers middleware added (`ResponseSecurityHeaders`) for web/api: sets Referrer-Policy `strict-origin-when-cross-origin`, X-Content-Type-Options nosniff, X-Frame-Options SAMEORIGIN, Permissions-Policy (camera/mic/geo disabled), and HSTS on secure non-local requests.
|
||||||
|
- Guest upload hardened: rejects SVG via `mimes` allowlist, sets uploads to `status=pending` (no file URLs in response), and dispatches `ProcessPhotoSecurityScan` on upload. Scan job now auto-approves on clean or skipped scans, rejects on infected. Gallery endpoints already filter `status=approved`, so pending items are hidden until scan finishes.
|
||||||
|
- Gallery/API assets moving to signed access: gallery listings and stats now use temporary signed routes for thumbnails/full URLs (token + photo id) instead of raw `Storage::url` where possible; queries filter to approved status. Fallbacks remain for legacy paths.
|
||||||
|
- CSP tightened: added style nonce, allowed https style sources for Stripe/Paddle, removed `style-src 'unsafe-inline'` in non-dev (dev keeps inline for Vite), and added `frame-ancestors 'self'`. Script nonce already in place.
|
||||||
|
- Branding assets signed: added signed branding asset route with path allowlist; branding logos use signed URLs; blog banners now emit signed URLs instead of raw `Storage::url`. Tenant photo resource now emits signed URLs for full/thumbnail variants.
|
||||||
185
docs/process/todo/security-review-dec-2025.md
Normal file
185
docs/process/todo/security-review-dec-2025.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Security Review (Dec 2025)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Run a structured security review across marketing frontend + public API, Guest PWA, and Event Admin to produce prioritized findings, PoCs, and remediation tasks aligned with the Security Hardening epic.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
- Threat model + scope notes.
|
||||||
|
- Findings list with severity/likelihood, PoCs, and recommended fixes.
|
||||||
|
- Follow-up tasks filed in `docs/process/todo/` or Issues (label `TODO`) mapped to existing `SEC-*` tickets where possible.
|
||||||
|
|
||||||
|
## Status (Stand 2025-12-08)
|
||||||
|
- Discovery: In progress (scope mapped; marketing/API route inventory captured).
|
||||||
|
- Code review: Not started.
|
||||||
|
- Dynamic testing: Not started.
|
||||||
|
- Reporting: Not started.
|
||||||
|
|
||||||
|
## Scope & Trust Boundaries
|
||||||
|
- Marketing site + public API (web + api route groups, CORS, rate limits).
|
||||||
|
- Guest PWA (resources/js/guest, service worker, background sync, offline cache, uploads).
|
||||||
|
- Event Admin / Tenant Admin PWA (Filament resources, React admin, OAuth2/PKCE, Sanctum).
|
||||||
|
- Payments/Webhooks (Paddle, RevenueCat), media pipeline (uploads/QR/PDF), storage visibility.
|
||||||
|
- Headers/CSP/cookies, session/config defaults, logging hygiene (no PII).
|
||||||
|
|
||||||
|
## Workstreams & Checklists
|
||||||
|
|
||||||
|
1) Foundations & Threat Model
|
||||||
|
- [x] Map roles/data:
|
||||||
|
- Marketing visitors (no auth; optional contact form PII).
|
||||||
|
- Guest attendees via join token (photos, likes, push subscriptions, optional contact/email if provided).
|
||||||
|
- Tenant collaborators/admins (event config, uploads, member lists, notifications).
|
||||||
|
- Super admins (platform-level controls).
|
||||||
|
- Automated actors (Paddle/RevenueCat webhooks, background workers/queues).
|
||||||
|
- [x] Data classes & storage/retention (baseline):
|
||||||
|
- Photos/media: stored in configured filesystem disks (see `filesystems.php`/storage pipeline), variants via signed URLs; retention governed by tenant settings (per PRP 09/10).
|
||||||
|
- Join tokens & gallery access: tokens (hashing planned), events, and access logs; rate-limit counters; short-lived signed share links.
|
||||||
|
- Account/auth: Sanctum tokens, OAuth (Google), session cookies (`same_site=lax`, secure flag env-driven), PATs; device/browser not fingerprinted by default.
|
||||||
|
- PII/contact: marketing contact form submissions (controller TBD), tenant member lists, notification preferences, billing contact details.
|
||||||
|
- Billing: Paddle/Stripe/PayPal identifiers, checkout sessions, add-on purchases; webhooks queued.
|
||||||
|
- Logs/metrics: structured logs (no PII mandate in PRP 09), Matomo analytics (consent-gated plan), request timing middleware.
|
||||||
|
- [x] Confirm env/header defaults for review:
|
||||||
|
- `.env.example` ships with `APP_DEBUG=true` and `APP_URL=http://localhost`; production must set `APP_DEBUG=false` and HTTPS URL.
|
||||||
|
- Session defaults: driver `redis` (unless overridden), `SESSION_ENCRYPT=false`, `SAME_SITE=lax`, `SESSION_SECURE_COOKIE` unset (inherits HTTPS), partitioned cookies disabled.
|
||||||
|
- CORS: default Laravel config (not customized) => `paths=['api/*','sanctum/csrf-cookie']`, `allowed_origins=['*']`, `allowed_methods=['*']`, credentials `false`.
|
||||||
|
- CSP: web group appends `ContentSecurityPolicy` middleware; in debug/local it skips header. In prod it sets nonce-based `script-src` and broad `style-src 'unsafe-inline' https: data:`; allows Stripe/Paddle/Localize, Matomo origin if configured.
|
||||||
|
- [x] Test identities / fixtures (seeded):
|
||||||
|
- Super admin: `ADMIN_EMAIL`/`ADMIN_PASSWORD` (defaults `admin@example.com` / `ChangeMe123!`) via `SuperAdminSeeder`.
|
||||||
|
- Tenant admin demo: `tenant-demo@fotospiel.app` / `Demo1234!` via `DemoTenantSeeder` (package assigned, verified, active).
|
||||||
|
- Guest tokens: `DemoEventSeeder` seeds `demo-wedding-2025` with join token `W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd` (stored hashed+encrypted). Use for guest/PWA/API tests; additional demo event without explicit token uses generated token.
|
||||||
|
- Seed commands: `php artisan migrate --seed` (or targeted seeders: `db:seed --class=SuperAdminSeeder`, `DemoTenantSeeder`, `DemoEventSeeder`).
|
||||||
|
- [ ] Env assumptions for dynamic testing:
|
||||||
|
- Base URL/HTTPS: ensure `APP_URL` points to the test host with HTTPS; set `SESSION_SECURE_COOKIE=true` and `SESSION_SAME_SITE=lax/none` as needed for cross-origin tools.
|
||||||
|
- CORS/stateful domains: configure `SANCTUM_STATEFUL_DOMAINS` to include test origins (e.g., localhost:3000/5173) for SPA/PWA flows; consider tightening CORS from `*` to allowed hosts during tests if feasible.
|
||||||
|
- Storage: confirm disks (local/s3) and public assets linkage (`storage:link`) for media tests; signed URL generation in place.
|
||||||
|
- Webhooks: set Paddle/RevenueCat webhook secrets and target URLs; use throttling expectations (`throttle:60,1` on revenuecat; none on paddle webhooks).
|
||||||
|
- Queues: ensure queue workers running for uploads/scan jobs when exercising media pipelines.
|
||||||
|
|
||||||
|
## Role → Data/Storage/Retention Mapping (initial)
|
||||||
|
- Marketing visitor: contact form PII (controller storage TBD); cookies/localStorage for locale/consent; Matomo analytics (consent-gated); no persistent account.
|
||||||
|
- Guest (join token): event/gallery access via token; uploads (photo + EXIF), likes, push subscription keys; cached assets in PWA/service worker; signed share links; rate-limit counters and join-token access logs; retention tied to event/gallery expiry and tenant settings.
|
||||||
|
- Tenant collaborator/admin: account profile, tenant settings, events, members, notifications, tasks, uploads; billing identifiers for purchases; OAuth/Google tokens; Sanctum PATs/session cookies; audit/logs for actions; retention per tenant policy, legal retention for billing.
|
||||||
|
- Super admin: same as tenant admin plus platform-level audit/actions; impersonation logs expected; no extra PII beyond account.
|
||||||
|
- Webhooks (Paddle/RevenueCat): payload identifiers, signatures, session linkage; stored in webhook logs/queue jobs; retention per ops runbook.
|
||||||
|
|
||||||
|
## Dynamic Testing Harness Outline (draft)
|
||||||
|
- Identities: use seeded super admin, tenant demo, and demo guest token for auth contexts; create additional tenant collaborator if needed.
|
||||||
|
- Environments: run against local HTTPS host with `APP_URL` set; configure `SANCTUM_STATEFUL_DOMAINS` and cookies for Playwright/DAST sessions; ensure queues running.
|
||||||
|
- Surfaces to script:
|
||||||
|
- Marketing/API: contact form abuse/rate limit, coupon preview, gift voucher flows; check CORS preflight and CSP headers.
|
||||||
|
- Guest PWA: join token gallery load, photo upload (valid/invalid), like/share, push subscription register/delete, offline/cache poison checks; download/share signed URL enforcement.
|
||||||
|
- Event Admin: login (email/password + Google), CRUD on events/photos/members/tasks, package purchase intents (non-payment), photobooth enable/rotate; policy/IDOR checks.
|
||||||
|
- Webhooks: replay signed webhook samples (Paddle/RevenueCat) with stale timestamps to validate signature freshness and idempotency behavior.
|
||||||
|
- Media pipeline: upload with EXIF/malware test samples to observe AV/EXIF handling; verify signed URL visibility and expiry.
|
||||||
|
|
||||||
|
## Dynamic Testing Checklists (actionable)
|
||||||
|
- Marketing/API
|
||||||
|
- [ ] Verify CSP headers present in non-debug env; confirm nonce on scripts, no stray inline scripts/styles; note `'unsafe-inline'` styles as risk.
|
||||||
|
- [ ] Exercise contact form with/without JS; confirm throttling and spam validation; inspect error leakage.
|
||||||
|
- [ ] Coupon preview/gift voucher endpoints: validate rate limits, auth bypass attempts, input validation, CORS preflight, and response caching headers.
|
||||||
|
- [ ] Checkout wizard/login/register endpoints: session handling, CSRF, rate limits; ensure APP_DEBUG off to avoid stack traces.
|
||||||
|
- [ ] Public routes return 404/redirects without leaking internal paths.
|
||||||
|
|
||||||
|
- Guest PWA
|
||||||
|
- [ ] Gallery load with seeded token: check caching headers, ETag, and denial for invalid/expired token.
|
||||||
|
- [ ] Upload tests: valid image, oversized, wrong MIME, EXIF-laden, EICAR sample; expect AV/EXIF handling and clear errors.
|
||||||
|
- [ ] Likes/share: ensure signed share links required; verify signed asset URLs enforce expiry and token scope.
|
||||||
|
- [ ] Push subscription register/delete flows with bad payloads; ensure CORS/preflight and auth tied to token.
|
||||||
|
- [ ] Service worker/cache: verify scope, versioning, offline fallback, and resistance to cache poisoning (stale manifest/assets).
|
||||||
|
|
||||||
|
- Event Admin
|
||||||
|
- [ ] Login (email/password) and Google OAuth flow happy/failure paths; session fixation/regeneration checks.
|
||||||
|
- [ ] CRUD events/photos/members/tasks with tenant slug mismatch to probe IDOR; verify `tenant.isolation` + policies.
|
||||||
|
- [ ] Package purchase/payment-intent endpoints without completing payment—check idempotency/validation.
|
||||||
|
- [ ] Photobooth enable/rotate/disable endpoints with/without admin role.
|
||||||
|
- [ ] API rate limiting (`throttle:tenant-api`) and error shape consistency; check storage visibility toggles.
|
||||||
|
|
||||||
|
- Webhooks & Billing
|
||||||
|
- [ ] Replay Paddle/RevenueCat payloads with valid and stale timestamps; confirm signature verification and replay protection.
|
||||||
|
- [ ] Send duplicate IDs to test idempotency locks and queueing behavior; observe logs without PII leakage.
|
||||||
|
- [ ] Ensure webhook routes respect expected throttles (RevenueCat 60/min; Paddle currently none—note risk).
|
||||||
|
|
||||||
|
- Media Pipeline & Storage
|
||||||
|
- [ ] Signed URL expiry for gallery/download/share links; attempts outside tenant/token should fail.
|
||||||
|
- [ ] Verify private visibility defaults on new uploads and derivatives; public bucket exposure check.
|
||||||
|
- [ ] AV/EXIF queue path fires on upload; monitor job logs for failures.
|
||||||
|
- [x] Replace public URLs: gallery assets and branding/blog banners now use signed routes; tenant photo resource uses signed URLs for variants.
|
||||||
|
|
||||||
|
- Cross-cutting
|
||||||
|
- [ ] Headers: HSTS, X-Frame-Options/Frame-Ancestors, Referrer-Policy, Permissions-Policy; note gaps.
|
||||||
|
- [ ] CSRF on web forms and SPA flows; session cookie flags (Secure/HttpOnly/SameSite) over HTTPS.
|
||||||
|
- [ ] Rate limits alignment with documented policies; error messages avoid stack traces and sensitive data.
|
||||||
|
|
||||||
|
## CSP Tightening Plan
|
||||||
|
- Add style nonces everywhere inline styles exist (root blade/templates) and remove `style-src 'unsafe-inline'` outside dev.
|
||||||
|
- Ensure script nonce is applied (already set via Vite); audit any inline event handlers.
|
||||||
|
- Add `frame-ancestors 'self'` to CSP to align with X-Frame-Options.
|
||||||
|
|
||||||
|
## Guest Upload Gating Plan (scan-before-publish, auto-approve; optional moderation)
|
||||||
|
- Goals: keep guest uploads pending until AV/EXIF scan completes; auto-approve if clean; optional moderation toggle for tenants that want manual review; serve assets via signed URLs/private storage after approval.
|
||||||
|
- Storage/visibility:
|
||||||
|
- Store uploads on private disk (no public `Storage::url`); serve via signed URLs scoped to event/token with short TTL.
|
||||||
|
- Keep `status=pending` until scan completes; do not expose paths in API responses until approved.
|
||||||
|
- Security scanning:
|
||||||
|
- Dispatch `ProcessPhotoSecurityScan` on upload; mark `security_scan_status` and `security_scanned_at`.
|
||||||
|
- If infected/error: mark `rejected` with reason; optionally delete/quarantine asset and log.
|
||||||
|
- Approval workflow:
|
||||||
|
- Default: auto-approve when scan returns clean. Optional tenant/event flag `photo_upload_requires_manual_approval` to hold after scan for manual review (default off).
|
||||||
|
- Pending uploads can surface in admin (list + bulk approve/reject) only when manual flag is on.
|
||||||
|
- API changes:
|
||||||
|
- Guest upload response: return pending state and no direct file URLs while pending.
|
||||||
|
- Gallery/photos endpoints: filter to approved only; include pending count for admin if manual flag is on.
|
||||||
|
- Signed URL generation: use `Storage::temporaryUrl` or signed route; avoid raw public paths.
|
||||||
|
- Rate/abuse controls:
|
||||||
|
- Preserve per-token/device limits; consider stricter throttles while approval is enabled.
|
||||||
|
- Log join-token usage and anomalies for audit.
|
||||||
|
- Migration/rollout:
|
||||||
|
- Backfill existing photos to `approved` to avoid breaking live galleries.
|
||||||
|
- Feature flag to enable per-tenant/event; add config toggle and admin UI.
|
||||||
|
- Testing:
|
||||||
|
- Feature tests for pending upload, approval flow, rejection, and signed URL access control; scan failure path blocks approval.
|
||||||
|
- [x] Trust boundaries/entrypoints:
|
||||||
|
- Marketing/Inertia under `/{locale}` prefix (`de|en`) with session/Accept-Language fallback redirect; login/register guarded by `guest` middleware; contact forms throttled (`throttle:contact-form`); gift voucher print uses `signed`.
|
||||||
|
- Guest PWA entry at `/event`, `/g/{token}`, `/e/{token}/{path?}`, `/share/{slug}` (views rendered by `guest` blade; tokens unauthed).
|
||||||
|
- Event Admin shell under `/event-admin/*` with Google OAuth endpoints; auth enforced in controller; SPA catch-all `/{view?}`.
|
||||||
|
- Checkout endpoints always exposed (`/purchase-wizard/{package}`, `/checkout/{package}`, `/checkout/*` helpers, `/paddle/webhook`); Paddle webhook lacks explicit throttle middleware.
|
||||||
|
- API entry at `/api/v1` (see details below); testing-only routes gated by env check.
|
||||||
|
|
||||||
|
2) Marketing Frontend + Public API
|
||||||
|
- [x] Inventory routes/middleware (auth, rate limits, CORS, cache/ETag) and note anonymous vs authenticated paths:
|
||||||
|
- Marketing web routes: locale-prefixed group; auth pages gated by `guest`; contact/kontakt POST throttled; gift voucher print signed; profile/voucher-status require `auth`; marketing fallbacks render Inertia 404. Legacy unprefixed routes redirect to locale-prefixed equivalents.
|
||||||
|
- Event Admin: `/event-admin` routes include auth/login/logout/dashboard and SPA catch-all; rely on controller auth checks (middleware not on route).
|
||||||
|
- Guest PWA: view routes for gallery/event/share are unauthenticated; token patterns unconstrained except share slug regex.
|
||||||
|
- Checkout: purchase-wizard/checkout routes toggled by `config('checkout.enabled')`; login/register/track-abandoned POSTs exposed; Paddle webhook route has no rate limit middleware.
|
||||||
|
- Public API: `api/v1` marketing coupon/gift voucher endpoints throttled (`throttle:*`), RevenueCat webhook throttled `60,1`. Public event/gallery endpoints grouped under `throttle:100,1`; signed URLs for share assets/downloads; uploads exposed at `/events/{token}/upload` and `/photobooth/sparkbooth/upload`.
|
||||||
|
- Tenant API: `auth:sanctum`, `tenant.collaborator`, `tenant.isolation`, `throttle:tenant-api` on `/api/v1/tenant/*`; many routes further gated by `tenant.admin`; package/credit checks on event mutations; signed download/layout routes within tenant scope.
|
||||||
|
- [ ] Review controllers/resources for authz (policies/gates), FormRequest validation, mass assignment, IDOR risks.
|
||||||
|
- [ ] Check response handling (error leakage, pagination limits, idempotency on mutations).
|
||||||
|
- [ ] Review CSP/headers/cookies and analytics gating; verify no `unsafe-inline` without nonce/hash plan.
|
||||||
|
|
||||||
|
3) Guest PWA
|
||||||
|
- [ ] Verify join token handling (hashing/migration alignment with `SEC-GT-*`), gallery/photo rate limits, throttling per token/IP.
|
||||||
|
- [ ] Inspect upload validation (MIME/size/dimensions), background sync request signing, storage visibility.
|
||||||
|
- [ ] Audit service worker scope, cache versioning/poisoning risk, offline fallbacks, and CSP for PWA.
|
||||||
|
|
||||||
|
4) Event Admin (Filament + React Admin)
|
||||||
|
- [ ] Audit Filament resources/actions for policy checks, scoping, mass assignment guards.
|
||||||
|
- [ ] Confirm OAuth2/PKCE + Sanctum session handling, role checks, impersonation/tenant boundary controls.
|
||||||
|
- [ ] Review file handling inside admin (imports/exports/PDF/QR) for SSRF/path traversal.
|
||||||
|
|
||||||
|
5) Payments & Webhooks
|
||||||
|
- [ ] Validate Paddle/RevenueCat webhook signature verification, timestamp/replay defense, idempotency locks, queueing.
|
||||||
|
- [ ] Check linkage between webhooks and checkout/session state; ensure failures alert and redact PII.
|
||||||
|
|
||||||
|
6) Media Pipeline & Storage
|
||||||
|
- [ ] Confirm AV/EXIF scanning coverage, checksum verification, and private visibility defaults.
|
||||||
|
- [ ] Review signed URL usage/expiry, path traversal protections, and storage bucket separation per tenant.
|
||||||
|
|
||||||
|
7) Dynamic Testing
|
||||||
|
- [ ] Set up test identities (guest token, tenant admin, super admin) and auth contexts for tooling.
|
||||||
|
- [ ] Run targeted DAST/Playwright flows for each surface (authn/z, uploads, rate limiting, CORS preflight).
|
||||||
|
- [ ] Fuzz uploads (images/metadata) and verify rejection paths + logging.
|
||||||
|
|
||||||
|
## Evidence & Logging
|
||||||
|
- Log session notes and findings in `docs/process/changes/YYYY-MM-DD-security-review-*.md`.
|
||||||
|
- Update checklist statuses here after each pass.
|
||||||
|
- Open issues for remediation items, linking back to findings and relevant `SEC-*` tickets.
|
||||||
@@ -23,19 +23,26 @@
|
|||||||
]
|
]
|
||||||
: ['enabled' => false];
|
: ['enabled' => false];
|
||||||
@endphp
|
@endphp
|
||||||
<script>
|
<script nonce="{{ $cspNonce }}">
|
||||||
window.__MATOMO_ADMIN__ = {!! json_encode($matomoAdmin) !!};
|
window.__MATOMO_ADMIN__ = {!! json_encode($matomoAdmin) !!};
|
||||||
</script>
|
</script>
|
||||||
|
<style nonce="{{ $cspStyleNonce }}">
|
||||||
|
#root { min-height: 100vh; }
|
||||||
|
.ns-admin-bg { background: #0b1224; color: #fff; }
|
||||||
|
.ns-btn-primary { color: #fff; text-decoration: none; background: #ec4899; }
|
||||||
|
.ns-btn-outline { color: #e5e7eb; text-decoration: none; border: 1px solid rgba(255,255,255,0.2); }
|
||||||
|
.ns-card-border { border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.05); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@php
|
@php
|
||||||
$noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
|
$noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
|
||||||
@endphp
|
@endphp
|
||||||
<noscript>
|
<noscript>
|
||||||
<style>
|
<style nonce="{{ $cspStyleNonce }}">
|
||||||
#root { display: none !important; }
|
#root { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
<div class="min-h-screen bg-slate-950 text-white" style="background:#0b1224;color:#fff;">
|
<div class="min-h-screen bg-slate-950 text-white ns-admin-bg">
|
||||||
<div class="mx-auto flex max-w-4xl flex-col gap-10 px-6 py-14">
|
<div class="mx-auto flex max-w-4xl flex-col gap-10 px-6 py-14">
|
||||||
<header class="space-y-2">
|
<header class="space-y-2">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-pink-300">Fotospiel Admin</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-pink-300">Fotospiel Admin</p>
|
||||||
@@ -44,7 +51,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid gap-4 sm:grid-cols-2">
|
<section class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 shadow-lg backdrop-blur" style="border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);">
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 shadow-lg backdrop-blur ns-card-border">
|
||||||
<h2 class="text-xl font-semibold text-white">Warum JS?</h2>
|
<h2 class="text-xl font-semibold text-white">Warum JS?</h2>
|
||||||
<ul class="mt-3 space-y-2 text-sm text-white/80">
|
<ul class="mt-3 space-y-2 text-sm text-white/80">
|
||||||
<li>• Echtzeit-Listen für Fotos, Tasks und Emotion-Tags</li>
|
<li>• Echtzeit-Listen für Fotos, Tasks und Emotion-Tags</li>
|
||||||
@@ -53,7 +60,7 @@
|
|||||||
<li>• Sichere OAuth2-Session mit PKCE</li>
|
<li>• Sichere OAuth2-Session mit PKCE</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 shadow-lg backdrop-blur" style="border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);">
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 shadow-lg backdrop-blur ns-card-border">
|
||||||
<h2 class="text-xl font-semibold text-white">Nächste Schritte</h2>
|
<h2 class="text-xl font-semibold text-white">Nächste Schritte</h2>
|
||||||
<ol class="mt-3 space-y-2 text-sm text-white/80">
|
<ol class="mt-3 space-y-2 text-sm text-white/80">
|
||||||
<li>1) JavaScript im Browser aktivieren</li>
|
<li>1) JavaScript im Browser aktivieren</li>
|
||||||
@@ -61,13 +68,13 @@
|
|||||||
<li>3) Optional: Admin-App zum Homescreen hinzufügen</li>
|
<li>3) Optional: Admin-App zum Homescreen hinzufügen</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<a href="{{ route('marketing.contact', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full bg-pink-500 px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-400" style="color:#fff;text-decoration:none;background:#ec4899;">
|
<a href="{{ route('marketing.contact', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full bg-pink-500 px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-400 ns-btn-primary">
|
||||||
Support kontaktieren
|
Support kontaktieren
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('impressum', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40" style="color:#e5e7eb;text-decoration:none;border:1px solid rgba(255,255,255,0.2);">
|
<a href="{{ route('impressum', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40 ns-btn-outline">
|
||||||
Impressum
|
Impressum
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('datenschutz', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40" style="color:#e5e7eb;text-decoration:none;border:1px solid rgba(255,255,255,0.2);">
|
<a href="{{ route('datenschutz', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40 ns-btn-outline">
|
||||||
Datenschutz
|
Datenschutz
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,20 +24,26 @@
|
|||||||
]
|
]
|
||||||
: ['enabled' => false];
|
: ['enabled' => false];
|
||||||
@endphp
|
@endphp
|
||||||
<script>
|
<script nonce="{{ $cspNonce }}">
|
||||||
window.__GUEST_RUNTIME_CONFIG__ = {!! json_encode($guestRuntimeConfig) !!};
|
window.__GUEST_RUNTIME_CONFIG__ = {!! json_encode($guestRuntimeConfig) !!};
|
||||||
window.__MATOMO_GUEST__ = {!! json_encode($matomoGuest) !!};
|
window.__MATOMO_GUEST__ = {!! json_encode($matomoGuest) !!};
|
||||||
</script>
|
</script>
|
||||||
|
<style nonce="{{ $cspStyleNonce }}">
|
||||||
|
#root { min-height: 100vh; }
|
||||||
|
.ns-bg { background: linear-gradient(180deg,#0f172a 0%,#111827 50%,#0b1224 100%); color: #fff; }
|
||||||
|
.ns-btn-primary { color: #fff; text-decoration: none; background: #ec4899; }
|
||||||
|
.ns-btn-outline { color: #e5e7eb; text-decoration: none; border: 1px solid rgba(255,255,255,0.2); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@php
|
@php
|
||||||
$noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
|
$noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
|
||||||
@endphp
|
@endphp
|
||||||
<noscript>
|
<noscript>
|
||||||
<style>
|
<style nonce="{{ $cspStyleNonce }}">
|
||||||
#root { display: none !important; }
|
#root { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
<div class="min-h-screen bg-gradient-to-b from-[#0f172a] via-[#111827] to-[#0b1224] text-white" style="background:linear-gradient(180deg,#0f172a 0%,#111827 50%,#0b1224 100%);color:#fff;">
|
<div class="min-h-screen bg-gradient-to-b from-[#0f172a] via-[#111827] to-[#0b1224] text-white ns-bg">
|
||||||
<div class="mx-auto flex max-w-5xl flex-col gap-12 px-6 py-14">
|
<div class="mx-auto flex max-w-5xl flex-col gap-12 px-6 py-14">
|
||||||
<header class="space-y-3 text-center">
|
<header class="space-y-3 text-center">
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.18em] text-pink-300">Fotospiel</p>
|
<p class="text-sm font-semibold uppercase tracking-[0.18em] text-pink-300">Fotospiel</p>
|
||||||
@@ -65,13 +71,13 @@
|
|||||||
<li>3) Optional: Füge die App deinem Homescreen hinzu</li>
|
<li>3) Optional: Füge die App deinem Homescreen hinzu</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<a href="{{ route('marketing.contact', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full bg-pink-500 px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-400" style="color:#fff;text-decoration:none;background:#ec4899;">
|
<a href="{{ route('marketing.contact', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full bg-pink-500 px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-400 ns-btn-primary">
|
||||||
Support kontaktieren
|
Support kontaktieren
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('impressum', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40" style="color:#e5e7eb;text-decoration:none;border:1px solid rgba(255,255,255,0.2);">
|
<a href="{{ route('impressum', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40 ns-btn-outline">
|
||||||
Impressum
|
Impressum
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('datenschutz', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40" style="color:#e5e7eb;text-decoration:none;border:1px solid rgba(255,255,255,0.2);">
|
<a href="{{ route('datenschutz', ['locale' => $noscriptLocale]) }}" class="inline-flex items-center justify-center rounded-full border border-white/20 px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-white/40 ns-btn-outline">
|
||||||
Datenschutz
|
Datenschutz
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->middleware('signed')
|
->middleware('signed')
|
||||||
->name('photo-shares.asset');
|
->name('photo-shares.asset');
|
||||||
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
||||||
|
Route::get('/branding/asset/{path}', [EventPublicController::class, 'brandingAsset'])
|
||||||
|
->where('path', '.*')
|
||||||
|
->middleware('signed')
|
||||||
|
->name('branding.asset');
|
||||||
|
|
||||||
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');
|
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');
|
||||||
Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos');
|
Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos');
|
||||||
|
|||||||
@@ -338,4 +338,6 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create');
|
Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('/paddle/webhook', [PaddleWebhookController::class, 'handle'])->name('paddle.webhook');
|
Route::post('/paddle/webhook', [PaddleWebhookController::class, 'handle'])
|
||||||
|
->middleware('throttle:paddle-webhook')
|
||||||
|
->name('paddle.webhook');
|
||||||
|
|||||||
52
tests/Feature/Api/Event/BrandingAssetTest.php
Normal file
52
tests/Feature/Api/Event/BrandingAssetTest.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BrandingAssetTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_branding_asset_serves_signed_file(): void
|
||||||
|
{
|
||||||
|
Config::set('filesystems.default', 'public');
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$path = 'branding/logo.png';
|
||||||
|
Storage::disk('public')->put($path, 'branding-content');
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute(
|
||||||
|
'api.v1.branding.asset',
|
||||||
|
now()->addMinutes(5),
|
||||||
|
['path' => $path]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertSame('branding-content', $response->streamedContent());
|
||||||
|
$this->assertStringContainsString('max-age=3600', $response->headers->get('Cache-Control'));
|
||||||
|
$this->assertStringContainsString('private', $response->headers->get('Cache-Control'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_branding_asset_rejects_invalid_path(): void
|
||||||
|
{
|
||||||
|
Config::set('filesystems.default', 'public');
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute(
|
||||||
|
'api.v1.branding.asset',
|
||||||
|
now()->addMinutes(5),
|
||||||
|
['path' => '../.env']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/Feature/Api/Event/EventUploadSecurityTest.php
Normal file
106
tests/Feature/Api/Event/EventUploadSecurityTest.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use App\Jobs\ProcessPhotoSecurityScan;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\EventType;
|
||||||
|
use App\Models\MediaStorageTarget;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventUploadSecurityTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guest_upload_rejects_svg_and_does_not_dispatch_scan(): void
|
||||||
|
{
|
||||||
|
[$token] = $this->prepareUploadContext();
|
||||||
|
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/v1/events/{$token}/upload", [
|
||||||
|
'photo' => UploadedFile::fake()->create('evil.svg', 10, 'image/svg+xml'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_upload_dispatches_security_scan_for_valid_image(): void
|
||||||
|
{
|
||||||
|
[$token] = $this->prepareUploadContext();
|
||||||
|
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/v1/events/{$token}/upload", [
|
||||||
|
'photo' => UploadedFile::fake()->image('photo.jpg', 800, 600),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonPath('status', 'pending');
|
||||||
|
|
||||||
|
Bus::assertDispatched(ProcessPhotoSecurityScan::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{string}
|
||||||
|
*/
|
||||||
|
private function prepareUploadContext(): array
|
||||||
|
{
|
||||||
|
config(['filesystems.default' => 'public']);
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$package = Package::factory()->endcustomer()->create([
|
||||||
|
'max_photos' => 100,
|
||||||
|
'gallery_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eventType = EventType::factory()->create();
|
||||||
|
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
|
'status' => 'published',
|
||||||
|
'photo_upload_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MediaStorageTarget::create([
|
||||||
|
'key' => 'public',
|
||||||
|
'name' => 'Public (test)',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [
|
||||||
|
'root' => Storage::disk('public')->path(''),
|
||||||
|
'url' => 'http://localhost/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
],
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
EventPackage::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => $package->price,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'used_photos' => 0,
|
||||||
|
'gallery_expires_at' => now()->addDays($package->gallery_days ?? 30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)
|
||||||
|
->createToken($event, ['label' => 'Test upload'])
|
||||||
|
->getAttribute('plain_token');
|
||||||
|
|
||||||
|
return [$token];
|
||||||
|
}
|
||||||
|
}
|
||||||
169
tests/Feature/Jobs/ProcessPhotoSecurityScanTest.php
Normal file
169
tests/Feature/Jobs/ProcessPhotoSecurityScanTest.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\ProcessPhotoSecurityScan;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventMediaAsset;
|
||||||
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\EventType;
|
||||||
|
use App\Models\MediaStorageTarget;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Security\PhotoSecurityScanner;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProcessPhotoSecurityScanTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_clean_scan_auto_approves_pending_photo(): void
|
||||||
|
{
|
||||||
|
[$photo, $asset] = $this->seedPhotoWithAsset();
|
||||||
|
|
||||||
|
$scanner = new class extends PhotoSecurityScanner {
|
||||||
|
public function scan(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'clean', 'message' => 'ok'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stripExif(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'stripped', 'message' => 'removed'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(new ProcessPhotoSecurityScan($photo->id))->handle($scanner);
|
||||||
|
|
||||||
|
$photo->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('approved', $photo->status);
|
||||||
|
$this->assertSame('clean', $photo->security_scan_status);
|
||||||
|
$this->assertNotNull($photo->security_scanned_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skipped_scan_still_approves_pending_photo(): void
|
||||||
|
{
|
||||||
|
[$photo] = $this->seedPhotoWithAsset();
|
||||||
|
|
||||||
|
$scanner = new class extends PhotoSecurityScanner {
|
||||||
|
public function scan(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'skipped', 'message' => 'disabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stripExif(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'skipped'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(new ProcessPhotoSecurityScan($photo->id))->handle($scanner);
|
||||||
|
|
||||||
|
$photo->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('approved', $photo->status);
|
||||||
|
$this->assertSame('skipped', $photo->security_scan_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_infected_scan_rejects_photo(): void
|
||||||
|
{
|
||||||
|
[$photo, $asset] = $this->seedPhotoWithAsset();
|
||||||
|
|
||||||
|
$scanner = new class extends PhotoSecurityScanner {
|
||||||
|
public function scan(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'infected', 'message' => 'bad'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stripExif(string $disk, ?string $relativePath): array
|
||||||
|
{
|
||||||
|
return ['status' => 'skipped'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(new ProcessPhotoSecurityScan($photo->id))->handle($scanner);
|
||||||
|
|
||||||
|
$photo->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('rejected', $photo->status);
|
||||||
|
$this->assertSame('infected', $photo->security_scan_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{Photo, EventMediaAsset}
|
||||||
|
*/
|
||||||
|
private function seedPhotoWithAsset(): array
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$eventType = EventType::factory()->create();
|
||||||
|
$package = Package::factory()->endcustomer()->create([
|
||||||
|
'max_photos' => 100,
|
||||||
|
'gallery_days' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
|
'status' => 'published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
EventPackage::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => $package->price,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'used_photos' => 0,
|
||||||
|
'gallery_expires_at' => now()->addDays($package->gallery_days ?? 30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = "events/{$event->id}/photos/example.jpg";
|
||||||
|
Storage::disk('public')->put($path, 'fake-image');
|
||||||
|
|
||||||
|
$target = MediaStorageTarget::create([
|
||||||
|
'key' => 'public',
|
||||||
|
'name' => 'Public (test)',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [
|
||||||
|
'root' => Storage::disk('public')->path(''),
|
||||||
|
'url' => 'http://localhost/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
],
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$photo = Photo::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'guest_name' => 'tester',
|
||||||
|
'file_path' => $path,
|
||||||
|
'thumbnail_path' => $path,
|
||||||
|
'status' => 'pending',
|
||||||
|
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset = EventMediaAsset::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'media_storage_target_id' => $target->id,
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $path,
|
||||||
|
'variant' => 'original',
|
||||||
|
'status' => 'hot',
|
||||||
|
'processed_at' => now(),
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'size_bytes' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$photo->update(['media_asset_id' => $asset->id]);
|
||||||
|
|
||||||
|
return [$photo, $asset];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user