Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty

states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
    (resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
  - Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
    routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
    resources/js/admin/router.tsx, routes/web.php)
This commit is contained in:
Codex Agent
2025-10-19 23:00:47 +02:00
parent a949c8d3af
commit 6290a3a448
95 changed files with 3708 additions and 394 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthListKeysCommand extends Command
{
protected $signature = 'oauth:list-keys {--json : Output as JSON for scripting}';
protected $description = 'List available JWT signing key directories and their status.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->error("Key store path does not exist: {$storage}");
return self::FAILURE;
}
$directories = collect(File::directories($storage))
->filter(fn ($path) => Str::lower(basename($path)) !== 'archive')
->values()
->map(function (string $path) use ($currentKid) {
$kid = basename($path);
$publicKey = $path.DIRECTORY_SEPARATOR.'public.key';
$privateKey = $path.DIRECTORY_SEPARATOR.'private.key';
return [
'kid' => $kid,
'status' => $kid === $currentKid ? 'current' : 'legacy',
'public' => File::exists($publicKey),
'private' => File::exists($privateKey),
'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null,
'path' => $path,
];
})
->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid'])
->values();
if ($this->option('json')) {
$this->line($directories->toJson(JSON_PRETTY_PRINT));
return self::SUCCESS;
}
if ($directories->isEmpty()) {
$this->warn('No signing key directories found.');
return self::SUCCESS;
}
$this->table(
['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'],
$directories->map(fn ($entry) => [
$entry['kid'],
$entry['status'],
$entry['public'] ? 'yes' : 'no',
$entry['private'] ? 'yes' : 'no',
$entry['updated_at'] ?? 'n/a',
$entry['path'],
])
);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthPruneKeysCommand extends Command
{
protected $signature = 'oauth:prune-keys
{--days=90 : Prune keys whose directories were last modified before this many days ago}
{--dry-run : Show which keys would be removed without deleting}
{--force : Skip confirmation prompt}';
protected $description = 'Remove legacy JWT signing keys older than the configured threshold.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->error("Key store path does not exist: {$storage}");
return self::FAILURE;
}
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$candidates = collect(File::directories($storage))
->reject(fn ($path) => Str::lower(basename($path)) === 'archive')
->filter(function (string $path) use ($currentKid, $cutoff) {
$kid = basename($path);
if ($kid === $currentKid) {
return false;
}
$lastModified = File::lastModified($path);
return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified));
})
->values();
if ($candidates->isEmpty()) {
$this->info("No legacy key directories older than {$days} days were found.");
return self::SUCCESS;
}
$this->table(
['KID', 'Last Modified', 'Path'],
$candidates->map(fn ($path) => [
basename($path),
date('c', File::lastModified($path)),
$path,
])
);
if ($this->option('dry-run')) {
$this->info('Dry run complete. No keys were removed.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) {
$this->warn('Prune cancelled.');
return self::SUCCESS;
}
foreach ($candidates as $path) {
File::deleteDirectory($path);
}
$this->info('Legacy key directories pruned.');
return self::SUCCESS;
}
}

View File

@@ -42,9 +42,12 @@ class OAuthRotateKeysCommand extends Command
if ($archiveDir) {
$this->line("Previous keys archived at: {$archiveDir}");
$this->line('Existing key remains available for token verification until you prune it.');
}
$this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}");
$this->info('Run `php artisan oauth:list-keys` to verify active signing directories.');
$this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.');
return self::SUCCESS;
}
@@ -58,7 +61,7 @@ class OAuthRotateKeysCommand extends Command
if (File::exists($existingDir)) {
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
File::ensureDirectoryExists(dirname($archiveDir));
File::moveDirectory($existingDir, $archiveDir);
File::copyDirectory($existingDir, $archiveDir);
return $archiveDir;
}
@@ -67,11 +70,11 @@ class OAuthRotateKeysCommand extends Command
File::ensureDirectoryExists($archiveDir);
if (File::exists($legacyPublic)) {
File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
}
if (File::exists($legacyPrivate)) {
File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
}
return $archiveDir;
@@ -108,4 +111,3 @@ class OAuthRotateKeysCommand extends Command
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
}
}

View File

@@ -63,19 +63,24 @@ class SendAbandonedCheckoutReminders extends Command
$resumeUrl = $this->generateResumeUrl($checkout);
if (!$isDryRun) {
Mail::to($checkout->user)->queue(
new AbandonedCheckout(
$checkout->user,
$checkout->package,
$stage,
$resumeUrl
)
);
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
Mail::to($checkout->user)
->locale($mailLocale)
->queue(
new AbandonedCheckout(
$checkout->user,
$checkout->package,
$stage,
$resumeUrl
)
);
$checkout->updateReminderStage($stage);
$totalSent++;
} else {
$this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$checkout->package->name}");
$packageName = $checkout->package->getNameForLocale($checkout->user->preferred_locale ?? config('app.locale'));
$this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$packageName}");
}
$totalProcessed++;

View File

@@ -7,7 +7,9 @@ use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationM
use App\Models\Event;
use App\Models\EventType;
use App\Models\Tenant;
use App\Models\EventJoinTokenEvent;
use App\Support\JoinTokenLayoutRegistry;
use Carbon\Carbon;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
@@ -149,36 +151,89 @@ class EventResource extends Resource
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get()
->map(function ($token) use ($record) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
->get();
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/' . $token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
];
if ($tokens->isEmpty()) {
return view('filament.events.join-link', [
'event' => $record,
'tokens' => collect(),
]);
}
$tokenIds = $tokens->pluck('id');
$now = now();
$totals = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, event_type, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->groupBy('event_join_token_id', 'event_type')
->get()
->groupBy('event_join_token_id');
$recent24h = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->where('occurred_at', '>=', $now->copy()->subHours(24))
->groupBy('event_join_token_id')
->pluck('total', 'event_join_token_id');
$lastSeen = EventJoinTokenEvent::query()
->whereIn('event_join_token_id', $tokenIds)
->selectRaw('event_join_token_id, MAX(occurred_at) as last_at')
->groupBy('event_join_token_id')
->pluck('last_at', 'event_join_token_id');
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
$analyticsGroup = $totals->get($token->id, collect());
$analytics = $analyticsGroup->mapWithKeys(function ($row) {
return [$row->event_type => (int) $row->total];
});
$successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0);
$failureCount = (int) ($analytics['invalid_token'] ?? 0)
+ (int) ($analytics['token_expired'] ?? 0)
+ (int) ($analytics['token_revoked'] ?? 0)
+ (int) ($analytics['token_rate_limited'] ?? 0)
+ (int) ($analytics['event_not_public'] ?? 0)
+ (int) ($analytics['gallery_expired'] ?? 0);
$lastSeenAt = $lastSeen->get($token->id);
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/' . $token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
'analytics' => [
'success_total' => $successCount,
'failure_total' => $failureCount,
'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0),
'recent_24h' => (int) $recent24h->get($token->id, 0),
'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null,
],
];
});
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource\RelationManagers\AuditsRelationManager;
use App\Models\RefreshToken;
use BackedEnum;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
class RefreshTokenResource extends Resource
{
protected static ?string $model = RefreshToken::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 32;
public static function getNavigationGroup(): string
{
return __('admin.nav.security');
}
public static function getNavigationLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getPluralLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getModelLabel(): string
{
return __('admin.refresh_tokens.single');
}
public static function form(Schema $form): Schema
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label(__('admin.refresh_tokens.fields.tenant'))
->searchable(),
Tables\Columns\TextColumn::make('client_id')
->label(__('admin.refresh_tokens.fields.client'))
->copyable()
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label(__('admin.refresh_tokens.fields.ip_address'))
->toggleable(),
Tables\Columns\TextColumn::make('user_agent')
->label(__('admin.refresh_tokens.fields.user_agent'))
->limit(40)
->tooltip(fn (RefreshToken $record) => $record->user_agent)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.refresh_tokens.fields.created_at'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('last_used_at')
->label(__('admin.refresh_tokens.fields.last_used_at'))
->since()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('expires_at')
->label(__('admin.refresh_tokens.fields.expires_at'))
->dateTime()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('status')
->label(__('admin.refresh_tokens.fields.status'))
->badge()
->formatStateUsing(function (RefreshToken $record): string {
if ($record->revoked_at) {
return __('admin.refresh_tokens.status.revoked');
}
if ($record->expires_at && $record->expires_at->isPast()) {
return __('admin.refresh_tokens.status.expired');
}
return __('admin.refresh_tokens.status.active');
})
->color(function (RefreshToken $record): string {
if ($record->revoked_at) {
return 'danger';
}
if ($record->expires_at && $record->expires_at->isPast()) {
return 'warning';
}
return 'success';
}),
Tables\Columns\TextColumn::make('revoked_reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->formatStateUsing(function (?string $state): ?string {
if (! $state) {
return null;
}
$key = "admin.refresh_tokens.reasons.{$state}";
$translated = __($key);
return $translated === $key ? $state : $translated;
})
->badge()
->color('gray')
->toggleable(),
])
->filters([
SelectFilter::make('status')
->label(__('admin.refresh_tokens.filters.status'))
->options([
'active' => __('admin.refresh_tokens.status.active'),
'revoked' => __('admin.refresh_tokens.status.revoked'),
'expired' => __('admin.refresh_tokens.status.expired'),
])
->query(function (Builder $query, array $data): Builder {
return match ($data['value'] ?? null) {
'revoked' => $query->whereNotNull('revoked_at'),
'expired' => $query->whereNull('revoked_at')->whereNotNull('expires_at')->where('expires_at', '<=', now()),
'active' => $query->whereNull('revoked_at')->where(function ($inner) {
$inner->whereNull('expires_at')
->orWhere('expires_at', '>', now());
}),
default => $query,
};
}),
SelectFilter::make('tenant_id')
->label(__('admin.refresh_tokens.filters.tenant'))
->relationship('tenant', 'name')
->searchable(),
])
->actions([
Tables\Actions\ViewAction::make(),
Action::make('revoke')
->label(__('admin.refresh_tokens.actions.revoke'))
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (RefreshToken $record): bool => $record->isActive())
->form([
Forms\Components\Select::make('reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->options([
'manual' => __('admin.refresh_tokens.reasons.manual'),
'operator' => __('admin.refresh_tokens.reasons.operator'),
])
->default('manual')
->required(),
Forms\Components\Textarea::make('note')
->label(__('admin.refresh_tokens.fields.note'))
->rows(2)
->maxLength(255),
])
->requiresConfirmation()
->action(function (RefreshToken $record, array $data): void {
$note = $data['note'] ?? null;
$record->revoke(
$data['reason'] ?? 'manual',
auth()->id(),
request(),
$note ? ['note' => $note] : []
);
}),
])
->bulkActions([]);
}
public static function getRelations(): array
{
return [
AuditsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListRefreshTokens::route('/'),
'view' => Pages\ViewRefreshToken::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Resources\Pages\ListRecords;
class ListRefreshTokens extends ListRecords
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Pages\ViewRecord;
class ViewRefreshToken extends ViewRecord
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('revoke')
->label(__('admin.refresh_tokens.actions.revoke'))
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (): bool => $this->record->isActive())
->form([
Forms\Components\Select::make('reason')
->label(__('admin.refresh_tokens.fields.revoked_reason'))
->options([
'manual' => __('admin.refresh_tokens.reasons.manual'),
'operator' => __('admin.refresh_tokens.reasons.operator'),
])
->default('manual')
->required(),
Forms\Components\Textarea::make('note')
->label(__('admin.refresh_tokens.fields.note'))
->rows(2)
->maxLength(255),
])
->requiresConfirmation()
->action(function (array $data): void {
$note = $data['note'] ?? null;
$this->record->revoke(
$data['reason'] ?? 'manual',
auth()->id(),
request(),
$note ? ['note' => $note] : []
);
$this->record->refresh();
$this->notify('success', __('admin.refresh_tokens.notifications.revoked'));
}),
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class AuditsRelationManager extends RelationManager
{
protected static string $relationship = 'audits';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('event')
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.refresh_tokens.audit.performed_at'))
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('event')
->label(__('admin.refresh_tokens.audit.event'))
->badge()
->formatStateUsing(function (?string $state): ?string {
if (! $state) {
return null;
}
$key = "admin.refresh_tokens.audit.events.{$state}";
$translated = __($key);
return $translated === $key ? $state : $translated;
}),
Tables\Columns\TextColumn::make('performedBy.name')
->label(__('admin.refresh_tokens.audit.performed_by'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label(__('admin.refresh_tokens.audit.ip_address'))
->toggleable(),
Tables\Columns\TextColumn::make('context')
->label(__('admin.refresh_tokens.audit.context'))
->formatStateUsing(function ($state): string {
if (! is_array($state) || empty($state)) {
return '—';
}
return collect($state)
->filter(fn ($value) => filled($value))
->map(function ($value, $key) {
if (is_array($value)) {
$value = json_encode($value);
}
return "{$key}: {$value}";
})
->implode(', ');
})
->wrap()
->toggleable(),
])
->filters([])
->paginated([10, 25, 50])
->emptyStateHeading(__('admin.refresh_tokens.audit.empty.heading'))
->emptyStateDescription(__('admin.refresh_tokens.audit.empty.description'));
}
}

View File

@@ -7,6 +7,8 @@ use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use App\Models\Event;
use App\Models\EventType;
use App\Models\EventJoinTokenEvent;
use Carbon\Carbon;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
@@ -149,36 +151,89 @@ class EventResource extends Resource
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get()
->map(function ($token) use ($record) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
->get();
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
];
if ($tokens->isEmpty()) {
return view('filament.events.join-link', [
'event' => $record,
'tokens' => collect(),
]);
}
$tokenIds = $tokens->pluck('id');
$now = now();
$totals = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, event_type, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->groupBy('event_join_token_id', 'event_type')
->get()
->groupBy('event_join_token_id');
$recent24h = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->where('occurred_at', '>=', $now->copy()->subHours(24))
->groupBy('event_join_token_id')
->pluck('total', 'event_join_token_id');
$lastSeen = EventJoinTokenEvent::query()
->whereIn('event_join_token_id', $tokenIds)
->selectRaw('event_join_token_id, MAX(occurred_at) as last_at')
->groupBy('event_join_token_id')
->pluck('last_at', 'event_join_token_id');
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
$analyticsGroup = $totals->get($token->id, collect());
$analytics = $analyticsGroup->mapWithKeys(function ($row) {
return [$row->event_type => (int) $row->total];
});
$successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0);
$failureCount = (int) ($analytics['invalid_token'] ?? 0)
+ (int) ($analytics['token_expired'] ?? 0)
+ (int) ($analytics['token_revoked'] ?? 0)
+ (int) ($analytics['token_rate_limited'] ?? 0)
+ (int) ($analytics['event_not_public'] ?? 0)
+ (int) ($analytics['gallery_expired'] ?? 0);
$lastSeenAt = $lastSeen->get($token->id);
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
'analytics' => [
'success_total' => $successCount,
'failure_total' => $failureCount,
'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0),
'recent_24h' => (int) $recent24h->get($token->id, 0),
'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null,
],
];
});
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,

View File

@@ -10,9 +10,11 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Support\ImageHelper;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\Storage\EventStorageManager;
use App\Models\Event;
@@ -24,9 +26,12 @@ use App\Models\EventMediaAsset;
class EventPublicController extends BaseController
{
private const SIGNED_URL_TTL_SECONDS = 1800;
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly EventStorageManager $eventStorageManager,
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
) {
}
@@ -40,34 +45,65 @@ class EventPublicController extends BaseController
$joinToken = $this->joinTokenService->findToken($token, true);
if (! $joinToken) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'invalid_token',
Response::HTTP_NOT_FOUND,
[
'token' => Str::limit($token, 12),
],
$token
);
}
if ($joinToken->revoked_at !== null) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_revoked',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
],
$token,
$joinToken
);
}
if ($joinToken->expires_at !== null) {
$expiresAt = CarbonImmutable::parse($joinToken->expires_at);
if ($expiresAt->isPast()) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'expired_at' => $expiresAt->toAtomString(),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_expired',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
'expired_at' => $expiresAt->toAtomString(),
],
$token,
$joinToken
);
}
}
if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'usage_count' => $joinToken->usage_count,
'usage_limit' => $joinToken->usage_limit,
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_expired',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
'usage_count' => $joinToken->usage_count,
'usage_limit' => $joinToken->usage_limit,
],
$token,
$joinToken
);
}
$columns = array_unique(array_merge($columns, ['status']));
@@ -77,10 +113,18 @@ class EventPublicController extends BaseController
->first($columns);
if (! $event) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'invalid_token',
Response::HTTP_NOT_FOUND,
[
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
],
$token,
$joinToken
);
}
if (($event->status ?? null) !== 'published') {
@@ -90,6 +134,18 @@ class EventPublicController extends BaseController
'ip' => $request->ip(),
]);
$this->recordTokenEvent(
$joinToken,
$request,
'event_not_public',
[
'token' => Str::limit($token, 12),
'event_id' => $event->id ?? null,
],
$token,
Response::HTTP_FORBIDDEN
);
return response()->json([
'error' => [
'code' => 'event_not_public',
@@ -104,6 +160,22 @@ class EventPublicController extends BaseController
unset($event->status);
}
$this->recordTokenEvent(
$joinToken,
$request,
'access_granted',
[
'event_id' => $event->id ?? null,
],
$token,
Response::HTTP_OK
);
$throttleResponse = $this->enforceAccessThrottle($joinToken, $request, $token);
if ($throttleResponse instanceof JsonResponse) {
return $throttleResponse;
}
return [$event, $joinToken];
}
@@ -134,6 +206,18 @@ class EventPublicController extends BaseController
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
$this->recordTokenEvent(
$joinToken,
$request,
'gallery_expired',
[
'event_id' => $event->id,
'expired_at' => $expiresAt->toIso8601String(),
],
$token,
Response::HTTP_GONE
);
return response()->json([
'error' => [
'code' => 'gallery_expired',
@@ -143,16 +227,47 @@ class EventPublicController extends BaseController
], Response::HTTP_GONE);
}
$this->recordTokenEvent(
$joinToken,
$request,
'gallery_access_granted',
[
'event_id' => $event->id,
],
$token,
Response::HTTP_OK
);
return [$event, $joinToken];
}
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
private function handleTokenFailure(
Request $request,
string $rateLimiterKey,
string $code,
int $status,
array $context = [],
?string $rawToken = null,
?EventJoinToken $joinToken = null
): JsonResponse
{
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
$failureLimit = max(1, (int) config('join_tokens.failure_limit', 10));
$failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5));
if (RateLimiter::tooManyAttempts($rateLimiterKey, $failureLimit)) {
Log::warning('Join token rate limit exceeded', array_merge([
'ip' => $request->ip(),
], $context));
$this->recordTokenEvent(
$joinToken,
$request,
'token_rate_limited',
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'token_rate_limited',
@@ -161,13 +276,22 @@ class EventPublicController extends BaseController
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($rateLimiterKey, 300);
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
Log::notice('Join token access denied', array_merge([
'code' => $code,
'ip' => $request->ip(),
], $context));
$this->recordTokenEvent(
$joinToken,
$request,
$code,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
$rawToken,
$status
);
return response()->json([
'error' => [
'code' => $code,
@@ -186,6 +310,89 @@ class EventPublicController extends BaseController
};
}
private function recordTokenEvent(
?EventJoinToken $joinToken,
Request $request,
string $eventType,
array $context = [],
?string $rawToken = null,
?int $status = null
): void {
$this->analyticsRecorder->record($joinToken, $eventType, $request, $context, $rawToken, $status);
}
private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.access_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.access_decay_minutes', 1));
$key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'access_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'access_rate_limited',
'message' => 'Too many requests. Please slow down.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.download_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.download_decay_minutes', 1));
$key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'download_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'download_rate_limited',
'message' => 'Download rate limit exceeded. Please wait a moment.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function getLocalized($value, $locale, $default = '') {
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -288,23 +495,50 @@ class EventPublicController extends BaseController
private function makeGalleryPhotoResource(Photo $photo, string $token): array
{
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
$full = $this->toPublicUrl($photo->file_path ?? null);
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
return [
'id' => $photo->id,
'thumbnail_url' => $thumbnail,
'full_url' => $full,
'download_url' => route('api.v1.gallery.photos.download', [
'token' => $token,
'photo' => $photo->id,
]),
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
'full_url' => $fullUrl,
'download_url' => $downloadUrl,
'likes_count' => $photo->likes_count,
'guest_name' => $photo->guest_name,
'created_at' => $photo->created_at?->toIso8601String(),
];
}
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
{
if (! in_array($variant, ['thumbnail', 'full'], true)) {
return null;
}
return URL::temporarySignedRoute(
'api.v1.gallery.photos.asset',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
'variant' => $variant,
]
);
}
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
{
return URL::temporarySignedRoute(
'api.v1.gallery.photos.download',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
]
);
}
public function gallery(Request $request, string $token)
{
$locale = $request->query('locale', app()->getLocale());
@@ -316,7 +550,11 @@ class EventPublicController extends BaseController
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$branding = $this->buildGalleryBranding($event);
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
@@ -342,7 +580,11 @@ class EventPublicController extends BaseController
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$limit = (int) $request->query('limit', 30);
$limit = max(1, min($limit, 60));
@@ -389,6 +631,39 @@ class EventPublicController extends BaseController
]);
}
public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
{
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
$record = Photo::with('mediaAsset')
->where('id', $photo)
->where('event_id', $event->id)
->where('status', 'approved')
->first();
if (! $record) {
return response()->json([
'error' => [
'code' => 'photo_not_found',
'message' => 'The requested photo is no longer available.',
],
], Response::HTTP_NOT_FOUND);
}
$variantPreference = $variant === 'thumbnail'
? ['thumbnail', 'original']
: ['original'];
return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline');
}
public function galleryPhotoDownload(Request $request, string $token, int $photo)
{
$resolved = $this->resolveGalleryEvent($request, $token);
@@ -415,52 +690,7 @@ class EventPublicController extends BaseController
], Response::HTTP_NOT_FOUND);
}
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
if ($asset) {
$disk = $asset->disk ?? config('filesystems.default');
$path = $asset->path ?? $record->file_path;
try {
if ($path && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if ($stream) {
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
$filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension);
$mime = $asset->mime_type ?? 'image/jpeg';
return response()->streamDownload(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, $filename, [
'Content-Type' => $mime,
]);
}
}
} catch (\Throwable $e) {
Log::warning('Gallery photo download failed', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $asset->disk ?? null,
'path' => $asset->path ?? null,
'error' => $e->getMessage(),
]);
}
}
$publicUrl = $this->toPublicUrl($record->file_path ?? null);
if ($publicUrl) {
return redirect()->away($publicUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be downloaded.',
],
], Response::HTTP_NOT_FOUND);
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
}
public function event(Request $request, string $token)
@@ -518,6 +748,160 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store');
}
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
{
foreach ($variantPreference as $variant) {
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
if (! $path) {
continue;
}
$diskName = $this->resolveStorageDisk($disk);
try {
$storage = Storage::disk($diskName);
} catch (\Throwable $e) {
Log::warning('Gallery asset disk unavailable', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'error' => $e->getMessage(),
]);
continue;
}
foreach ($this->storagePathCandidates($path) as $candidate) {
try {
if (! $storage->exists($candidate)) {
continue;
}
$stream = $storage->readStream($candidate);
if (! $stream) {
continue;
}
$size = null;
try {
$size = $storage->size($candidate);
} catch (\Throwable $e) {
$size = null;
}
if (! $mime) {
try {
$mime = $storage->mimeType($candidate);
} catch (\Throwable $e) {
$mime = null;
}
}
$extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
$suffix = $variant === 'thumbnail' ? '-thumb' : '';
$filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');
$headers = [
'Content-Type' => $mime ?? 'image/jpeg',
'Cache-Control' => $disposition === 'attachment'
? 'no-store, max-age=0'
: 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
];
if ($size) {
$headers['Content-Length'] = $size;
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, $headers);
} catch (\Throwable $e) {
Log::warning('Gallery asset stream error', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'path' => $candidate,
'variant' => $variant,
'error' => $e->getMessage(),
]);
}
}
}
$fallbackUrl = $this->toPublicUrl($record->file_path ?? null);
if ($fallbackUrl) {
return redirect()->away($fallbackUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be loaded.',
],
], Response::HTTP_NOT_FOUND);
}
private function resolvePhotoVariant(Photo $record, string $variant): array
{
if ($variant === 'thumbnail') {
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $asset?->mime_type ?? 'image/jpeg';
} else {
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->file_path ?? null);
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
}
return [
$disk ?: config('filesystems.default', 'public'),
$path,
$mime,
];
}
private function resolveStorageDisk(?string $disk): string
{
$disk = $disk ?: config('filesystems.default', 'public');
if (! config("filesystems.disks.{$disk}")) {
return config('filesystems.default', 'public');
}
return $disk;
}
private function storagePathCandidates(string $path): array
{
$normalized = str_replace('\\', '/', $path);
$candidates = [$normalized];
$trimmed = ltrim($normalized, '/');
if ($trimmed !== $normalized) {
$candidates[] = $trimmed;
}
if (str_starts_with($trimmed, 'storage/')) {
$candidates[] = substr($trimmed, strlen('storage/'));
}
if (str_starts_with($trimmed, 'public/')) {
$candidates[] = substr($trimmed, strlen('public/'));
}
$needle = '/storage/app/public/';
if (str_contains($normalized, $needle)) {
$candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
}
return array_values(array_unique(array_filter($candidates)));
}
public function stats(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -526,7 +910,7 @@ class EventPublicController extends BaseController
return $result;
}
[$event] = $result;
[$event, $joinToken] = $result;
$eventId = $event->id;
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
@@ -866,6 +1250,19 @@ class EventPublicController extends BaseController
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
$this->recordTokenEvent(
$joinToken,
$request,
'upload_device_limit',
[
'event_id' => $eventId,
'device_id' => $deviceId,
'device_count' => $deviceCount,
],
$token,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
}
@@ -936,11 +1333,26 @@ class EventPublicController extends BaseController
->where('id', $photoId)
->update(['media_asset_id' => $asset->id]);
return response()->json([
$response = response()->json([
'id' => $photoId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
], 201);
$this->recordTokenEvent(
$joinToken,
$request,
'upload_completed',
[
'event_id' => $eventId,
'photo_id' => $photoId,
'device_id' => $deviceId,
],
$token,
Response::HTTP_CREATED
);
return $response;
}
/**
@@ -1201,7 +1613,6 @@ class EventPublicController extends BaseController
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
}
private function resolveDiskUrl(string $disk, string $path): string
{
@@ -1217,3 +1628,5 @@ class EventPublicController extends BaseController
return $path;
}
}
}

View File

@@ -3,10 +3,11 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PackagePurchase;
use App\Models\EventPackage;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Models\User;
use App\Services\Checkout\CheckoutWebhookService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -15,6 +16,10 @@ use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
{
}
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
@@ -23,7 +28,9 @@ class StripeWebhookController extends Controller
try {
$event = Webhook::constructEvent(
$payload, $sigHeader, $endpointSecret
$payload,
$sigHeader,
$endpointSecret
);
} catch (SignatureVerificationException $e) {
return response()->json(['error' => 'Invalid signature'], 400);
@@ -31,54 +38,81 @@ class StripeWebhookController extends Controller
return response()->json(['error' => 'Invalid payload'], 400);
}
// Handle the event
switch ($event['type']) {
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
if ($this->checkoutWebhooks->handleStripeEvent($eventArray)) {
return response()->json(['status' => 'success'], 200);
}
// Legacy handlers for legacy marketing checkout
return $this->handleLegacyEvent($eventArray);
}
private function handleLegacyEvent(array $event)
{
$type = $event['type'] ?? null;
switch ($type) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
$paymentIntent = $event['data']['object'] ?? [];
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
case 'invoice.paid':
$invoice = $event['data']['object'];
$invoice = $event['data']['object'] ?? [];
$this->handleInvoicePaid($invoice);
break;
default:
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
Log::info('Unhandled Stripe event', ['type' => $type]);
}
return response()->json(['status' => 'success'], 200);
}
private function handlePaymentIntentSucceeded(array $paymentIntent)
private function handlePaymentIntentSucceeded(array $paymentIntent): void
{
$metadata = $paymentIntent['metadata'];
$packageId = $metadata['package_id'];
$type = $metadata['type'];
$metadata = $paymentIntent['metadata'] ?? [];
$packageId = $metadata['package_id'] ?? null;
$type = $metadata['type'] ?? null;
if (! $packageId || ! $type) {
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
return;
}
DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
// Create purchase record
$purchase = PackagePurchase::create([
'package_id' => $packageId,
'type' => $type,
'provider_id' => 'stripe',
'transaction_id' => $paymentIntent['id'],
'price' => $paymentIntent['amount_received'] / 100,
'transaction_id' => $paymentIntent['id'] ?? null,
'price' => isset($paymentIntent['amount_received'])
? $paymentIntent['amount_received'] / 100
: 0,
'metadata' => $metadata,
]);
if ($type === 'endcustomer_event') {
$eventId = $metadata['event_id'];
$eventId = $metadata['event_id'] ?? null;
if (! $eventId) {
return;
}
EventPackage::create([
'event_id' => $eventId,
'package_id' => $packageId,
'package_purchase_id' => $purchase->id,
'used_photos' => 0,
'used_guests' => 0,
'expires_at' => now()->addDays(30), // Default, or from package
'expires_at' => now()->addDays(30),
]);
} elseif ($type === 'reseller_subscription') {
$tenantId = $metadata['tenant_id'];
$tenantId = $metadata['tenant_id'] ?? null;
if (! $tenantId) {
return;
}
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
@@ -88,59 +122,60 @@ class StripeWebhookController extends Controller
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id']);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
});
}
private function handleInvoicePaid(array $invoice)
{
$subscription = $invoice['subscription'];
$metadata = $subscription['metadata'] ?? [];
if (isset($metadata['tenant_id'])) {
$tenantId = $metadata['tenant_id'];
$packageId = $metadata['package_id'];
// Renew or create tenant package
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
->where('package_id', $packageId)
->where('stripe_subscription_id', $subscription)
->first();
if ($tenantPackage) {
$tenantPackage->update([
'active' => true,
'expires_at' => now()->addYear(),
]);
} else {
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'stripe_subscription_id' => $subscription,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
// Create purchase record
PackagePurchase::create([
'package_id' => $packageId,
'type' => 'reseller_subscription',
'provider_id' => 'stripe',
'transaction_id' => $invoice['id'],
'price' => $invoice['amount_paid'] / 100,
'metadata' => $metadata,
]);
}
});
}
}
private function handleInvoicePaid(array $invoice): void
{
$subscription = $invoice['subscription'] ?? null;
$metadata = $subscription['metadata'] ?? [];
if (! isset($metadata['tenant_id'], $metadata['package_id'])) {
return;
}
$tenantId = $metadata['tenant_id'];
$packageId = $metadata['package_id'];
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
->where('package_id', $packageId)
->where('stripe_subscription_id', $subscription)
->first();
if ($tenantPackage) {
$tenantPackage->update([
'active' => true,
'expires_at' => now()->addYear(),
]);
} else {
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'stripe_subscription_id' => $subscription,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
PackagePurchase::create([
'package_id' => $packageId,
'type' => 'reseller_subscription',
'provider_id' => 'stripe',
'transaction_id' => $invoice['id'] ?? null,
'price' => isset($invoice['amount_paid']) ? $invoice['amount_paid'] / 100 : 0,
'metadata' => $metadata,
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\Photo;
use App\Support\ImageHelper;
use App\Services\Storage\EventStorageManager;
use App\Jobs\ProcessPhotoSecurityScan;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -157,6 +158,8 @@ class PhotoController extends Controller
[$width, $height] = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);
ProcessPhotoSecurityScan::dispatch($photo->id);
$photo->load('event')->loadCount('likes');
return response()->json([

View File

@@ -102,7 +102,9 @@ class RegisteredUserController extends Controller
event(new Registered($user));
// Send Welcome Email
Mail::to($user)->queue(new \App\Mail\Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new \App\Mail\Welcome($user));
if ($request->filled('package_id')) {
$package = \App\Models\Package::find($request->package_id);

View File

@@ -123,7 +123,9 @@ class CheckoutController extends Controller
$user->sendEmailVerificationNotification();
// Willkommens-E-Mail senden
Mail::to($user)->queue(new Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new Welcome($user));
});
return response()->json([

View File

@@ -91,7 +91,9 @@ class CheckoutGoogleController extends Controller
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
try {
Mail::to($user)->queue(new Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new Welcome($user));
} catch (\Throwable $exception) {
Log::warning('Failed to queue welcome mail after Google signup', [
'user_id' => $user->id,

View File

@@ -60,14 +60,28 @@ class MarketingController extends Controller
'message' => 'required|string|max:1000',
]);
Mail::raw("Kontakt-Anfrage von {$request->name} ({$request->email}): {$request->message}", function ($message) use ($request) {
$message->to('admin@fotospiel.de')
->subject('Neue Kontakt-Anfrage');
});
$locale = app()->getLocale();
$contactAddress = config('mail.contact_address', config('mail.from.address')) ?: 'admin@fotospiel.de';
Mail::to($request->email)->queue(new ContactConfirmation($request->name));
Mail::raw(
__('emails.contact.body', [
'name' => $request->name,
'email' => $request->email,
'message' => $request->message,
], $locale),
function ($message) use ($request, $contactAddress, $locale) {
$message->to($contactAddress)
->subject(__('emails.contact.subject', [], $locale));
}
);
return redirect()->back()->with('success', 'Nachricht gesendet!');
Mail::to($request->email)
->locale($locale)
->queue(new ContactConfirmation($request->name));
return redirect()
->back()
->with('success', __('marketing.contact.success', [], $locale));
}
public function contactView()

View File

@@ -141,7 +141,6 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId,
]);
@@ -181,7 +180,6 @@ class OAuthController extends Controller
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
Log::warning('[OAuth] Authorization code missing or expired', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
]);
return $this->errorResponse('Invalid or expired authorization code', 400);
@@ -192,7 +190,7 @@ class OAuthController extends Controller
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
Log::warning('[OAuth] Authorization code validation failed', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'oauth_code_id' => $oauthCode?->id,
]);
return $this->errorResponse('Invalid authorization code', 400);
@@ -222,7 +220,7 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'oauth_code_id' => $oauthCode->id ?? null,
'tenant_id' => $tenantId,
]);
@@ -271,16 +269,33 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid refresh token', 400);
}
$storedRefreshToken->recordAudit('refresh_attempt', [
'client_id' => $request->client_id,
], null, $request);
if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
$storedRefreshToken->recordAudit('client_mismatch', [
'expected_client' => $storedRefreshToken->client_id,
'provided_client' => $request->client_id,
], null, $request);
return $this->errorResponse('Refresh token does not match client', 400);
}
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
$storedRefreshToken->update(['revoked_at' => now()]);
$storedRefreshToken->revoke('expired', null, $request, [
'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(),
]);
return $this->errorResponse('Refresh token expired', 400);
}
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
$storedRefreshToken->recordAudit('invalid_secret', [], null, $request);
$storedRefreshToken->revoke('invalid_secret', null, $request, [
'client_id' => $request->client_id,
]);
return $this->errorResponse('Invalid refresh token', 400);
}
@@ -288,8 +303,6 @@ class OAuthController extends Controller
$currentIp = (string) ($request->ip() ?? '');
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
$storedRefreshToken->update(['revoked_at' => now()]);
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
@@ -297,6 +310,11 @@ class OAuthController extends Controller
'current_ip' => $currentIp,
]);
$storedRefreshToken->revoke('ip_mismatch', null, $request, [
'stored_ip' => $storedIp,
'current_ip' => $currentIp,
]);
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
}
@@ -313,15 +331,36 @@ class OAuthController extends Controller
'tenant_id' => $storedRefreshToken->tenant_id,
]);
$storedRefreshToken->revoke('tenant_missing', null, $request, [
'missing_tenant_id' => $storedRefreshToken->tenant_id,
]);
return $this->errorResponse('Tenant not found', 404);
}
$scopes = $this->parseScopes($storedRefreshToken->scope);
$storedRefreshToken->update(['revoked_at' => now()]);
$storedRefreshToken->forceFill([
'last_used_at' => now(),
])->save();
$storedRefreshToken->recordAudit('refreshed', [
'client_id' => $request->client_id,
], null, $request);
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
$newComposite = $tokenResponse['refresh_token'] ?? null;
$newRefreshTokenId = null;
if ($newComposite && str_contains($newComposite, '|')) {
[$newRefreshTokenId] = explode('|', $newComposite, 2);
}
$storedRefreshToken->revoke('rotated', null, $request, [
'replaced_by' => $newRefreshTokenId,
]);
return response()->json($tokenResponse);
}
@@ -370,18 +409,45 @@ class OAuthController extends Controller
$composite = $refreshTokenId.'|'.$secret;
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
RefreshToken::create([
/** @var RefreshToken $refreshToken */
$refreshToken = RefreshToken::create([
'id' => $refreshTokenId,
'tenant_id' => $tenant->id,
'client_id' => $client->client_id,
'token' => Hash::make($secret),
'access_token' => $accessTokenJti,
'expires_at' => $expiresAt,
'last_used_at' => now(),
'scope' => implode(' ', $scopes),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
$refreshToken->recordAudit('issued', [
'scopes' => $scopes,
], null, $request);
$maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5);
if ($maxActive > 0) {
$activeTokens = RefreshToken::query()
->forTenant((string) $tenant->id)
->active()
->orderByDesc('created_at')
->get();
if ($activeTokens->count() > $maxActive) {
$activeTokens
->slice($maxActive)
->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void {
$token->revoke('max_active_limit', null, $request, [
'threshold' => $maxActive,
'new_token' => $refreshToken->id,
]);
});
}
}
return $composite;
}

View File

@@ -13,11 +13,14 @@ use App\Models\Package;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Services\PayPal\PaypalClientFactory;
use App\Services\Checkout\CheckoutWebhookService;
class PayPalWebhookController extends Controller
{
public function __construct(private PaypalClientFactory $clientFactory)
{
public function __construct(
private PaypalClientFactory $clientFactory,
private CheckoutWebhookService $checkoutWebhooks,
) {
}
public function verify(Request $request): JsonResponse
@@ -59,6 +62,10 @@ class PayPalWebhookController extends Controller
Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']);
if ($this->checkoutWebhooks->handlePayPalEvent($event)) {
return;
}
switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED':
// Handle order approval if needed

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$scriptNonce = base64_encode(random_bytes(16));
$styleNonce = null;
$request->attributes->set('csp_script_nonce', $scriptNonce);
$request->attributes->set('csp_style_nonce', $styleNonce);
View::share('cspNonce', $scriptNonce);
View::share('cspStyleNonce', $styleNonce);
Vite::useCspNonce($scriptNonce);
$response = $next($request);
if (app()->environment('local') || config('app.debug')) {
return $response;
}
if ($response->headers->has('Content-Security-Policy')) {
return $response;
}
$matomoOrigin = $this->normaliseOrigin(config('services.matomo.url'));
$scriptSources = [
"'self'",
"'nonce-{$scriptNonce}'",
'https://js.stripe.com',
'https://js.stripe.network',
];
$styleSources = [
"'self'",
"'unsafe-inline'",
'https:',
];
$connectSources = [
"'self'",
'https://api.stripe.com',
'https://api.stripe.network',
];
$frameSources = [
"'self'",
'https://js.stripe.com',
];
$imgSources = [
"'self'",
'data:',
'blob:',
'https:',
];
$fontSources = [
"'self'",
'data:',
'https:',
];
$mediaSources = [
"'self'",
'data:',
'blob:',
'https:',
];
if ($matomoOrigin) {
$scriptSources[] = $matomoOrigin;
$connectSources[] = $matomoOrigin;
$imgSources[] = $matomoOrigin;
}
if (app()->environment(['local', 'development']) || config('app.debug')) {
$devHosts = [
'http://localhost:5173',
'http://127.0.0.1:5173',
'https://localhost:5173',
'https://127.0.0.1:5173',
];
$wsHosts = [
'ws://localhost:5173',
'ws://127.0.0.1:5173',
'wss://localhost:5173',
'wss://127.0.0.1:5173',
];
$scriptSources = array_merge($scriptSources, $devHosts, ["'unsafe-inline'", "'unsafe-eval'"]);
$styleSources = array_merge($styleSources, $devHosts, ["'unsafe-inline'"]);
$connectSources = array_merge($connectSources, $devHosts, $wsHosts);
$fontSources = array_merge($fontSources, $devHosts);
$mediaSources = array_merge($mediaSources, $devHosts);
}
$styleSources[] = 'data:';
$connectSources[] = 'https:';
$fontSources[] = 'https:';
$directives = [
'default-src' => ["'self'"],
'script-src' => array_unique($scriptSources),
'style-src' => array_unique($styleSources),
'img-src' => array_unique($imgSources),
'font-src' => array_unique($fontSources),
'connect-src' => array_unique($connectSources),
'media-src' => array_unique($mediaSources),
'frame-src' => array_unique($frameSources),
'form-action' => ["'self'"],
'base-uri' => ["'self'"],
'object-src' => ["'none'"],
];
$csp = collect($directives)
->map(fn ($values, $directive) => $directive.' '.implode(' ', array_filter($values)))
->implode('; ');
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
private function normaliseOrigin(?string $url): ?string
{
if (! $url) {
return null;
}
$parsed = parse_url($url);
if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) {
return null;
}
$origin = strtolower($parsed['scheme'].'://'.$parsed['host']);
if (isset($parsed['port'])) {
$origin .= ':'.$parsed['port'];
}
return $origin;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class StripeCSP
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com https://js.stripe.network; style-src 'self' 'unsafe-inline' data: https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://api.stripe.network wss://*.stripe.network; media-src 'self' data: blob:; frame-src 'self' https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}

View File

@@ -36,11 +36,14 @@ class EventJoinTokenResource extends JsonResource
])
: null;
$plainToken = $this->resource->plain_token ?? $this->token;
return [
'id' => $this->id,
'label' => $this->label,
'token' => $this->token,
'url' => url('/e/'.$this->token),
'token' => $plainToken,
'token_preview' => $this->token_preview,
'url' => $plainToken ? url('/e/'.$plainToken) : null,
'usage_limit' => $this->usage_limit,
'usage_count' => $this->usage_count,
'expires_at' => optional($this->expires_at)->toIso8601String(),

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Jobs;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use App\Services\Security\PhotoSecurityScanner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessPhotoSecurityScan implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public int $photoId)
{
$this->queue = config('security.queue.name', 'default');
}
public function handle(PhotoSecurityScanner $scanner): void
{
$photo = Photo::with('mediaAsset')->find($this->photoId);
if (! $photo) {
Log::warning('[PhotoSecurity] Skipping missing photo', ['photo_id' => $this->photoId]);
return;
}
$asset = $photo->mediaAsset ?? EventMediaAsset::where('photo_id', $photo->id)->where('variant', 'original')->first();
if (! $asset) {
Log::warning('[PhotoSecurity] No media asset available for scan', [
'photo_id' => $photo->id,
]);
$photo->forceFill([
'security_scan_status' => 'error',
'security_scan_message' => 'Media asset not available for scanning.',
'security_scanned_at' => now(),
])->save();
return;
}
$disk = $asset->disk ?? config('filesystems.default', 'public');
$path = $asset->path ?? $photo->file_path;
$scanResult = $scanner->scan($disk, $path);
$status = $scanResult['status'] ?? 'error';
$message = $scanResult['message'] ?? null;
$metadata = [
'scan' => $scanResult,
];
if ($status === 'clean' && config('security.exif.strip', true)) {
$stripResult = $scanner->stripExif($disk, $path);
$metadata['exif'] = $stripResult;
}
$existingMeta = $photo->security_meta ?? [];
$photo->forceFill([
'security_scan_status' => $status,
'security_scan_message' => $message,
'security_scanned_at' => now(),
'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata),
])->save();
if ($status === 'infected') {
Log::alert('[PhotoSecurity] Infected photo detected', [
'photo_id' => $photo->id,
'event_id' => $photo->event_id,
'message' => $message,
]);
}
}
}

View File

@@ -17,7 +17,7 @@ class AbandonedCheckout extends Mailable
public function __construct(
public User $user,
public Package $package,
public string $timing, // '1h', '24h', '1w'
public string $timing,
public string $resumeUrl
) {
//
@@ -25,10 +25,9 @@ class AbandonedCheckout extends Mailable
public function envelope(): Envelope
{
$subjectKey = 'emails.abandoned_checkout.subject_' . $this->timing;
return new Envelope(
subject: __('emails.abandoned_checkout.subject_' . $this->timing, [
'package' => $this->package->name
'package' => $this->localizedPackageName(),
]),
);
}
@@ -40,6 +39,7 @@ class AbandonedCheckout extends Mailable
with: [
'user' => $this->user,
'package' => $this->package,
'packageName' => $this->localizedPackageName(),
'timing' => $this->timing,
'resumeUrl' => $this->resumeUrl,
],
@@ -50,4 +50,11 @@ class AbandonedCheckout extends Mailable
{
return [];
}
}
private function localizedPackageName(): string
{
$locale = $this->locale ?? app()->getLocale();
return $this->package->getNameForLocale($locale);
}
}

View File

@@ -20,7 +20,7 @@ class ContactConfirmation extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Vielen Dank fuer Ihre Nachricht bei Fotospiel',
subject: __('emails.contact_confirmation.subject', ['name' => $this->name]),
);
}

View File

@@ -21,7 +21,7 @@ class PurchaseConfirmation extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.purchase.subject', ['package' => $this->purchase->package->name]),
subject: __('emails.purchase.subject', ['package' => $this->localizedPackageName()]),
);
}
@@ -33,6 +33,7 @@ class PurchaseConfirmation extends Mailable
'purchase' => $this->purchase,
'user' => $this->purchase->tenant->user,
'package' => $this->purchase->package,
'packageName' => $this->localizedPackageName(),
],
);
}
@@ -41,4 +42,11 @@ class PurchaseConfirmation extends Mailable
{
return [];
}
}
private function localizedPackageName(): string
{
$locale = $this->locale ?? app()->getLocale();
return optional($this->purchase->package)->getNameForLocale($locale) ?? '';
}
}

View File

@@ -1,9 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\EventJoinTokenEvent;
use Illuminate\Support\Facades\Crypt;
class EventJoinToken extends Model
{
@@ -11,7 +15,9 @@ class EventJoinToken extends Model
protected $fillable = [
'event_id',
'token',
'token_hash',
'token_encrypted',
'token_preview',
'label',
'usage_limit',
'usage_count',
@@ -29,6 +35,15 @@ class EventJoinToken extends Model
'usage_count' => 'integer',
];
protected $hidden = [
'token_encrypted',
'token_hash',
];
protected $appends = [
'token_preview',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
@@ -39,6 +54,11 @@ class EventJoinToken extends Model
return $this->belongsTo(User::class, 'created_by');
}
public function analytics(): HasMany
{
return $this->hasMany(EventJoinTokenEvent::class);
}
public function isActive(): bool
{
if ($this->revoked_at !== null) {
@@ -55,4 +75,64 @@ class EventJoinToken extends Model
return true;
}
public function getTokenAttribute(?string $value): ?string
{
$encrypted = $this->attributes['token_encrypted'] ?? null;
if (! empty($encrypted)) {
try {
return Crypt::decryptString($encrypted);
} catch (\Throwable $e) {
try {
return Crypt::decrypt($encrypted);
} catch (\Throwable $e) {
// Fall back to stored hash if both decrypt strategies fail.
}
}
}
return $value;
}
public function setTokenAttribute(?string $value): void
{
if ($value === null) {
$this->attributes['token'] = null;
$this->attributes['token_hash'] = null;
$this->attributes['token_encrypted'] = null;
$this->attributes['token_preview'] = null;
return;
}
$hash = hash('sha256', $value);
$this->attributes['token'] = $hash;
$this->attributes['token_hash'] = $hash;
$this->attributes['token_encrypted'] = Crypt::encryptString($value);
$this->attributes['token_preview'] = $this->buildPreview($value);
}
public function getTokenPreviewAttribute(?string $value): ?string
{
if ($value) {
return $value;
}
$token = $this->token;
return $token ? $this->buildPreview($token) : null;
}
private function buildPreview(string $token): string
{
$length = strlen($token);
if ($length <= 10) {
return $token;
}
return substr($token, 0, 6).'…'.substr($token, -4);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventJoinTokenEvent extends Model
{
use HasFactory;
protected $fillable = [
'event_join_token_id',
'event_id',
'tenant_id',
'token_hash',
'token_preview',
'event_type',
'route',
'http_method',
'http_status',
'device_id',
'ip_address',
'user_agent',
'context',
'occurred_at',
];
protected $casts = [
'context' => 'array',
'occurred_at' => 'datetime',
];
public function joinToken(): BelongsTo
{
return $this->belongsTo(EventJoinToken::class, 'event_join_token_id');
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -99,6 +99,24 @@ class Package extends Model
return $this->type === 'reseller';
}
public function getNameForLocale(?string $locale = null): string
{
$locale = $locale ?: app()->getLocale();
$translations = $this->name_translations ?? [];
if (!empty($translations[$locale])) {
return $translations[$locale];
}
foreach (['en', 'de'] as $fallback) {
if ($locale !== $fallback && !empty($translations[$fallback])) {
return $translations[$fallback];
}
}
return $this->name ?? '';
}
public function getLimitsAttribute(): array
{
return [

View File

@@ -20,6 +20,12 @@ class Photo extends Model
protected $casts = [
'is_featured' => 'boolean',
'metadata' => 'array',
'security_meta' => 'array',
'security_scanned_at' => 'datetime',
];
protected $attributes = [
'security_scan_status' => 'pending',
];
public function mediaAsset(): BelongsTo

View File

@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
class RefreshToken extends Model
{
@@ -12,9 +14,9 @@ class RefreshToken extends Model
public $timestamps = false;
protected $table = 'refresh_tokens';
protected $guarded = [];
protected $fillable = [
'id',
'tenant_id',
@@ -22,40 +24,97 @@ class RefreshToken extends Model
'token',
'access_token',
'expires_at',
'last_used_at',
'scope',
'ip_address',
'user_agent',
'revoked_at',
'revoked_reason',
];
protected $casts = [
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
'created_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function revoke(): bool
public function audits(): HasMany
{
return $this->update(['revoked_at' => now()]);
return $this->hasMany(RefreshTokenAudit::class);
}
public function revoke(?string $reason = null, ?int $performedBy = null, ?Request $request = null, array $context = []): bool
{
$result = $this->update([
'revoked_at' => now(),
'revoked_reason' => $reason,
]);
$event = match ($reason) {
'rotated' => 'rotated',
'ip_mismatch' => 'ip_mismatch',
'expired' => 'expired',
'invalid_secret' => 'invalid_secret',
'tenant_missing' => 'tenant_missing',
'max_active_limit' => 'max_active_limit',
default => 'revoked',
};
$this->recordAudit(
$event,
array_merge([
'reason' => $reason,
], $context),
$performedBy,
$request
);
return $result;
}
public function isActive(): bool
{
return $this->revoked_at === null && $this->expires_at > now();
if ($this->revoked_at !== null) {
return false;
}
return $this->expires_at === null || $this->expires_at->isFuture();
}
public function scopeActive($query)
{
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
return $query
->whereNull('revoked_at')
->where(function ($inner) {
$inner->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeForTenant($query, string $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public function recordAudit(string $event, array $context = [], ?int $performedBy = null, ?Request $request = null): void
{
$request ??= request();
$this->audits()->create([
'tenant_id' => $this->tenant_id,
'client_id' => $this->client_id,
'event' => $event,
'context' => $context ?: null,
'ip_address' => $request?->ip(),
'user_agent' => $request?->userAgent(),
'performed_by' => $performedBy,
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshTokenAudit extends Model
{
use HasFactory;
protected $fillable = [
'refresh_token_id',
'tenant_id',
'client_id',
'event',
'context',
'ip_address',
'user_agent',
'performed_by',
'created_at',
];
public $timestamps = false;
protected $casts = [
'context' => 'array',
'created_at' => 'datetime',
];
public function refreshToken(): BelongsTo
{
return $this->belongsTo(RefreshToken::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function performedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'performed_by');
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\Checkout\CheckoutSessionService;
use App\Notifications\UploadPipelineFailed;
use App\Services\Storage\EventStorageManager;
use App\Services\Storage\StorageHealthService;
use App\Services\Security\PhotoSecurityScanner;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Queue\Events\JobFailed;
@@ -29,6 +30,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(CheckoutPaymentService::class);
$this->app->singleton(EventStorageManager::class);
$this->app->singleton(StorageHealthService::class);
$this->app->singleton(PhotoSecurityScanner::class);
}
/**
@@ -73,6 +75,26 @@ class AppServiceProvider extends ServiceProvider
];
});
Inertia::share('security', static function () {
$request = request();
if (! $request) {
return [
'csp' => [
'scriptNonce' => null,
'styleNonce' => null,
],
];
}
return [
'csp' => [
'scriptNonce' => $request->attributes->get('csp_script_nonce'),
'styleNonce' => $request->attributes->get('csp_style_nonce'),
],
];
});
if (config('storage-monitor.queue_failure_alerts')) {
Queue::failing(function (JobFailed $event) {
$context = [

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Analytics;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class JoinTokenAnalyticsRecorder
{
public function record(
?EventJoinToken $joinToken,
string $eventType,
Request $request,
array $context = [],
?string $providedToken = null,
?int $httpStatus = null
): void {
try {
EventJoinTokenEvent::create($this->buildPayload(
$joinToken,
$eventType,
$request,
$context,
$providedToken,
$httpStatus
));
} catch (\Throwable $exception) {
// Never block the main request if analytics fails
report($exception);
}
}
private function buildPayload(
?EventJoinToken $joinToken,
string $eventType,
Request $request,
array $context,
?string $providedToken,
?int $httpStatus
): array {
$route = $request->route();
$routeName = $route ? $route->getName() : null;
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) : null;
$tokenHash = null;
$tokenPreview = null;
if ($joinToken) {
$tokenHash = $joinToken->token_hash ?? null;
$tokenPreview = $joinToken->token_preview ?? null;
} elseif ($providedToken) {
$tokenHash = hash('sha256', $providedToken);
$tokenPreview = $this->buildPreview($providedToken);
}
$eventId = $joinToken?->event_id;
$tenantId = null;
if ($joinToken && $joinToken->relationLoaded('event')) {
$tenantId = $joinToken->event?->tenant_id;
} elseif ($joinToken) {
$tenantId = $joinToken->event()->value('tenant_id');
}
return [
'event_join_token_id' => $joinToken?->getKey(),
'event_id' => $eventId,
'tenant_id' => $tenantId,
'token_hash' => $tokenHash,
'token_preview' => $tokenPreview,
'event_type' => $eventType,
'route' => $routeName,
'http_method' => $request->getMethod(),
'http_status' => $httpStatus,
'device_id' => $deviceId,
'ip_address' => $request->ip(),
'user_agent' => $this->shortenUserAgent($request->userAgent()),
'context' => $context ?: null,
'occurred_at' => now(),
];
}
private function shortenUserAgent(?string $userAgent): ?string
{
if ($userAgent === null) {
return null;
}
if (Str::length($userAgent) > 1024) {
return Str::substr($userAgent, 0, 1024);
}
return $userAgent;
}
private function buildPreview(string $token): string
{
$token = trim($token);
$length = Str::length($token);
if ($length <= 10) {
return $token;
}
return Str::substr($token, 0, 6).'…'.Str::substr($token, -4);
}
}

View File

@@ -84,10 +84,16 @@ class CheckoutAssignmentService
}
if ($user) {
Mail::to($user)->queue(new Welcome($user));
$mailLocale = $user->preferred_locale ?? app()->getLocale();
Mail::to($user)
->locale($mailLocale)
->queue(new Welcome($user));
if ($purchase->wasRecentlyCreated) {
Mail::to($user)->queue(new PurchaseConfirmation($purchase));
Mail::to($user)
->locale($mailLocale)
->queue(new PurchaseConfirmation($purchase));
}
AbandonedCheckout::query()

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Services\Checkout;
use App\Models\CheckoutSession;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CheckoutWebhookService
{
public function __construct(
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {
}
public function handleStripeEvent(array $event): bool
{
$eventType = $event['type'] ?? null;
$intent = $event['data']['object'] ?? null;
if (! $eventType || ! is_array($intent)) {
return false;
}
if (! str_starts_with($eventType, 'payment_intent.')) {
return false;
}
$intentId = $intent['id'] ?? null;
if (! $intentId) {
return false;
}
$session = $this->locateStripeSession($intent);
if (! $session) {
return false;
}
$lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30);
if (! $lock->get()) {
Log::info('[CheckoutWebhook] Stripe intent lock busy', [
'intent_id' => $intentId,
'session_id' => $session->id,
]);
return true;
}
try {
$session->forceFill([
'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId,
'provider' => CheckoutSession::PROVIDER_STRIPE,
])->save();
$metadata = [
'stripe_last_event' => $eventType,
'stripe_last_event_id' => $event['id'] ?? null,
'stripe_intent_status' => $intent['status'] ?? null,
'stripe_last_update_at' => now()->toIso8601String(),
];
$this->mergeProviderMetadata($session, $metadata);
return $this->applyStripeIntent($session, $eventType, $intent);
} finally {
$lock->release();
}
}
public function handlePayPalEvent(array $event): bool
{
$eventType = $event['event_type'] ?? null;
$resource = $event['resource'] ?? [];
if (! $eventType || ! is_array($resource)) {
return false;
}
$orderId = $resource['order_id'] ?? $resource['id'] ?? null;
$session = $this->locatePayPalSession($resource, $orderId);
if (! $session) {
return false;
}
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id);
$lock = Cache::lock($lockKey, 30);
if (! $lock->get()) {
Log::info('[CheckoutWebhook] PayPal lock busy', [
'order_id' => $orderId,
'session_id' => $session->id,
]);
return true;
}
try {
$session->forceFill([
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
'provider' => CheckoutSession::PROVIDER_PAYPAL,
])->save();
$metadata = [
'paypal_last_event' => $eventType,
'paypal_last_event_id' => $event['id'] ?? null,
'paypal_last_update_at' => now()->toIso8601String(),
'paypal_order_id' => $orderId,
'paypal_capture_id' => $resource['id'] ?? null,
];
$this->mergeProviderMetadata($session, $metadata);
return $this->applyPayPalEvent($session, $eventType, $resource);
} finally {
$lock->release();
}
}
protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool
{
switch ($eventType) {
case 'payment_intent.processing':
case 'payment_intent.amount_capturable_updated':
$this->sessions->markProcessing($session, [
'stripe_intent_status' => $intent['status'] ?? null,
]);
return true;
case 'payment_intent.requires_action':
$reason = $intent['next_action']['type'] ?? 'requires_action';
$this->sessions->markRequiresCustomerAction($session, $reason);
return true;
case 'payment_intent.payment_failed':
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
$this->sessions->markFailed($session, $failure);
return true;
case 'payment_intent.succeeded':
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
$this->sessions->markProcessing($session, [
'stripe_intent_status' => $intent['status'] ?? null,
]);
$this->assignment->finalise($session, [
'source' => 'stripe_webhook',
'stripe_payment_intent_id' => $intent['id'] ?? null,
'stripe_charge_id' => $this->extractStripeChargeId($intent),
]);
$this->sessions->markCompleted($session, now());
}
return true;
default:
return false;
}
}
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool
{
switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED':
$this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null,
]);
return true;
case 'PAYMENT.CAPTURE.COMPLETED':
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
$this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null,
]);
$this->assignment->finalise($session, [
'source' => 'paypal_webhook',
'paypal_order_id' => $resource['order_id'] ?? null,
'paypal_capture_id' => $resource['id'] ?? null,
]);
$this->sessions->markCompleted($session, now());
}
return true;
case 'PAYMENT.CAPTURE.DENIED':
$this->sessions->markFailed($session, 'paypal_capture_denied');
return true;
default:
return false;
}
}
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
{
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
$session->save();
}
protected function locateStripeSession(array $intent): ?CheckoutSession
{
$intentId = $intent['id'] ?? null;
if ($intentId) {
$session = CheckoutSession::query()
->where('stripe_payment_intent_id', $intentId)
->first();
if ($session) {
return $session;
}
}
$metadata = $intent['metadata'] ?? [];
$sessionId = $metadata['checkout_session_id'] ?? null;
if ($sessionId) {
return CheckoutSession::find($sessionId);
}
return null;
}
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
{
if ($orderId) {
$session = CheckoutSession::query()
->where('paypal_order_id', $orderId)
->first();
if ($session) {
return $session;
}
}
$metadata = $this->extractPayPalMetadata($resource);
$sessionId = $metadata['checkout_session_id'] ?? null;
if ($sessionId) {
return CheckoutSession::find($sessionId);
}
return null;
}
protected function extractPayPalMetadata(array $resource): array
{
$customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null);
if ($customId) {
$decoded = json_decode($customId, true);
if (is_array($decoded)) {
return $decoded;
}
}
$meta = Arr::get($resource, 'supplementary_data.related_ids', []);
return is_array($meta) ? $meta : [];
}
protected function extractStripeChargeId(array $intent): ?string
{
$charges = $intent['charges']['data'] ?? null;
if (is_array($charges) && count($charges) > 0) {
return $charges[0]['id'] ?? null;
}
return null;
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\EventJoinToken;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
class EventJoinTokenService
@@ -15,10 +16,13 @@ class EventJoinTokenService
{
return DB::transaction(function () use ($event, $attributes) {
$tokenValue = $this->generateUniqueToken();
$tokenHash = $this->hashToken($tokenValue);
$payload = [
'event_id' => $event->id,
'token' => $tokenValue,
'token_hash' => $tokenHash,
'token_encrypted' => Crypt::encryptString($tokenValue),
'token_preview' => $this->previewToken($tokenValue),
'label' => Arr::get($attributes, 'label'),
'usage_limit' => Arr::get($attributes, 'usage_limit'),
'metadata' => Arr::get($attributes, 'metadata', []),
@@ -34,7 +38,9 @@ class EventJoinTokenService
$payload['created_by'] = $createdBy;
}
return EventJoinToken::create($payload);
return tap(EventJoinToken::create($payload), function (EventJoinToken $model) use ($tokenValue) {
$model->setAttribute('plain_token', $tokenValue);
});
});
}
@@ -60,8 +66,16 @@ class EventJoinTokenService
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
{
$hash = $this->hashToken($token);
return EventJoinToken::query()
->where('token', $token)
->where(function ($query) use ($hash, $token) {
$query->where('token_hash', $hash)
->orWhere(function ($inner) use ($token) {
$inner->whereNull('token_hash')
->where('token', $token);
});
})
->when(! $includeInactive, function ($query) {
$query->whereNull('revoked_at')
->where(function ($query) {
@@ -85,8 +99,25 @@ class EventJoinTokenService
{
do {
$token = Str::random($length);
} while (EventJoinToken::where('token', $token)->exists());
$hash = $this->hashToken($token);
} while (EventJoinToken::where('token_hash', $hash)->exists());
return $token;
}
protected function hashToken(string $token): string
{
return hash('sha256', $token);
}
protected function previewToken(string $token): string
{
$length = strlen($token);
if ($length <= 10) {
return $token;
}
return substr($token, 0, 6).'…'.substr($token, -4);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Services\Security;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class PhotoSecurityScanner
{
public function scan(string $disk, ?string $relativePath): array
{
if (! $relativePath) {
return [
'status' => 'error',
'message' => 'Missing path for antivirus scan.',
];
}
if (! config('security.antivirus.enabled', false)) {
return [
'status' => 'skipped',
'message' => 'Antivirus scanning disabled.',
];
}
[$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath);
if (! $absolutePath) {
return [
'status' => 'skipped',
'message' => $message ?? 'Unable to resolve path for antivirus.',
];
}
$binary = config('security.antivirus.binary', '/usr/bin/clamscan');
$arguments = config('security.antivirus.arguments', '--no-summary');
$timeout = config('security.antivirus.timeout', 60);
$command = $binary.' '.$arguments.' '.escapeshellarg($absolutePath);
try {
$process = Process::fromShellCommandline($command);
$process->setTimeout($timeout);
$process->run();
if ($process->isSuccessful()) {
return [
'status' => 'clean',
'message' => trim($process->getOutput()),
];
}
if ($process->getExitCode() === 1) {
return [
'status' => 'infected',
'message' => trim($process->getOutput() ?: $process->getErrorOutput()),
];
}
return [
'status' => 'error',
'message' => trim($process->getErrorOutput() ?: 'Unknown antivirus error.'),
];
} catch (ProcessFailedException $exception) {
return [
'status' => 'error',
'message' => $exception->getMessage(),
];
} catch (\Throwable $exception) {
Log::warning('[PhotoSecurity] Antivirus scan failed', [
'disk' => $disk,
'path' => $relativePath,
'error' => $exception->getMessage(),
]);
return [
'status' => 'error',
'message' => $exception->getMessage(),
];
}
}
public function stripExif(string $disk, ?string $relativePath): array
{
if (! config('security.exif.strip', true)) {
return [
'status' => 'skipped',
'message' => 'EXIF stripping disabled.',
];
}
if (! $relativePath) {
return [
'status' => 'error',
'message' => 'Missing path for EXIF stripping.',
];
}
[$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath);
if (! $absolutePath) {
return [
'status' => 'skipped',
'message' => $message ?? 'Unable to resolve path for EXIF stripping.',
];
}
$extension = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
return [
'status' => 'skipped',
'message' => 'Unsupported format for EXIF stripping.',
];
}
try {
$contents = @file_get_contents($absolutePath);
if ($contents === false) {
throw new \RuntimeException('Unable to read file for EXIF stripping.');
}
$image = @imagecreatefromstring($contents);
if (! $image) {
return [
'status' => 'skipped',
'message' => 'Unable to decode image for EXIF stripping.',
];
}
$result = match ($extension) {
'png' => imagepng($image, $absolutePath),
'webp' => function_exists('imagewebp') ? imagewebp($image, $absolutePath) : false,
default => imagejpeg($image, $absolutePath, 90),
};
imagedestroy($image);
if (! $result) {
return [
'status' => 'error',
'message' => 'Failed to re-encode image without EXIF.',
];
}
return [
'status' => 'stripped',
'message' => 'EXIF metadata removed via re-encode.',
];
} catch (\Throwable $exception) {
Log::warning('[PhotoSecurity] EXIF stripping failed', [
'disk' => $disk,
'path' => $relativePath,
'error' => $exception->getMessage(),
]);
return [
'status' => 'error',
'message' => $exception->getMessage(),
];
}
}
/**
* @return array{0: string|null, 1: string|null}
*/
private function resolveAbsolutePath(string $disk, string $relativePath): array
{
try {
$storage = Storage::disk($disk);
if (! method_exists($storage, 'path')) {
return [null, 'Storage driver does not expose local paths.'];
}
$absolute = $storage->path($relativePath);
if (! file_exists($absolute)) {
return [null, 'File not found on disk.'];
}
return [$absolute, null];
} catch (\Throwable $exception) {
Log::warning('[PhotoSecurity] Unable to resolve absolute path', [
'disk' => $disk,
'path' => $relativePath,
'error' => $exception->getMessage(),
]);
return [null, $exception->getMessage()];
}
}
}