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:
69
app/Console/Commands/OAuthListKeysCommand.php
Normal file
69
app/Console/Commands/OAuthListKeysCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
app/Console/Commands/OAuthPruneKeysCommand.php
Normal file
77
app/Console/Commands/OAuthPruneKeysCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
|
||||
200
app/Filament/Resources/RefreshTokenResource.php
Normal file
200
app/Filament/Resources/RefreshTokenResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
154
app/Http/Middleware/ContentSecurityPolicy.php
Normal file
154
app/Http/Middleware/ContentSecurityPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
85
app/Jobs/ProcessPhotoSecurityScan.php
Normal file
85
app/Jobs/ProcessPhotoSecurityScan.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
50
app/Models/EventJoinTokenEvent.php
Normal file
50
app/Models/EventJoinTokenEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Models/RefreshTokenAudit.php
Normal file
46
app/Models/RefreshTokenAudit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
112
app/Services/Analytics/JoinTokenAnalyticsRecorder.php
Normal file
112
app/Services/Analytics/JoinTokenAnalyticsRecorder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
279
app/Services/Checkout/CheckoutWebhookService.php
Normal file
279
app/Services/Checkout/CheckoutWebhookService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
195
app/Services/Security/PhotoSecurityScanner.php
Normal file
195
app/Services/Security/PhotoSecurityScanner.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user