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:
16
.env.example
16
.env.example
@@ -87,5 +87,21 @@ OAUTH_JWT_KID=fotospiel-jwt
|
|||||||
OAUTH_KEY_STORE=
|
OAUTH_KEY_STORE=
|
||||||
OAUTH_REFRESH_ENFORCE_IP=true
|
OAUTH_REFRESH_ENFORCE_IP=true
|
||||||
OAUTH_REFRESH_ALLOW_SUBNET=false
|
OAUTH_REFRESH_ALLOW_SUBNET=false
|
||||||
|
OAUTH_REFRESH_MAX_ACTIVE=5
|
||||||
|
OAUTH_REFRESH_AUDIT_RETENTION_DAYS=90
|
||||||
|
JOIN_TOKEN_FAILURE_LIMIT=10
|
||||||
|
JOIN_TOKEN_FAILURE_DECAY=5
|
||||||
|
JOIN_TOKEN_ACCESS_LIMIT=120
|
||||||
|
JOIN_TOKEN_ACCESS_DECAY=1
|
||||||
|
JOIN_TOKEN_DOWNLOAD_LIMIT=60
|
||||||
|
JOIN_TOKEN_DOWNLOAD_DECAY=1
|
||||||
|
|
||||||
|
# Security scanning
|
||||||
|
SECURITY_AV_ENABLED=false
|
||||||
|
SECURITY_AV_BINARY=/usr/bin/clamscan
|
||||||
|
SECURITY_AV_ARGUMENTS=--no-summary
|
||||||
|
SECURITY_AV_TIMEOUT=60
|
||||||
|
SECURITY_STRIP_EXIF=true
|
||||||
|
SECURITY_SCAN_QUEUE=media-security
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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) {
|
if ($archiveDir) {
|
||||||
$this->line("Previous keys archived at: {$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->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;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ class OAuthRotateKeysCommand extends Command
|
|||||||
if (File::exists($existingDir)) {
|
if (File::exists($existingDir)) {
|
||||||
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
|
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
|
||||||
File::ensureDirectoryExists(dirname($archiveDir));
|
File::ensureDirectoryExists(dirname($archiveDir));
|
||||||
File::moveDirectory($existingDir, $archiveDir);
|
File::copyDirectory($existingDir, $archiveDir);
|
||||||
return $archiveDir;
|
return $archiveDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +70,11 @@ class OAuthRotateKeysCommand extends Command
|
|||||||
File::ensureDirectoryExists($archiveDir);
|
File::ensureDirectoryExists($archiveDir);
|
||||||
|
|
||||||
if (File::exists($legacyPublic)) {
|
if (File::exists($legacyPublic)) {
|
||||||
File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
|
File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File::exists($legacyPrivate)) {
|
if (File::exists($legacyPrivate)) {
|
||||||
File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
|
File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $archiveDir;
|
return $archiveDir;
|
||||||
@@ -108,4 +111,3 @@ class OAuthRotateKeysCommand extends Command
|
|||||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
$resumeUrl = $this->generateResumeUrl($checkout);
|
$resumeUrl = $this->generateResumeUrl($checkout);
|
||||||
|
|
||||||
if (!$isDryRun) {
|
if (!$isDryRun) {
|
||||||
Mail::to($checkout->user)->queue(
|
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
||||||
|
|
||||||
|
Mail::to($checkout->user)
|
||||||
|
->locale($mailLocale)
|
||||||
|
->queue(
|
||||||
new AbandonedCheckout(
|
new AbandonedCheckout(
|
||||||
$checkout->user,
|
$checkout->user,
|
||||||
$checkout->package,
|
$checkout->package,
|
||||||
@@ -75,7 +79,8 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
$checkout->updateReminderStage($stage);
|
$checkout->updateReminderStage($stage);
|
||||||
$totalSent++;
|
$totalSent++;
|
||||||
} else {
|
} 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++;
|
$totalProcessed++;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationM
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\EventJoinTokenEvent;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
|
use Carbon\Carbon;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
@@ -149,8 +151,39 @@ class EventResource extends Resource
|
|||||||
->modalContent(function ($record) {
|
->modalContent(function ($record) {
|
||||||
$tokens = $record->joinTokens()
|
$tokens = $record->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
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()
|
->get()
|
||||||
->map(function ($token) use ($record) {
|
->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) {
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
||||||
return route('tenant.events.join-tokens.layouts.download', [
|
return route('tenant.events.join-tokens.layouts.download', [
|
||||||
'event' => $record->slug,
|
'event' => $record->slug,
|
||||||
@@ -160,6 +193,21 @@ class EventResource extends Resource
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$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 [
|
return [
|
||||||
'id' => $token->id,
|
'id' => $token->id,
|
||||||
'label' => $token->label,
|
'label' => $token->label,
|
||||||
@@ -176,6 +224,13 @@ class EventResource extends Resource
|
|||||||
'event' => $record->slug,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'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,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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\Support\TenantOnboardingState;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
|
use App\Models\EventJoinTokenEvent;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@@ -149,8 +151,39 @@ class EventResource extends Resource
|
|||||||
->modalContent(function ($record) {
|
->modalContent(function ($record) {
|
||||||
$tokens = $record->joinTokens()
|
$tokens = $record->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
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()
|
->get()
|
||||||
->map(function ($token) use ($record) {
|
->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) {
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
||||||
return route('tenant.events.join-tokens.layouts.download', [
|
return route('tenant.events.join-tokens.layouts.download', [
|
||||||
'event' => $record->slug,
|
'event' => $record->slug,
|
||||||
@@ -160,6 +193,21 @@ class EventResource extends Resource
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$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 [
|
return [
|
||||||
'id' => $token->id,
|
'id' => $token->id,
|
||||||
'label' => $token->label,
|
'label' => $token->label,
|
||||||
@@ -176,6 +224,13 @@ class EventResource extends Resource
|
|||||||
'event' => $record->slug,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'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,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
|
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
@@ -24,9 +26,12 @@ use App\Models\EventMediaAsset;
|
|||||||
|
|
||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
{
|
{
|
||||||
|
private const SIGNED_URL_TTL_SECONDS = 1800;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EventJoinTokenService $joinTokenService,
|
private readonly EventJoinTokenService $joinTokenService,
|
||||||
private readonly EventStorageManager $eventStorageManager,
|
private readonly EventStorageManager $eventStorageManager,
|
||||||
|
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,34 +45,65 @@ class EventPublicController extends BaseController
|
|||||||
$joinToken = $this->joinTokenService->findToken($token, true);
|
$joinToken = $this->joinTokenService->findToken($token, true);
|
||||||
|
|
||||||
if (! $joinToken) {
|
if (! $joinToken) {
|
||||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
|
return $this->handleTokenFailure(
|
||||||
|
$request,
|
||||||
|
$rateLimiterKey,
|
||||||
|
'invalid_token',
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
[
|
||||||
'token' => Str::limit($token, 12),
|
'token' => Str::limit($token, 12),
|
||||||
]);
|
],
|
||||||
|
$token
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($joinToken->revoked_at !== null) {
|
if ($joinToken->revoked_at !== null) {
|
||||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [
|
return $this->handleTokenFailure(
|
||||||
|
$request,
|
||||||
|
$rateLimiterKey,
|
||||||
|
'token_revoked',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
[
|
||||||
'token' => Str::limit($token, 12),
|
'token' => Str::limit($token, 12),
|
||||||
]);
|
],
|
||||||
|
$token,
|
||||||
|
$joinToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($joinToken->expires_at !== null) {
|
if ($joinToken->expires_at !== null) {
|
||||||
$expiresAt = CarbonImmutable::parse($joinToken->expires_at);
|
$expiresAt = CarbonImmutable::parse($joinToken->expires_at);
|
||||||
|
|
||||||
if ($expiresAt->isPast()) {
|
if ($expiresAt->isPast()) {
|
||||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
|
return $this->handleTokenFailure(
|
||||||
|
$request,
|
||||||
|
$rateLimiterKey,
|
||||||
|
'token_expired',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
[
|
||||||
'token' => Str::limit($token, 12),
|
'token' => Str::limit($token, 12),
|
||||||
'expired_at' => $expiresAt->toAtomString(),
|
'expired_at' => $expiresAt->toAtomString(),
|
||||||
]);
|
],
|
||||||
|
$token,
|
||||||
|
$joinToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
|
if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
|
||||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
|
return $this->handleTokenFailure(
|
||||||
|
$request,
|
||||||
|
$rateLimiterKey,
|
||||||
|
'token_expired',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
[
|
||||||
'token' => Str::limit($token, 12),
|
'token' => Str::limit($token, 12),
|
||||||
'usage_count' => $joinToken->usage_count,
|
'usage_count' => $joinToken->usage_count,
|
||||||
'usage_limit' => $joinToken->usage_limit,
|
'usage_limit' => $joinToken->usage_limit,
|
||||||
]);
|
],
|
||||||
|
$token,
|
||||||
|
$joinToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$columns = array_unique(array_merge($columns, ['status']));
|
$columns = array_unique(array_merge($columns, ['status']));
|
||||||
@@ -77,10 +113,18 @@ class EventPublicController extends BaseController
|
|||||||
->first($columns);
|
->first($columns);
|
||||||
|
|
||||||
if (! $event) {
|
if (! $event) {
|
||||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
|
return $this->handleTokenFailure(
|
||||||
|
$request,
|
||||||
|
$rateLimiterKey,
|
||||||
|
'invalid_token',
|
||||||
|
Response::HTTP_NOT_FOUND,
|
||||||
|
[
|
||||||
'token' => Str::limit($token, 12),
|
'token' => Str::limit($token, 12),
|
||||||
'reason' => 'event_missing',
|
'reason' => 'event_missing',
|
||||||
]);
|
],
|
||||||
|
$token,
|
||||||
|
$joinToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($event->status ?? null) !== 'published') {
|
if (($event->status ?? null) !== 'published') {
|
||||||
@@ -90,6 +134,18 @@ class EventPublicController extends BaseController
|
|||||||
'ip' => $request->ip(),
|
'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([
|
return response()->json([
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'event_not_public',
|
'code' => 'event_not_public',
|
||||||
@@ -104,6 +160,22 @@ class EventPublicController extends BaseController
|
|||||||
unset($event->status);
|
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];
|
return [$event, $joinToken];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +206,18 @@ class EventPublicController extends BaseController
|
|||||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||||
|
|
||||||
if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
|
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([
|
return response()->json([
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'gallery_expired',
|
'code' => 'gallery_expired',
|
||||||
@@ -143,16 +227,47 @@ class EventPublicController extends BaseController
|
|||||||
], Response::HTTP_GONE);
|
], Response::HTTP_GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->recordTokenEvent(
|
||||||
|
$joinToken,
|
||||||
|
$request,
|
||||||
|
'gallery_access_granted',
|
||||||
|
[
|
||||||
|
'event_id' => $event->id,
|
||||||
|
],
|
||||||
|
$token,
|
||||||
|
Response::HTTP_OK
|
||||||
|
);
|
||||||
|
|
||||||
return [$event, $joinToken];
|
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([
|
Log::warning('Join token rate limit exceeded', array_merge([
|
||||||
'ip' => $request->ip(),
|
'ip' => $request->ip(),
|
||||||
], $context));
|
], $context));
|
||||||
|
|
||||||
|
$this->recordTokenEvent(
|
||||||
|
$joinToken,
|
||||||
|
$request,
|
||||||
|
'token_rate_limited',
|
||||||
|
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
|
||||||
|
$rawToken,
|
||||||
|
Response::HTTP_TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'token_rate_limited',
|
'code' => 'token_rate_limited',
|
||||||
@@ -161,13 +276,22 @@ class EventPublicController extends BaseController
|
|||||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::hit($rateLimiterKey, 300);
|
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
|
||||||
|
|
||||||
Log::notice('Join token access denied', array_merge([
|
Log::notice('Join token access denied', array_merge([
|
||||||
'code' => $code,
|
'code' => $code,
|
||||||
'ip' => $request->ip(),
|
'ip' => $request->ip(),
|
||||||
], $context));
|
], $context));
|
||||||
|
|
||||||
|
$this->recordTokenEvent(
|
||||||
|
$joinToken,
|
||||||
|
$request,
|
||||||
|
$code,
|
||||||
|
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
|
||||||
|
$rawToken,
|
||||||
|
$status
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => $code,
|
'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 = '') {
|
private function getLocalized($value, $locale, $default = '') {
|
||||||
if (is_string($value) && json_decode($value) !== null) {
|
if (is_string($value) && json_decode($value) !== null) {
|
||||||
$data = json_decode($value, true);
|
$data = json_decode($value, true);
|
||||||
@@ -288,23 +495,50 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
private function makeGalleryPhotoResource(Photo $photo, string $token): array
|
private function makeGalleryPhotoResource(Photo $photo, string $token): array
|
||||||
{
|
{
|
||||||
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
|
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
|
||||||
$full = $this->toPublicUrl($photo->file_path ?? null);
|
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
|
||||||
|
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $photo->id,
|
'id' => $photo->id,
|
||||||
'thumbnail_url' => $thumbnail,
|
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
|
||||||
'full_url' => $full,
|
'full_url' => $fullUrl,
|
||||||
'download_url' => route('api.v1.gallery.photos.download', [
|
'download_url' => $downloadUrl,
|
||||||
'token' => $token,
|
|
||||||
'photo' => $photo->id,
|
|
||||||
]),
|
|
||||||
'likes_count' => $photo->likes_count,
|
'likes_count' => $photo->likes_count,
|
||||||
'guest_name' => $photo->guest_name,
|
'guest_name' => $photo->guest_name,
|
||||||
'created_at' => $photo->created_at?->toIso8601String(),
|
'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)
|
public function gallery(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$locale = $request->query('locale', app()->getLocale());
|
$locale = $request->query('locale', app()->getLocale());
|
||||||
@@ -316,7 +550,11 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
/** @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);
|
$branding = $this->buildGalleryBranding($event);
|
||||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||||
@@ -342,7 +580,11 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
/** @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 = (int) $request->query('limit', 30);
|
||||||
$limit = max(1, min($limit, 60));
|
$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)
|
public function galleryPhotoDownload(Request $request, string $token, int $photo)
|
||||||
{
|
{
|
||||||
$resolved = $this->resolveGalleryEvent($request, $token);
|
$resolved = $this->resolveGalleryEvent($request, $token);
|
||||||
@@ -415,52 +690,7 @@ class EventPublicController extends BaseController
|
|||||||
], Response::HTTP_NOT_FOUND);
|
], Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function event(Request $request, string $token)
|
public function event(Request $request, string $token)
|
||||||
@@ -518,6 +748,160 @@ class EventPublicController extends BaseController
|
|||||||
])->header('Cache-Control', 'no-store');
|
])->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)
|
public function stats(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
@@ -526,7 +910,7 @@ class EventPublicController extends BaseController
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$event] = $result;
|
[$event, $joinToken] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
|
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
|
||||||
|
|
||||||
@@ -866,6 +1250,19 @@ class EventPublicController extends BaseController
|
|||||||
// Per-device cap per event (MVP: 50)
|
// Per-device cap per event (MVP: 50)
|
||||||
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
||||||
if ($deviceCount >= 50) {
|
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);
|
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,11 +1333,26 @@ class EventPublicController extends BaseController
|
|||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
->update(['media_asset_id' => $asset->id]);
|
->update(['media_asset_id' => $asset->id]);
|
||||||
|
|
||||||
return response()->json([
|
$response = response()->json([
|
||||||
'id' => $photoId,
|
'id' => $photoId,
|
||||||
'file_path' => $url,
|
'file_path' => $url,
|
||||||
'thumbnail_path' => $thumbUrl,
|
'thumbnail_path' => $thumbUrl,
|
||||||
], 201);
|
], 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('Cache-Control', 'no-store')
|
||||||
->header('ETag', $etag);
|
->header('ETag', $etag);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveDiskUrl(string $disk, string $path): string
|
private function resolveDiskUrl(string $disk, string $path): string
|
||||||
{
|
{
|
||||||
@@ -1217,3 +1628,5 @@ class EventPublicController extends BaseController
|
|||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\PackagePurchase;
|
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Checkout\CheckoutWebhookService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -15,6 +16,10 @@ use Stripe\Webhook;
|
|||||||
|
|
||||||
class StripeWebhookController extends Controller
|
class StripeWebhookController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function handleWebhook(Request $request)
|
public function handleWebhook(Request $request)
|
||||||
{
|
{
|
||||||
$payload = $request->getContent();
|
$payload = $request->getContent();
|
||||||
@@ -23,7 +28,9 @@ class StripeWebhookController extends Controller
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$event = Webhook::constructEvent(
|
$event = Webhook::constructEvent(
|
||||||
$payload, $sigHeader, $endpointSecret
|
$payload,
|
||||||
|
$sigHeader,
|
||||||
|
$endpointSecret
|
||||||
);
|
);
|
||||||
} catch (SignatureVerificationException $e) {
|
} catch (SignatureVerificationException $e) {
|
||||||
return response()->json(['error' => 'Invalid signature'], 400);
|
return response()->json(['error' => 'Invalid signature'], 400);
|
||||||
@@ -31,54 +38,81 @@ class StripeWebhookController extends Controller
|
|||||||
return response()->json(['error' => 'Invalid payload'], 400);
|
return response()->json(['error' => 'Invalid payload'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the event
|
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
|
||||||
switch ($event['type']) {
|
|
||||||
|
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':
|
case 'payment_intent.succeeded':
|
||||||
$paymentIntent = $event['data']['object'];
|
$paymentIntent = $event['data']['object'] ?? [];
|
||||||
$this->handlePaymentIntentSucceeded($paymentIntent);
|
$this->handlePaymentIntentSucceeded($paymentIntent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'invoice.paid':
|
case 'invoice.paid':
|
||||||
$invoice = $event['data']['object'];
|
$invoice = $event['data']['object'] ?? [];
|
||||||
$this->handleInvoicePaid($invoice);
|
$this->handleInvoicePaid($invoice);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
|
Log::info('Unhandled Stripe event', ['type' => $type]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'success'], 200);
|
return response()->json(['status' => 'success'], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handlePaymentIntentSucceeded(array $paymentIntent)
|
private function handlePaymentIntentSucceeded(array $paymentIntent): void
|
||||||
{
|
{
|
||||||
$metadata = $paymentIntent['metadata'];
|
$metadata = $paymentIntent['metadata'] ?? [];
|
||||||
$packageId = $metadata['package_id'];
|
$packageId = $metadata['package_id'] ?? null;
|
||||||
$type = $metadata['type'];
|
$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) {
|
DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
|
||||||
// Create purchase record
|
|
||||||
$purchase = PackagePurchase::create([
|
$purchase = PackagePurchase::create([
|
||||||
'package_id' => $packageId,
|
'package_id' => $packageId,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'provider_id' => 'stripe',
|
'provider_id' => 'stripe',
|
||||||
'transaction_id' => $paymentIntent['id'],
|
'transaction_id' => $paymentIntent['id'] ?? null,
|
||||||
'price' => $paymentIntent['amount_received'] / 100,
|
'price' => isset($paymentIntent['amount_received'])
|
||||||
|
? $paymentIntent['amount_received'] / 100
|
||||||
|
: 0,
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($type === 'endcustomer_event') {
|
if ($type === 'endcustomer_event') {
|
||||||
$eventId = $metadata['event_id'];
|
$eventId = $metadata['event_id'] ?? null;
|
||||||
|
if (! $eventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $eventId,
|
'event_id' => $eventId,
|
||||||
'package_id' => $packageId,
|
'package_id' => $packageId,
|
||||||
'package_purchase_id' => $purchase->id,
|
'package_purchase_id' => $purchase->id,
|
||||||
'used_photos' => 0,
|
'used_photos' => 0,
|
||||||
'used_guests' => 0,
|
'used_guests' => 0,
|
||||||
'expires_at' => now()->addDays(30), // Default, or from package
|
'expires_at' => now()->addDays(30),
|
||||||
]);
|
]);
|
||||||
} elseif ($type === 'reseller_subscription') {
|
} elseif ($type === 'reseller_subscription') {
|
||||||
$tenantId = $metadata['tenant_id'];
|
$tenantId = $metadata['tenant_id'] ?? null;
|
||||||
|
if (! $tenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
TenantPackage::create([
|
TenantPackage::create([
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'package_id' => $packageId,
|
'package_id' => $packageId,
|
||||||
@@ -88,7 +122,7 @@ class StripeWebhookController extends Controller
|
|||||||
'expires_at' => now()->addYear(),
|
'expires_at' => now()->addYear(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::find($metadata['user_id']);
|
$user = User::find($metadata['user_id'] ?? null);
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->update(['role' => 'tenant_admin']);
|
$user->update(['role' => 'tenant_admin']);
|
||||||
}
|
}
|
||||||
@@ -96,16 +130,18 @@ class StripeWebhookController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleInvoicePaid(array $invoice)
|
private function handleInvoicePaid(array $invoice): void
|
||||||
{
|
{
|
||||||
$subscription = $invoice['subscription'];
|
$subscription = $invoice['subscription'] ?? null;
|
||||||
$metadata = $subscription['metadata'] ?? [];
|
$metadata = $subscription['metadata'] ?? [];
|
||||||
|
|
||||||
if (isset($metadata['tenant_id'])) {
|
if (! isset($metadata['tenant_id'], $metadata['package_id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$tenantId = $metadata['tenant_id'];
|
$tenantId = $metadata['tenant_id'];
|
||||||
$packageId = $metadata['package_id'];
|
$packageId = $metadata['package_id'];
|
||||||
|
|
||||||
// Renew or create tenant package
|
|
||||||
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
|
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
|
||||||
->where('package_id', $packageId)
|
->where('package_id', $packageId)
|
||||||
->where('stripe_subscription_id', $subscription)
|
->where('stripe_subscription_id', $subscription)
|
||||||
@@ -132,15 +168,14 @@ class StripeWebhookController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create purchase record
|
|
||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'package_id' => $packageId,
|
'package_id' => $packageId,
|
||||||
'type' => 'reseller_subscription',
|
'type' => 'reseller_subscription',
|
||||||
'provider_id' => 'stripe',
|
'provider_id' => 'stripe',
|
||||||
'transaction_id' => $invoice['id'],
|
'transaction_id' => $invoice['id'] ?? null,
|
||||||
'price' => $invoice['amount_paid'] / 100,
|
'price' => isset($invoice['amount_paid']) ? $invoice['amount_paid'] / 100 : 0,
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Event;
|
|||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
|
use App\Jobs\ProcessPhotoSecurityScan;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -157,6 +158,8 @@ class PhotoController extends Controller
|
|||||||
[$width, $height] = getimagesize($file->getRealPath());
|
[$width, $height] = getimagesize($file->getRealPath());
|
||||||
$photo->update(['width' => $width, 'height' => $height]);
|
$photo->update(['width' => $width, 'height' => $height]);
|
||||||
|
|
||||||
|
ProcessPhotoSecurityScan::dispatch($photo->id);
|
||||||
|
|
||||||
$photo->load('event')->loadCount('likes');
|
$photo->load('event')->loadCount('likes');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ class RegisteredUserController extends Controller
|
|||||||
event(new Registered($user));
|
event(new Registered($user));
|
||||||
|
|
||||||
// Send Welcome Email
|
// 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')) {
|
if ($request->filled('package_id')) {
|
||||||
$package = \App\Models\Package::find($request->package_id);
|
$package = \App\Models\Package::find($request->package_id);
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ class CheckoutController extends Controller
|
|||||||
$user->sendEmailVerificationNotification();
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
// Willkommens-E-Mail senden
|
// 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([
|
return response()->json([
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ class CheckoutGoogleController extends Controller
|
|||||||
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Mail::to($user)->queue(new Welcome($user));
|
Mail::to($user)
|
||||||
|
->locale($user->preferred_locale ?? app()->getLocale())
|
||||||
|
->queue(new Welcome($user));
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Log::warning('Failed to queue welcome mail after Google signup', [
|
Log::warning('Failed to queue welcome mail after Google signup', [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
|
|||||||
@@ -60,14 +60,28 @@ class MarketingController extends Controller
|
|||||||
'message' => 'required|string|max:1000',
|
'message' => 'required|string|max:1000',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Mail::raw("Kontakt-Anfrage von {$request->name} ({$request->email}): {$request->message}", function ($message) use ($request) {
|
$locale = app()->getLocale();
|
||||||
$message->to('admin@fotospiel.de')
|
$contactAddress = config('mail.contact_address', config('mail.from.address')) ?: 'admin@fotospiel.de';
|
||||||
->subject('Neue Kontakt-Anfrage');
|
|
||||||
});
|
|
||||||
|
|
||||||
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()
|
public function contactView()
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ class OAuthController extends Controller
|
|||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||||
'client_id' => $request->client_id,
|
'client_id' => $request->client_id,
|
||||||
'refresh_token_id' => $storedRefreshToken->id,
|
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -181,7 +180,6 @@ class OAuthController extends Controller
|
|||||||
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
|
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
|
||||||
Log::warning('[OAuth] Authorization code missing or expired', [
|
Log::warning('[OAuth] Authorization code missing or expired', [
|
||||||
'client_id' => $request->client_id,
|
'client_id' => $request->client_id,
|
||||||
'refresh_token_id' => $storedRefreshToken->id,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->errorResponse('Invalid or expired authorization code', 400);
|
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)) {
|
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
|
||||||
Log::warning('[OAuth] Authorization code validation failed', [
|
Log::warning('[OAuth] Authorization code validation failed', [
|
||||||
'client_id' => $request->client_id,
|
'client_id' => $request->client_id,
|
||||||
'refresh_token_id' => $storedRefreshToken->id,
|
'oauth_code_id' => $oauthCode?->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->errorResponse('Invalid authorization code', 400);
|
return $this->errorResponse('Invalid authorization code', 400);
|
||||||
@@ -222,7 +220,7 @@ class OAuthController extends Controller
|
|||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||||
'client_id' => $request->client_id,
|
'client_id' => $request->client_id,
|
||||||
'refresh_token_id' => $storedRefreshToken->id,
|
'oauth_code_id' => $oauthCode->id ?? null,
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -271,16 +269,33 @@ class OAuthController extends Controller
|
|||||||
return $this->errorResponse('Invalid refresh token', 400);
|
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) {
|
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);
|
return $this->errorResponse('Refresh token does not match client', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
|
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);
|
return $this->errorResponse('Refresh token expired', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
|
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);
|
return $this->errorResponse('Invalid refresh token', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +303,6 @@ class OAuthController extends Controller
|
|||||||
$currentIp = (string) ($request->ip() ?? '');
|
$currentIp = (string) ($request->ip() ?? '');
|
||||||
|
|
||||||
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
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', [
|
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
|
||||||
'client_id' => $request->client_id,
|
'client_id' => $request->client_id,
|
||||||
'refresh_token_id' => $storedRefreshToken->id,
|
'refresh_token_id' => $storedRefreshToken->id,
|
||||||
@@ -297,6 +310,11 @@ class OAuthController extends Controller
|
|||||||
'current_ip' => $currentIp,
|
'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);
|
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,
|
'tenant_id' => $storedRefreshToken->tenant_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$storedRefreshToken->revoke('tenant_missing', null, $request, [
|
||||||
|
'missing_tenant_id' => $storedRefreshToken->tenant_id,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->errorResponse('Tenant not found', 404);
|
return $this->errorResponse('Tenant not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopes = $this->parseScopes($storedRefreshToken->scope);
|
$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);
|
$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);
|
return response()->json($tokenResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,18 +409,45 @@ class OAuthController extends Controller
|
|||||||
$composite = $refreshTokenId.'|'.$secret;
|
$composite = $refreshTokenId.'|'.$secret;
|
||||||
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
|
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
|
||||||
|
|
||||||
RefreshToken::create([
|
/** @var RefreshToken $refreshToken */
|
||||||
|
$refreshToken = RefreshToken::create([
|
||||||
'id' => $refreshTokenId,
|
'id' => $refreshTokenId,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'client_id' => $client->client_id,
|
'client_id' => $client->client_id,
|
||||||
'token' => Hash::make($secret),
|
'token' => Hash::make($secret),
|
||||||
'access_token' => $accessTokenJti,
|
'access_token' => $accessTokenJti,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => $expiresAt,
|
||||||
|
'last_used_at' => now(),
|
||||||
'scope' => implode(' ', $scopes),
|
'scope' => implode(' ', $scopes),
|
||||||
'ip_address' => $request->ip(),
|
'ip_address' => $request->ip(),
|
||||||
'user_agent' => $request->userAgent(),
|
'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;
|
return $composite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ use App\Models\Package;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use App\Services\PayPal\PaypalClientFactory;
|
use App\Services\PayPal\PaypalClientFactory;
|
||||||
|
use App\Services\Checkout\CheckoutWebhookService;
|
||||||
|
|
||||||
class PayPalWebhookController extends Controller
|
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
|
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']);
|
Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']);
|
||||||
|
|
||||||
|
if ($this->checkoutWebhooks->handlePayPalEvent($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch ($eventType) {
|
switch ($eventType) {
|
||||||
case 'CHECKOUT.ORDER.APPROVED':
|
case 'CHECKOUT.ORDER.APPROVED':
|
||||||
// Handle order approval if needed
|
// 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;
|
: null;
|
||||||
|
|
||||||
|
$plainToken = $this->resource->plain_token ?? $this->token;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'token' => $this->token,
|
'token' => $plainToken,
|
||||||
'url' => url('/e/'.$this->token),
|
'token_preview' => $this->token_preview,
|
||||||
|
'url' => $plainToken ? url('/e/'.$plainToken) : null,
|
||||||
'usage_limit' => $this->usage_limit,
|
'usage_limit' => $this->usage_limit,
|
||||||
'usage_count' => $this->usage_count,
|
'usage_count' => $this->usage_count,
|
||||||
'expires_at' => optional($this->expires_at)->toIso8601String(),
|
'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 function __construct(
|
||||||
public User $user,
|
public User $user,
|
||||||
public Package $package,
|
public Package $package,
|
||||||
public string $timing, // '1h', '24h', '1w'
|
public string $timing,
|
||||||
public string $resumeUrl
|
public string $resumeUrl
|
||||||
) {
|
) {
|
||||||
//
|
//
|
||||||
@@ -25,10 +25,9 @@ class AbandonedCheckout extends Mailable
|
|||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
$subjectKey = 'emails.abandoned_checkout.subject_' . $this->timing;
|
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: __('emails.abandoned_checkout.subject_' . $this->timing, [
|
subject: __('emails.abandoned_checkout.subject_' . $this->timing, [
|
||||||
'package' => $this->package->name
|
'package' => $this->localizedPackageName(),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,6 +39,7 @@ class AbandonedCheckout extends Mailable
|
|||||||
with: [
|
with: [
|
||||||
'user' => $this->user,
|
'user' => $this->user,
|
||||||
'package' => $this->package,
|
'package' => $this->package,
|
||||||
|
'packageName' => $this->localizedPackageName(),
|
||||||
'timing' => $this->timing,
|
'timing' => $this->timing,
|
||||||
'resumeUrl' => $this->resumeUrl,
|
'resumeUrl' => $this->resumeUrl,
|
||||||
],
|
],
|
||||||
@@ -50,4 +50,11 @@ class AbandonedCheckout extends Mailable
|
|||||||
{
|
{
|
||||||
return [];
|
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
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new 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
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new 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,
|
'purchase' => $this->purchase,
|
||||||
'user' => $this->purchase->tenant->user,
|
'user' => $this->purchase->tenant->user,
|
||||||
'package' => $this->purchase->package,
|
'package' => $this->purchase->package,
|
||||||
|
'packageName' => $this->localizedPackageName(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,4 +42,11 @@ class PurchaseConfirmation extends Mailable
|
|||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function localizedPackageName(): string
|
||||||
|
{
|
||||||
|
$locale = $this->locale ?? app()->getLocale();
|
||||||
|
|
||||||
|
return optional($this->purchase->package)->getNameForLocale($locale) ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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
|
class EventJoinToken extends Model
|
||||||
{
|
{
|
||||||
@@ -11,7 +15,9 @@ class EventJoinToken extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'event_id',
|
'event_id',
|
||||||
'token',
|
'token_hash',
|
||||||
|
'token_encrypted',
|
||||||
|
'token_preview',
|
||||||
'label',
|
'label',
|
||||||
'usage_limit',
|
'usage_limit',
|
||||||
'usage_count',
|
'usage_count',
|
||||||
@@ -29,6 +35,15 @@ class EventJoinToken extends Model
|
|||||||
'usage_count' => 'integer',
|
'usage_count' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'token_encrypted',
|
||||||
|
'token_hash',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'token_preview',
|
||||||
|
];
|
||||||
|
|
||||||
public function event(): BelongsTo
|
public function event(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Event::class);
|
return $this->belongsTo(Event::class);
|
||||||
@@ -39,6 +54,11 @@ class EventJoinToken extends Model
|
|||||||
return $this->belongsTo(User::class, 'created_by');
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function analytics(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EventJoinTokenEvent::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
if ($this->revoked_at !== null) {
|
if ($this->revoked_at !== null) {
|
||||||
@@ -55,4 +75,64 @@ class EventJoinToken extends Model
|
|||||||
|
|
||||||
return true;
|
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';
|
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
|
public function getLimitsAttribute(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class Photo extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_featured' => 'boolean',
|
'is_featured' => 'boolean',
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
|
'security_meta' => 'array',
|
||||||
|
'security_scanned_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'security_scan_status' => 'pending',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mediaAsset(): BelongsTo
|
public function mediaAsset(): BelongsTo
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class RefreshToken extends Model
|
class RefreshToken extends Model
|
||||||
{
|
{
|
||||||
@@ -22,14 +24,17 @@ class RefreshToken extends Model
|
|||||||
'token',
|
'token',
|
||||||
'access_token',
|
'access_token',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
|
'last_used_at',
|
||||||
'scope',
|
'scope',
|
||||||
'ip_address',
|
'ip_address',
|
||||||
'user_agent',
|
'user_agent',
|
||||||
'revoked_at',
|
'revoked_at',
|
||||||
|
'revoked_reason',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
'revoked_at' => 'datetime',
|
'revoked_at' => 'datetime',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
];
|
];
|
||||||
@@ -39,23 +44,77 @@ class RefreshToken extends Model
|
|||||||
return $this->belongsTo(Tenant::class);
|
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
|
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)
|
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)
|
public function scopeForTenant($query, string $tenantId)
|
||||||
{
|
{
|
||||||
return $query->where('tenant_id', $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\Notifications\UploadPipelineFailed;
|
||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Services\Storage\StorageHealthService;
|
use App\Services\Storage\StorageHealthService;
|
||||||
|
use App\Services\Security\PhotoSecurityScanner;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Queue\Events\JobFailed;
|
use Illuminate\Queue\Events\JobFailed;
|
||||||
@@ -29,6 +30,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(CheckoutPaymentService::class);
|
$this->app->singleton(CheckoutPaymentService::class);
|
||||||
$this->app->singleton(EventStorageManager::class);
|
$this->app->singleton(EventStorageManager::class);
|
||||||
$this->app->singleton(StorageHealthService::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')) {
|
if (config('storage-monitor.queue_failure_alerts')) {
|
||||||
Queue::failing(function (JobFailed $event) {
|
Queue::failing(function (JobFailed $event) {
|
||||||
$context = [
|
$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) {
|
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) {
|
if ($purchase->wasRecentlyCreated) {
|
||||||
Mail::to($user)->queue(new PurchaseConfirmation($purchase));
|
Mail::to($user)
|
||||||
|
->locale($mailLocale)
|
||||||
|
->queue(new PurchaseConfirmation($purchase));
|
||||||
}
|
}
|
||||||
|
|
||||||
AbandonedCheckout::query()
|
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\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class EventJoinTokenService
|
class EventJoinTokenService
|
||||||
@@ -15,10 +16,13 @@ class EventJoinTokenService
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($event, $attributes) {
|
return DB::transaction(function () use ($event, $attributes) {
|
||||||
$tokenValue = $this->generateUniqueToken();
|
$tokenValue = $this->generateUniqueToken();
|
||||||
|
$tokenHash = $this->hashToken($tokenValue);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'token' => $tokenValue,
|
'token_hash' => $tokenHash,
|
||||||
|
'token_encrypted' => Crypt::encryptString($tokenValue),
|
||||||
|
'token_preview' => $this->previewToken($tokenValue),
|
||||||
'label' => Arr::get($attributes, 'label'),
|
'label' => Arr::get($attributes, 'label'),
|
||||||
'usage_limit' => Arr::get($attributes, 'usage_limit'),
|
'usage_limit' => Arr::get($attributes, 'usage_limit'),
|
||||||
'metadata' => Arr::get($attributes, 'metadata', []),
|
'metadata' => Arr::get($attributes, 'metadata', []),
|
||||||
@@ -34,7 +38,9 @@ class EventJoinTokenService
|
|||||||
$payload['created_by'] = $createdBy;
|
$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
|
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||||
{
|
{
|
||||||
|
$hash = $this->hashToken($token);
|
||||||
|
|
||||||
return EventJoinToken::query()
|
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) {
|
->when(! $includeInactive, function ($query) {
|
||||||
$query->whereNull('revoked_at')
|
$query->whereNull('revoked_at')
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
@@ -85,8 +99,25 @@ class EventJoinTokenService
|
|||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
$token = Str::random($length);
|
$token = Str::random($length);
|
||||||
} while (EventJoinToken::where('token', $token)->exists());
|
$hash = $this->hashToken($token);
|
||||||
|
} while (EventJoinToken::where('token_hash', $hash)->exists());
|
||||||
|
|
||||||
return $token;
|
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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withCommands([
|
->withCommands([
|
||||||
\App\Console\Commands\OAuthRotateKeysCommand::class,
|
\App\Console\Commands\OAuthRotateKeysCommand::class,
|
||||||
|
\App\Console\Commands\OAuthListKeysCommand::class,
|
||||||
|
\App\Console\Commands\OAuthPruneKeysCommand::class,
|
||||||
])
|
])
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
@@ -37,6 +39,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\App\Http\Middleware\SetLocale::class,
|
\App\Http\Middleware\SetLocale::class,
|
||||||
SetLocaleFromUser::class,
|
SetLocaleFromUser::class,
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
|
\App\Http\Middleware\ContentSecurityPolicy::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
12
config/join_tokens.php
Normal file
12
config/join_tokens.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||||
|
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
||||||
|
|
||||||
|
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120),
|
||||||
|
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
||||||
|
|
||||||
|
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60),
|
||||||
|
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
||||||
|
];
|
||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'refresh_tokens' => [
|
'refresh_tokens' => [
|
||||||
'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true),
|
'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true),
|
||||||
'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false),
|
'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false),
|
||||||
|
'max_active_per_tenant' => env('OAUTH_REFRESH_MAX_ACTIVE', 5),
|
||||||
|
'audit_retention_days' => env('OAUTH_REFRESH_AUDIT_RETENTION_DAYS', 90),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
17
config/security.php
Normal file
17
config/security.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'antivirus' => [
|
||||||
|
'enabled' => env('SECURITY_AV_ENABLED', false),
|
||||||
|
'binary' => env('SECURITY_AV_BINARY', '/usr/bin/clamscan'),
|
||||||
|
'arguments' => env('SECURITY_AV_ARGUMENTS', '--no-summary'),
|
||||||
|
'timeout' => (int) env('SECURITY_AV_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
'exif' => [
|
||||||
|
'strip' => env('SECURITY_STRIP_EXIF', true),
|
||||||
|
],
|
||||||
|
'queue' => [
|
||||||
|
'name' => env('SECURITY_SCAN_QUEUE', 'media-security'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$addedTokenHashColumn = false;
|
||||||
|
|
||||||
|
Schema::table('event_join_tokens', function (Blueprint $table) use (&$addedTokenHashColumn) {
|
||||||
|
if (!Schema::hasColumn('event_join_tokens', 'token_hash')) {
|
||||||
|
$table->string('token_hash', 128)->nullable()->after('token');
|
||||||
|
$table->index('token_hash', 'event_join_tokens_token_hash_index');
|
||||||
|
$addedTokenHashColumn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasColumn('event_join_tokens', 'token_encrypted')) {
|
||||||
|
$table->text('token_encrypted')->nullable()->after('token_hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasColumn('event_join_tokens', 'token_preview')) {
|
||||||
|
$table->string('token_preview', 32)->nullable()->after('token_encrypted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('event_join_tokens')
|
||||||
|
->whereNull('token_hash')
|
||||||
|
->whereNotNull('token')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function ($tokens) {
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
$hash = hash('sha256', $token->token);
|
||||||
|
$preview = $this->previewToken($token->token);
|
||||||
|
|
||||||
|
DB::table('event_join_tokens')
|
||||||
|
->where('id', $token->id)
|
||||||
|
->update([
|
||||||
|
'token_hash' => $hash,
|
||||||
|
'token_encrypted' => Crypt::encryptString($token->token),
|
||||||
|
'token_preview' => $preview,
|
||||||
|
'token' => $hash,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($addedTokenHashColumn) {
|
||||||
|
Schema::table('event_join_tokens', function (Blueprint $table) {
|
||||||
|
$table->unique('token_hash', 'event_join_tokens_token_hash_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_join_tokens', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('event_join_tokens', 'token_hash')) {
|
||||||
|
try {
|
||||||
|
$table->dropUnique('event_join_tokens_token_hash_unique');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Unique index might already be absent (e.g. partial rollback).
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$table->dropIndex('event_join_tokens_token_hash_index');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Index might already be absent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = collect(['token_hash', 'token_encrypted', 'token_preview'])
|
||||||
|
->filter(fn ($column) => Schema::hasColumn('event_join_tokens', $column))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if (!empty($columns)) {
|
||||||
|
$table->dropColumn($columns);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previewToken(string $token): string
|
||||||
|
{
|
||||||
|
$length = strlen($token);
|
||||||
|
|
||||||
|
if ($length <= 10) {
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($token, 0, 6).'…'.substr($token, -4);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('photos', function (Blueprint $table) {
|
||||||
|
$table->string('security_scan_status')->default('pending')->after('metadata');
|
||||||
|
$table->text('security_scan_message')->nullable()->after('security_scan_status');
|
||||||
|
$table->timestamp('security_scanned_at')->nullable()->after('security_scan_message');
|
||||||
|
$table->json('security_meta')->nullable()->after('security_scanned_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('photos', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['security_scan_status', 'security_scan_message', 'security_scanned_at', 'security_meta']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('refresh_token_audits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('refresh_token_id');
|
||||||
|
$table->string('tenant_id')->index();
|
||||||
|
$table->string('client_id')->nullable()->index();
|
||||||
|
$table->string('event', 64)->index();
|
||||||
|
$table->json('context')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->foreign('refresh_token_id')
|
||||||
|
->references('id')
|
||||||
|
->on('refresh_tokens')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('refresh_token_audits');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('refresh_tokens', 'last_used_at')) {
|
||||||
|
$table->timestamp('last_used_at')->nullable()->after('expires_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('refresh_tokens', 'revoked_reason')) {
|
||||||
|
$table->string('revoked_reason', 64)->nullable()->after('revoked_at');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('refresh_tokens', 'last_used_at')) {
|
||||||
|
$table->dropColumn('last_used_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('refresh_tokens', 'revoked_reason')) {
|
||||||
|
$table->dropColumn('revoked_reason');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('event_join_token_events', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('event_join_token_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->unsignedBigInteger('event_id')->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('tenant_id')->nullable()->index();
|
||||||
|
$table->string('token_hash', 64)->nullable()->index();
|
||||||
|
$table->string('token_preview', 32)->nullable();
|
||||||
|
$table->string('event_type', 32);
|
||||||
|
$table->string('route', 100)->nullable()->index();
|
||||||
|
$table->string('http_method', 16)->nullable();
|
||||||
|
$table->unsignedSmallInteger('http_status')->nullable();
|
||||||
|
$table->string('device_id', 64)->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->json('context')->nullable();
|
||||||
|
$table->timestamp('occurred_at')->useCurrent();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['event_join_token_id', 'occurred_at'], 'event_join_token_events_token_time_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('event_join_token_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ class OAuthClientSeeder extends Seeder
|
|||||||
$serviceConfig = config('services.oauth.tenant_admin', []);
|
$serviceConfig = config('services.oauth.tenant_admin', []);
|
||||||
|
|
||||||
$clientId = $serviceConfig['id'] ?? 'tenant-admin-app';
|
$clientId = $serviceConfig['id'] ?? 'tenant-admin-app';
|
||||||
$tenantId = Tenant::where('slug', 'demo')->value('id')
|
$tenantId = Tenant::where('slug', 'demo-tenant')->value('id')
|
||||||
?? Tenant::query()->orderBy('id')->value('id');
|
?? Tenant::query()->orderBy('id')->value('id');
|
||||||
|
|
||||||
$redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []);
|
$redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []);
|
||||||
|
|||||||
28
docs/deployment/join-token-analytics.md
Normal file
28
docs/deployment/join-token-analytics.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Join Token Analytics & Alerting (SEC-GT-02)
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
- Table `event_join_token_events` captures successes, failures, rate-limit hits, and uploads per join token.
|
||||||
|
- Each row records route, device id, IP, HTTP status, and context for post-incident drill downs.
|
||||||
|
- Logged automatically from `EventPublicController` for `/api/v1/events/*` and `/api/v1/gallery/*`.
|
||||||
|
|
||||||
|
- Super Admin: Event resource → “Join Link / QR” modal now summarises total successes/failures, rate-limit hits, 24h volume, and last activity timestamp per token.
|
||||||
|
- Tenant Admin: identical modal surface so operators can monitor invite health.
|
||||||
|
|
||||||
|
## Alert Thresholds (initial)
|
||||||
|
- **Rate limit spike**: >25 `token_rate_limited` entries for a token within 10 minutes → flag in monitoring (Grafana/Prometheus TODO).
|
||||||
|
- **Failure ratio**: failure_count / success_count > 0.5 over rolling hour triggers warning for support follow-up.
|
||||||
|
- **Inactivity**: tokens without access for >30 days should be reviewed; scheduled report TBD.
|
||||||
|
|
||||||
|
Rate-limiter knobs (see `.env.example`):
|
||||||
|
- `JOIN_TOKEN_FAILURE_LIMIT` / `JOIN_TOKEN_FAILURE_DECAY` — repeated invalid attempts before temporary block (default 10 tries per 5 min).
|
||||||
|
- `JOIN_TOKEN_ACCESS_LIMIT` / `JOIN_TOKEN_ACCESS_DECAY` — successful request ceiling per token/IP (default 120 req per minute).
|
||||||
|
- `JOIN_TOKEN_DOWNLOAD_LIMIT` / `JOIN_TOKEN_DOWNLOAD_DECAY` — download ceiling per token/IP (default 60 downloads per minute).
|
||||||
|
|
||||||
|
## Follow-up Tasks
|
||||||
|
1. Wire aggregated metrics into Grafana once metrics pipeline is ready (synthetic monitors pending SEC-GT-03).
|
||||||
|
2. Implement scheduled command to email tenants a weekly digest of token activity and stale tokens.
|
||||||
|
3. Consider anonymising device identifiers before long-term retention (privacy review).
|
||||||
|
|
||||||
|
## Runbook Notes
|
||||||
|
- Analytics table may grow quickly for high-traffic events; plan nightly prune job (keep 90 days).
|
||||||
|
- Use `php artisan tinker` to inspect token activity: `EventJoinTokenEvent::where('event_join_token_id', $id)->latest()->limit(20)->get()`.
|
||||||
58
docs/deployment/oauth-key-rotation.md
Normal file
58
docs/deployment/oauth-key-rotation.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# OAuth JWT Key Rotation Playbook (Dual-Key)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Ensure marketing/tenant OAuth tokens remain valid during RSA key rotations by keeping the previous signing key available until all legacy tokens expire.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Environment variable `OAUTH_KEY_STORE` points to a shared filesystem (default `storage/app/oauth-keys`).
|
||||||
|
- `OAUTH_JWT_KID` set to the current signing key id.
|
||||||
|
- Application deploy tooling able to propagate `.env` changes promptly.
|
||||||
|
- Operations access to run artisan commands in the target environment.
|
||||||
|
|
||||||
|
## Rotation Workflow
|
||||||
|
|
||||||
|
1. **Review existing keys**
|
||||||
|
```bash
|
||||||
|
php artisan oauth:list-keys
|
||||||
|
```
|
||||||
|
Confirm the `current` entry matches `OAUTH_JWT_KID`, note any legacy KIDs that should remain trusted until rotation completes.
|
||||||
|
|
||||||
|
2. **Generate new key pair**
|
||||||
|
```bash
|
||||||
|
php artisan oauth:rotate-keys --kid=fotospiel-jwt-$(date +%Y%m%d%H%M)
|
||||||
|
```
|
||||||
|
- The command now *copies* the existing key into the `/archive` folder but leaves it in-place for token verification.
|
||||||
|
- After the command, run `php artisan oauth:list-keys` again to verify both the old and new KIDs exist.
|
||||||
|
|
||||||
|
3. **Update environment configuration**
|
||||||
|
- Set `OAUTH_JWT_KID` to the newly generated value.
|
||||||
|
- Deploy the updated config (restart queue workers/web instances if they cache config).
|
||||||
|
|
||||||
|
4. **Smoke test issuance**
|
||||||
|
- Request a fresh OAuth token (PKCE flow) and inspect the JWT header — `kid` must match the new value.
|
||||||
|
- Use an existing token issued **before** the rotation to hit a tenant API route; it should continue to verify because the old key remains present.
|
||||||
|
|
||||||
|
5. **Monitor**
|
||||||
|
- Watch application logs for `Invalid token` / `JWT public key not found` errors over the next 24h.
|
||||||
|
- Investigate any anomalies before pruning.
|
||||||
|
|
||||||
|
## Pruning Legacy Keys
|
||||||
|
After the longest access-token + refresh-token lifetime (default: 30 days for refresh), prune the legacy signing directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan oauth:prune-keys --days=45 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `--dry-run` first to see which directories would be removed.
|
||||||
|
- The prune command never deletes the `current` KID.
|
||||||
|
- Archived copies remain under `storage/app/oauth-keys/archive/...` for forensics.
|
||||||
|
|
||||||
|
## Runbook Summary
|
||||||
|
| Step | Command | Outcome |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Inspect | `php artisan oauth:list-keys` | Inventory current + legacy keys |
|
||||||
|
| Rotate | `php artisan oauth:rotate-keys --kid=...` | Creates new key while keeping legacy key active |
|
||||||
|
| Verify | Issue new token + test old token | Ensures dual-key window works |
|
||||||
|
| Prune | `php artisan oauth:prune-keys --days=45` | Removes legacy key once safe |
|
||||||
|
|
||||||
|
Document completion of `SEC-IO-01` in `docs/todo/security-hardening-epic.md` when the rotation runbook has been rehearsed in staging.
|
||||||
106
docs/deployment/public-api-incident-playbook.md
Normal file
106
docs/deployment/public-api-incident-playbook.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Public API Incident Response Playbook (SEC-API-02)
|
||||||
|
|
||||||
|
Scope: Guest-facing API endpoints that rely on join tokens and power the guest PWA plus the public gallery. This includes:
|
||||||
|
|
||||||
|
- `/api/v1/events/{token}/*` (stats, tasks, uploads, photos)
|
||||||
|
- `/api/v1/gallery/{token}/*`
|
||||||
|
- Signed download/asset routes generated via `EventPublicController`
|
||||||
|
|
||||||
|
The playbook focuses on abuse, availability loss, and leaked content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Detection & Alerting
|
||||||
|
|
||||||
|
| Signal | Where to Watch | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 4xx/5xx spikes | Application logs (`storage/logs/laravel.log`), centralized logging | Look for repeated `Join token access denied` / `token_rate_limited` or unexpected 5xx. |
|
||||||
|
| Rate-limit triggers | Laravel log lines emitted from `EventPublicController::handleTokenFailure` | Contains IP + truncated token preview. |
|
||||||
|
| CDN/WAF alerts | Reverse proxy (if enabled) | Ensure 429/403 anomalies are forwarded to incident channel. |
|
||||||
|
| Synthetic monitors | Planned via `SEC-API-03` | Placeholder until monitors exist. |
|
||||||
|
|
||||||
|
Manual check commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan log:tail --lines=200 | grep "Join token"
|
||||||
|
php artisan log:tail --lines=200 | grep "gallery"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Severity Classification
|
||||||
|
|
||||||
|
| Level | Criteria | Examples |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SEV-1 | Wide outage (>50% error rate), confirmed data leak or malicious mass-download | Gallery downloads serving wrong event, join-token table compromised. |
|
||||||
|
| SEV-2 | Localised outage (single tenant/event) or targeted brute force attempting to enumerate tokens | Single event returning 500, repeated `invalid_token` from single IP range. |
|
||||||
|
| SEV-3 | Minor functional regression or cosmetic issue | Rate limit misconfiguration causing occasional 429 for legitimate users. |
|
||||||
|
|
||||||
|
Escalate SEV-1/2 immediately to on-call via Slack `#incident-response` and open PagerDuty incident (if configured).
|
||||||
|
|
||||||
|
## 3. Immediate Response Checklist
|
||||||
|
|
||||||
|
1. **Confirm availability**
|
||||||
|
- `curl -I https://app.test/api/v1/gallery/{known_good_token}`
|
||||||
|
- Use tenant-provided test token to validate `/events/{token}` flow.
|
||||||
|
2. **Snapshot logs**
|
||||||
|
- Export last 15 minutes from log aggregator or `storage/logs`. Attach to incident ticket.
|
||||||
|
3. **Assess scope**
|
||||||
|
- Identify affected tenant/event IDs via log context.
|
||||||
|
- Note IP addresses triggering rate limits.
|
||||||
|
4. **Decide mitigation**
|
||||||
|
- Brute force? → throttle/bock offending IPs.
|
||||||
|
- Compromised token? → revoke token via Filament or `php artisan tenant:join-tokens:revoke {id}` (once command exists).
|
||||||
|
- Endpoint regression? → begin rolling fix or feature flag toggle.
|
||||||
|
|
||||||
|
## 4. Mitigation Tactics
|
||||||
|
|
||||||
|
### 4.1 Abuse / Brute force
|
||||||
|
- Increase rate-limiter strictness temporarily by editing `config/limiting.php` (if available) or applying runtime block in the load balancer.
|
||||||
|
- Use fail2ban/WAF rules to block offending IPs. For quick local action:
|
||||||
|
```bash
|
||||||
|
sudo ufw deny from <ip_address>
|
||||||
|
```
|
||||||
|
- Consider temporarily disabling gallery download by setting `PUBLIC_GALLERY_ENABLED=false` (feature flag planned) and clearing cache.
|
||||||
|
|
||||||
|
### 4.2 Token Compromise
|
||||||
|
- Revoke specific token via Filament “Join Tokens” modal (Event → Join Tokens → revoke).
|
||||||
|
- Notify tenant with replacement token instructions.
|
||||||
|
- Audit join-token logs for additional suspicious use and consider rotating all tokens for the event.
|
||||||
|
|
||||||
|
### 4.3 Internal Failure (500s)
|
||||||
|
- Tail logs for stack traces.
|
||||||
|
- If due to downstream storage, fail closed: return 503 with maintenance banner while running `php artisan storage:diagnostics`.
|
||||||
|
- Roll back recent deployment or disable new feature flag if traced to release.
|
||||||
|
|
||||||
|
## 5. Communication
|
||||||
|
|
||||||
|
| Audience | Channel | Cadence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Internal on-call | Slack `#incident-response`, PagerDuty | Initial alert, hourly updates. |
|
||||||
|
| Customer Support | Slack `#support` with summary | Once per significant change (mitigation applied, issue resolved). |
|
||||||
|
| Tenants | Email template “Public gallery disruption” (see `resources/lang/*/emails.php`) | Only for SEV-1 or impactful SEV-2 after mitigation. |
|
||||||
|
|
||||||
|
Document timeline, impact, and mitigation in the incident ticket.
|
||||||
|
|
||||||
|
## 6. Verification & Recovery
|
||||||
|
|
||||||
|
After applying mitigation:
|
||||||
|
|
||||||
|
1. Re-run test requests for affected endpoints.
|
||||||
|
2. Validate join-token creation/revocation via Filament.
|
||||||
|
3. Confirm error rates return to baseline in monitoring/dashboard.
|
||||||
|
4. Remove temporary firewall blocks once threat subsides.
|
||||||
|
|
||||||
|
## 7. Post-Incident Actions
|
||||||
|
|
||||||
|
- File RCA within 48 hours including: root cause, detection gaps, follow-up tasks (e.g., enabling synthetic monitors, adding audit fields).
|
||||||
|
- Update documentation if new procedures are required (`docs/prp/11-public-gallery.md`, `docs/prp/03-api.md`).
|
||||||
|
- Schedule backlog items for long-term fixes (e.g., better anomaly alerting, token analytics dashboards).
|
||||||
|
|
||||||
|
## 8. References & Tools
|
||||||
|
|
||||||
|
- Log aggregation: `storage/logs/laravel.log` (local), Stackdriver/Splunk (staging/prod).
|
||||||
|
- Rate limit config: `App\Providers\AppServiceProvider` → `RateLimiter::for('tenant-api')` and `EventPublicController::handleTokenFailure`.
|
||||||
|
- Token management UI: Filament → Events → Join Tokens.
|
||||||
|
- Signed URL generation: `app/Http/Controllers/Api/EventPublicController` (for tracing download issues).
|
||||||
|
|
||||||
|
Keep this document alongside the other deployment runbooks and review quarterly.
|
||||||
@@ -1,6 +1,32 @@
|
|||||||
### Update 2025-10-21
|
### Update 2025-10-21
|
||||||
- Phase 3 credit scope delivered: tenant event creation now honours package allowances *and* credit balances (middleware + ledger logging), RevenueCat webhook signature checks ship with queue/backoff + env config, idempotency covered via unit tests.
|
- Phase 3 credit scope delivered: tenant event creation now honours package allowances *and* credit balances (middleware + ledger logging), RevenueCat webhook signature checks ship with queue/backoff + env config, idempotency covered via unit tests.
|
||||||
- Follow-up (separate): evaluate photo upload quota enforcement + SuperAdmin ledger visualisations once package analytics stabilise.
|
- Follow-up (separate): evaluate photo upload quota enforcement + SuperAdmin ledger visualisations once package analytics stabilise.
|
||||||
|
|
||||||
|
### Upcoming (Next Weeks — Security Hardening)
|
||||||
|
- Week 1
|
||||||
|
- `SEC-IO-01` dual-key rollout playbook.
|
||||||
|
- `SEC-GT-01` hashed join tokens migration.
|
||||||
|
- `SEC-API-01` signed asset URLs.
|
||||||
|
- `SEC-MS-01` AV/EXIF worker integration.
|
||||||
|
- `SEC-BILL-01` checkout session linkage.
|
||||||
|
- `SEC-FE-01` CSP nonce utility.
|
||||||
|
- Week 2
|
||||||
|
- `SEC-IO-02` refresh-token management UI. *(delivered 2025-10-23)*
|
||||||
|
- `SEC-GT-02` token analytics dashboards.
|
||||||
|
- `SEC-API-02` incident response playbook.
|
||||||
|
- `SEC-MS-02` streaming upload refactor.
|
||||||
|
- `SEC-BILL-02` webhook signature freshness.
|
||||||
|
- `SEC-FE-02` consent-gated analytics loader.
|
||||||
|
- Week 3
|
||||||
|
- `SEC-IO-03` subnet/device configuration.
|
||||||
|
- `SEC-GT-03` gallery rate-limit alerts.
|
||||||
|
- `SEC-API-03` synthetic monitoring.
|
||||||
|
- `SEC-MS-03` checksum validation alerts.
|
||||||
|
- `SEC-BILL-03` failed capture notifications.
|
||||||
|
- `SEC-FE-03` cookie banner localisation refresh.
|
||||||
|
- Week 4
|
||||||
|
- `SEC-MS-04` storage health dashboard widget (Media Services).
|
||||||
|
|
||||||
# Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt)
|
# Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt)
|
||||||
|
|
||||||
## Implementierungsstand (Aktualisiert: 2025-09-15)
|
## Implementierungsstand (Aktualisiert: 2025-09-15)
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
## 2025 Hardening Priorities
|
## 2025 Hardening Priorities
|
||||||
|
|
||||||
- **Identity & OAuth** — *Owner: Backend Platform*
|
- **Identity & OAuth** — *Owner: Backend Platform*
|
||||||
Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions.
|
Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions. See `docs/deployment/oauth-key-rotation.md` for the rotation playbook. Filament now offers a refresh-token console with per-device revocation and audit history.
|
||||||
- **Guest Join Tokens** — *Owner: Guest Platform*
|
- **Guest Join Tokens** — *Owner: Guest Platform*
|
||||||
Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards.
|
Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards. Join-token access is now logged to `event_join_token_events` with summaries surfaced in the Event admin modal.
|
||||||
- **Public API Resilience** — *Owner: Core API*
|
- **Public API Resilience** — *Owner: Core API*
|
||||||
Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides.
|
Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides. See `docs/deployment/public-api-incident-playbook.md` for the response checklist.
|
||||||
- **Media Pipeline & Storage** — *Owner: Media Services*
|
- **Media Pipeline & Storage** — *Owner: Media Services*
|
||||||
Introduce antivirus + EXIF scrubbing workers, stream uploads to disk to avoid buffering, and enforce checksum verification during hot→archive transfers with configurable alerts from `StorageHealthService`.
|
Introduce antivirus + EXIF scrubbing workers, stream uploads to disk to avoid buffering, and enforce checksum verification during hot→archive transfers with configurable alerts from `StorageHealthService`.
|
||||||
|
- Queue `media-security` (job: `ProcessPhotoSecurityScan`) performs antivirus + EXIF sanitisation per upload; configure via `config/security.php`.
|
||||||
- **Payments & Webhooks** — *Owner: Billing*
|
- **Payments & Webhooks** — *Owner: Billing*
|
||||||
Align legacy Stripe hooks with checkout sessions, add idempotency locks/signature expiry checks, and plug failed capture notifications into the credit ledger audit trail.
|
Align legacy Stripe hooks with checkout sessions, add idempotency locks/signature expiry checks, and plug failed capture notifications into the credit ledger audit trail.
|
||||||
- **Frontend & CSP** — *Owner: Marketing Frontend*
|
- **Frontend & CSP** — *Owner: Marketing Frontend*
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ services:
|
|||||||
QUEUE_SLEEP: 5
|
QUEUE_SLEEP: 5
|
||||||
command: >
|
command: >
|
||||||
/var/www/html/docs/queue-supervisor/queue-worker.sh media-storage
|
/var/www/html/docs/queue-supervisor/queue-worker.sh media-storage
|
||||||
|
|
||||||
|
media-security-worker:
|
||||||
|
image: fotospiel-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV:-production}
|
||||||
|
QUEUE_CONNECTION: redis
|
||||||
|
QUEUE_TRIES: 3
|
||||||
|
QUEUE_SLEEP: 5
|
||||||
|
command: >
|
||||||
|
/var/www/html/docs/queue-supervisor/queue-worker.sh media-security
|
||||||
```
|
```
|
||||||
|
|
||||||
Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2).
|
Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2).
|
||||||
@@ -74,6 +87,7 @@ Expose Horizon via your web proxy and protect it with authentication (the app al
|
|||||||
- `QUEUE_CONNECTION` — should match the driver configured in `.env` (`redis` recommended).
|
- `QUEUE_CONNECTION` — should match the driver configured in `.env` (`redis` recommended).
|
||||||
- `QUEUE_TRIES`, `QUEUE_SLEEP`, `QUEUE_TIMEOUT`, `QUEUE_MAX_TIME` — optional tuning knobs consumed by `queue-worker.sh`.
|
- `QUEUE_TRIES`, `QUEUE_SLEEP`, `QUEUE_TIMEOUT`, `QUEUE_MAX_TIME` — optional tuning knobs consumed by `queue-worker.sh`.
|
||||||
- `STORAGE_ALERT_EMAIL` — enables upload failure notifications introduced in the new storage pipeline.
|
- `STORAGE_ALERT_EMAIL` — enables upload failure notifications introduced in the new storage pipeline.
|
||||||
|
- `SECURITY_SCAN_QUEUE` — overrides the queue name for the photo antivirus/EXIF worker (`media-security` by default).
|
||||||
- Redis / database credentials must be available in the worker containers exactly like the web container.
|
- Redis / database credentials must be available in the worker containers exactly like the web container.
|
||||||
|
|
||||||
### 5. Bootstrapping reminder
|
### 5. Bootstrapping reminder
|
||||||
|
|||||||
131
docs/todo/media-streaming-upload-refactor.md
Normal file
131
docs/todo/media-streaming-upload-refactor.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# SEC-MS-02 — Streaming Upload Refactor (Requirements Draft)
|
||||||
|
|
||||||
|
**Goal**
|
||||||
|
Replace the current “single POST with multipart FormData” guest upload with a streaming / chunked pipeline that:
|
||||||
|
|
||||||
|
- avoids buffering entire files in PHP memory
|
||||||
|
- supports larger assets (target 25 MB originals)
|
||||||
|
- keeps antivirus/EXIF scrubbing and storage accounting intact
|
||||||
|
- exposes clear retry semantics to the guest PWA
|
||||||
|
|
||||||
|
This document captures the scope for SEC-MS-02 and feeds into implementation tickets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current State (Baseline)
|
||||||
|
|
||||||
|
- Upload endpoint: `POST /api/v1/events/{token}/upload` handled by `EventPublicController::upload`.
|
||||||
|
- Laravel validation enforces `image|max:6144` (≈6 MB). Entire file is received via `Request::file('photo')`.
|
||||||
|
- Storage flow: `Storage::disk($hotDisk)->putFile(...)` followed by synchronous thumbnail creation and `event_media_assets` bookkeeping.
|
||||||
|
- Device rate limiting: simple counter (`guest_name` = device id) per event.
|
||||||
|
- Security: join token validation + IP rate limiting; antivirus/exif cleanup handled asynchronously by `ProcessPhotoSecurityScan` (queued).
|
||||||
|
- Frontend: guest PWA uses `fetch` + FormData; progress handled by custom XHR queue for UI feedback.
|
||||||
|
|
||||||
|
Pain points:
|
||||||
|
- Upload size ceiling due to PHP post_max_size + memory usage.
|
||||||
|
- Slow devices stall the controller request; no streaming/chunk resume.
|
||||||
|
- Throttling/locks only consider completed uploads; partial data still consumes bandwidth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target Architecture Overview
|
||||||
|
|
||||||
|
### 2.1 Session-Based Chunk Upload
|
||||||
|
|
||||||
|
1. **Create session**
|
||||||
|
- `POST /api/v1/events/{token}/uploads` → returns `upload_id`, `upload_key`, storage target, chunk size.
|
||||||
|
- Validate join token + device limits *before* accepting session. Record session in new table `event_upload_sessions`.
|
||||||
|
|
||||||
|
2. **Upload chunks**
|
||||||
|
- `PUT /api/v1/events/{token}/uploads/{upload_id}/chunk` with headers: `Content-Range`, `Content-Length`, `Upload-Key`.
|
||||||
|
- Chunks written to hot storage *stream* destination (e.g. `storage/app/uploads/{upload_id}/chunk_{index}`) via `StreamedResponse`/`fopen`.
|
||||||
|
- Track received ranges in session record; enforce sequential or limited parallel chunks.
|
||||||
|
|
||||||
|
3. **Complete upload**
|
||||||
|
- `POST /api/v1/events/{token}/uploads/{upload_id}/complete`
|
||||||
|
- Assemble chunks → single file (use stream copy to final path), compute checksum, dispatch queue jobs (AV/EXIF, thumbnail).
|
||||||
|
- Persist `photos` row + `event_media_assets` references (mirroring current logic).
|
||||||
|
|
||||||
|
4. **Abort**
|
||||||
|
- `DELETE /api/v1/events/{token}/uploads/{upload_id}` to clean up partial data.
|
||||||
|
|
||||||
|
### 2.2 Storage Strategy
|
||||||
|
|
||||||
|
- Use `EventStorageManager` hot disk but with temporary “staging” directory.
|
||||||
|
- After successful assembly, move to final `events/{eventId}/photos/{uuid}.ext`.
|
||||||
|
- For S3 targets, evaluate direct multipart upload to S3 using pre-signed URLs:
|
||||||
|
- Option A (short-term): stream into local disk, then background job pushes to S3.
|
||||||
|
- Option B (stretch): delegate chunk upload directly to S3 using `createMultipartUpload`, storing uploadId + partETags.
|
||||||
|
- Ensure staging cleanup job removes abandoned sessions (cron every hour).
|
||||||
|
|
||||||
|
### 2.3 Metadata & Limits
|
||||||
|
|
||||||
|
- New table `event_upload_sessions` fields:
|
||||||
|
`id (uuid)`, `event_id`, `join_token_id`, `device_id`, `status (pending|uploading|assembling|failed|completed)`, `total_size`, `received_bytes`, `chunk_size`, `expires_at`, `failure_reason`, timestamps.
|
||||||
|
- Device/upload limits: enforce daily cap per device via session creation; consider max concurrent sessions per device/token (default 2).
|
||||||
|
- Maximum file size: 25 MB (configurable via `config/media.php`). Validate at `complete` by comparing expected vs actual bytes.
|
||||||
|
|
||||||
|
### 2.4 Validation & Security
|
||||||
|
|
||||||
|
- Require `Upload-Key` secret per session (stored hashed) to prevent hijacking.
|
||||||
|
- Join token + device validations reused; log chunk IP + UA for anomaly detection.
|
||||||
|
- Abort sessions on repeated integrity failures or mismatched `Content-Range`.
|
||||||
|
- Update rate limiter to consider `PUT` chunk endpoints separately.
|
||||||
|
|
||||||
|
### 2.5 API Responses & Errors
|
||||||
|
|
||||||
|
- Provide consistent JSON:
|
||||||
|
- `201` create: `{ upload_id, chunk_size, expires_at }`
|
||||||
|
- chunk success: `204`
|
||||||
|
- complete: `201 { photo_id, file_path, thumbnail_path }`
|
||||||
|
- error codes: `upload_limit`, `chunk_out_of_order`, `range_mismatch`, `session_expired`.
|
||||||
|
- Document in `docs/prp/03-api.md` + update guest SDK.
|
||||||
|
|
||||||
|
### 2.6 Backend Jobs
|
||||||
|
|
||||||
|
- Assembly job (if asynchronous) ensures chunk merge is offloaded for large files; update `ProcessPhotoSecurityScan` to depend on final asset record.
|
||||||
|
- Add metric counters (Prometheus/Laravel events) for chunk throughput, failed sessions, average complete time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend Changes (Guest PWA)
|
||||||
|
|
||||||
|
- Replace current FormData POST with streaming uploader:
|
||||||
|
- Request session, slice file into `chunk_size` (default 1 MB) using `Blob.slice`, upload sequentially with retry/backoff.
|
||||||
|
- Show granular progress (bytes uploaded / total).
|
||||||
|
- Support resume: store `upload_id` & received ranges in IndexedDB; on reconnect query session status from new endpoint `GET /api/v1/events/{token}/uploads/{upload_id}`.
|
||||||
|
- Ensure compatibility fallback: if browser lacks required APIs (e.g. old Safari), fallback to legacy single POST (size-limited) with warning.
|
||||||
|
- Update service worker/queue to pause/resume chunk uploads when offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Integration & Migration Tasks
|
||||||
|
|
||||||
|
1. **Schema**: create `event_upload_sessions` table + indices; optional `event_upload_chunks` if tracking per-part metadata.
|
||||||
|
2. **Config**: new entries in `config/media.php` for chunk size, staging path, session TTL, max size.
|
||||||
|
3. **Env**: add `.env` knobs (e.g. `MEDIA_UPLOAD_CHUNK_SIZE=1048576`, `MEDIA_UPLOAD_MAX_SIZE=26214400`).
|
||||||
|
4. **Cleanup Command**: `php artisan media:prune-upload-sessions` to purge expired sessions & staging files. Hook into cron `/cron/media-prune-sessions.sh`.
|
||||||
|
5. **Docs**: update PRP (sections 03, 10) and guest PWA README; add troubleshooting guide for chunk upload errors.
|
||||||
|
6. **Testing**:
|
||||||
|
- Unit: session creation, chunk validation, assembly with mocked storage.
|
||||||
|
- Feature: end-to-end upload success + failure (PHPUnit).
|
||||||
|
- Playwright: simulate chunked upload with network throttling.
|
||||||
|
- Load: ensure concurrent uploads do not exhaust disk IO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open Questions
|
||||||
|
|
||||||
|
- **S3 Multipart vs. Local Assembly**: confirm timeline for direct-to-S3; MVP may prefer local assembly to limit complexity.
|
||||||
|
- **Encryption**: decide whether staging chunks require at-rest encryption (likely yes if hot disk is shared).
|
||||||
|
- **Quota Enforcement**: should device/event caps be session-based (limit sessions) or final photo count (existing)? Combine both?
|
||||||
|
- **Backward Compatibility**: decide when to retire legacy endpoint; temporarily keep `/upload` fallback behind feature flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Next Steps
|
||||||
|
|
||||||
|
- Finalise design choices (S3 vs local) with Media Services.
|
||||||
|
- Break down into implementation tasks (backend API, frontend uploader, cron cleanup, observability).
|
||||||
|
- Schedule dry run in staging with large sample files (20+ MB) and monitor memory/CPU.
|
||||||
|
- Update SEC-MS-02 ticket checklist with deliverables above.
|
||||||
@@ -9,31 +9,56 @@ Raise the baseline security posture across guest APIs, checkout, media storage,
|
|||||||
- Dual-key rollout for JWT signing with rotation runbook and monitoring.
|
- Dual-key rollout for JWT signing with rotation runbook and monitoring.
|
||||||
- Refresh-token revocation tooling (per device/IP) and anomaly alerts.
|
- Refresh-token revocation tooling (per device/IP) and anomaly alerts.
|
||||||
- Device fingerprint/subnet allowances documented and configurable.
|
- Device fingerprint/subnet allowances documented and configurable.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-IO-01` — Generate dual-key rollout playbook + automation (Week 1). *(Runbook: `docs/deployment/oauth-key-rotation.md`; commands: `oauth:list-keys`, `oauth:prune-keys`)*
|
||||||
|
- `SEC-IO-02` — Build refresh-token management UI + audit logs (Week 2). *(Filament console + audit trail added 2025-10-23)*
|
||||||
|
- `SEC-IO-03` — Implement subnet/device matching configuration & tests (Week 3).
|
||||||
|
|
||||||
2. **Guest Join Tokens (Guest Platform)**
|
2. **Guest Join Tokens (Guest Platform)**
|
||||||
- Store hashed tokens with irreversible lookups; migrate legacy data.
|
- Store hashed tokens with irreversible lookups; migrate legacy data.
|
||||||
- Add per-token usage analytics, alerting on spikes or expiry churn.
|
- Add per-token usage analytics, alerting on spikes or expiry churn.
|
||||||
- Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards.
|
- Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-GT-01` — Hash join tokens + data migration script (Week 1).
|
||||||
|
- `SEC-GT-02` — Implement token analytics + Grafana dashboard (Week 2). *(Logging + Filament summaries delivered 2025-10-23; monitoring dashboard still pending)*
|
||||||
|
- `SEC-GT-03` — Tighten gallery/photo rate limits + alerting (Week 3).
|
||||||
|
|
||||||
3. **Public API Resilience (Core API)**
|
3. **Public API Resilience (Core API)**
|
||||||
- Serve signed asset URLs instead of raw storage paths; expire appropriately.
|
- Serve signed asset URLs instead of raw storage paths; expire appropriately.
|
||||||
- Document incident response runbooks and playbooks for abuse mitigation.
|
- Document incident response runbooks and playbooks for abuse mitigation.
|
||||||
- Add synthetic monitors for `/api/v1/gallery/*` and upload endpoints.
|
- Add synthetic monitors for `/api/v1/gallery/*` and upload endpoints.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-API-01` — Signed URL middleware + asset migration (Week 1).
|
||||||
|
- `SEC-API-02` — Incident response playbook draft + review (Week 2). *(Runbook: `docs/deployment/public-api-incident-playbook.md`, added 2025-10-23)*
|
||||||
|
- `SEC-API-03` — Synthetic monitoring + alert config (Week 3).
|
||||||
|
|
||||||
4. **Media Pipeline & Storage (Media Services)**
|
4. **Media Pipeline & Storage (Media Services)**
|
||||||
- Integrate antivirus/EXIF scrubbers and streaming upload paths to avoid buffering.
|
- Integrate antivirus/EXIF scrubbers and streaming upload paths to avoid buffering.
|
||||||
- Verify checksum integrity on hot → archive transfers with alert thresholds.
|
- Verify checksum integrity on hot → archive transfers with alert thresholds.
|
||||||
- Surface storage target health (capacity, latency) in Super Admin dashboards.
|
- Surface storage target health (capacity, latency) in Super Admin dashboards.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-MS-01` — AV + EXIF scrubber worker integration (Week 1). *(Job: `ProcessPhotoSecurityScan`, queue: `media-security`)*
|
||||||
|
- `SEC-MS-02` — Streaming upload refactor + tests (Week 2). *(Requirements draft: `docs/todo/media-streaming-upload-refactor.md`, 2025-10-23)*
|
||||||
|
- `SEC-MS-03` — Checksum validation + alert thresholds (Week 3).
|
||||||
|
- `SEC-MS-04` — Storage health widget in Super Admin (Week 4).
|
||||||
|
|
||||||
5. **Payments & Webhooks (Billing)**
|
5. **Payments & Webhooks (Billing)**
|
||||||
- Link Stripe/PayPal webhooks to checkout sessions with idempotency locks.
|
- Link Stripe/PayPal webhooks to checkout sessions with idempotency locks.
|
||||||
- Add signature freshness validation + retry policies for provider outages.
|
- Add signature freshness validation + retry policies for provider outages.
|
||||||
- Pipe failed capture events into credit ledger audits and operator alerts.
|
- Pipe failed capture events into credit ledger audits and operator alerts.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-BILL-01` — Checkout session linkage + idempotency locks (Week 1).
|
||||||
|
- `SEC-BILL-02` — Signature freshness + retry policy implementation (Week 2).
|
||||||
|
- `SEC-BILL-03` — Failed capture notifications + ledger hook (Week 3).
|
||||||
|
|
||||||
6. **Frontend & CSP (Marketing Frontend)**
|
6. **Frontend & CSP (Marketing Frontend)**
|
||||||
- Replace `unsafe-inline` allowances with nonce/hash policies for Stripe + Matomo.
|
- Replace `unsafe-inline` allowances with nonce/hash policies for Stripe + Matomo.
|
||||||
- Gate analytics script injection behind consent with localised disclosures.
|
- Gate analytics script injection behind consent with localised disclosures.
|
||||||
- Broaden cookie banner layout to surface GDPR/legal copy clearly.
|
- Broaden cookie banner layout to surface GDPR/legal copy clearly.
|
||||||
|
- **Tickets**
|
||||||
|
- `SEC-FE-01` — CSP nonce/hashing utility + rollout (Week 1).
|
||||||
|
- `SEC-FE-02` — Consent-gated analytics loader refactor (Week 2).
|
||||||
|
- `SEC-FE-03` — Cookie banner UX update + localisation (Week 3).
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
- Updated docs (`docs/prp/09-security-compliance.md`, runbooks) with ownership & SLAs.
|
- Updated docs (`docs/prp/09-security-compliance.md`, runbooks) with ownership & SLAs.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"occasions": {
|
"occasions": {
|
||||||
"wedding": "Hochzeit",
|
"wedding": "Hochzeit",
|
||||||
"birthday": "Geburtstag",
|
"birthday": "Geburtstag",
|
||||||
"corporate": "Firmenevent"
|
"corporate": "Firmenevent",
|
||||||
|
"label": "Anlässe"
|
||||||
},
|
},
|
||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
|
|||||||
@@ -194,7 +194,8 @@
|
|||||||
"message": "Nachricht",
|
"message": "Nachricht",
|
||||||
"sending": "Wird gesendet...",
|
"sending": "Wird gesendet...",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"back_home": "Zurück zur Startseite"
|
"back_home": "Zurück zur Startseite",
|
||||||
|
"success": "Danke! Wir melden uns schnellstmöglich."
|
||||||
},
|
},
|
||||||
"occasions": {
|
"occasions": {
|
||||||
"title": "Fotospiel für :type",
|
"title": "Fotospiel für :type",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"occasions": {
|
"occasions": {
|
||||||
"wedding": "Wedding",
|
"wedding": "Wedding",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
"corporate": "Corporate Event"
|
"corporate": "Corporate Event",
|
||||||
|
"label": "Occasions"
|
||||||
},
|
},
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
|
|||||||
@@ -180,7 +180,8 @@
|
|||||||
"message": "Message",
|
"message": "Message",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"back_home": "Back to Home"
|
"back_home": "Back to Home",
|
||||||
|
"success": "Thanks! We will get back to you soon."
|
||||||
},
|
},
|
||||||
"occasions": {
|
"occasions": {
|
||||||
"title": "Fotospiel for :type",
|
"title": "Fotospiel for :type",
|
||||||
|
|||||||
@@ -119,6 +119,30 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: oklch(1 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
background-color: oklch(0.145 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-aurora {
|
||||||
|
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: aurora 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Basic typography styling for rendered markdown (prose) without Tailwind plugin */
|
/* Basic typography styling for rendered markdown (prose) without Tailwind plugin */
|
||||||
.prose {
|
.prose {
|
||||||
color: rgb(55 65 81);
|
color: rgb(55 65 81);
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ export type Package = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
||||||
const response = await authorizedFetch(`/api/v1/packages?type=${type}`);
|
const response = await authorizedFetch(`/api/v1/tenant/packages?type=${type}`);
|
||||||
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
|
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
|
||||||
return data.data ?? [];
|
return data.data ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
clearOAuthSession,
|
clearOAuthSession,
|
||||||
clearTokens,
|
clearTokens,
|
||||||
completeOAuthCallback,
|
completeOAuthCallback,
|
||||||
isAuthError,
|
|
||||||
loadTokens,
|
loadTokens,
|
||||||
registerAuthFailureHandler,
|
registerAuthFailureHandler,
|
||||||
startOAuthFlow,
|
startOAuthFlow,
|
||||||
} from './tokens';
|
} from './tokens';
|
||||||
|
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||||
|
|
||||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
|
||||||
@@ -58,18 +58,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
setUser(profile);
|
setUser(profile);
|
||||||
setStatus('authenticated');
|
setStatus('authenticated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAuthError(error)) {
|
|
||||||
handleAuthFailure();
|
|
||||||
} else {
|
|
||||||
console.error('[Auth] Failed to refresh profile', error);
|
console.error('[Auth] Failed to refresh profile', error);
|
||||||
}
|
handleAuthFailure();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [handleAuthFailure]);
|
}, [handleAuthFailure]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) {
|
||||||
|
clearTokens();
|
||||||
|
clearOAuthSession();
|
||||||
|
setUser(null);
|
||||||
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = loadTokens();
|
const tokens = loadTokens();
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
|
setUser(null);
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,7 +83,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
refreshProfile().catch(() => {
|
refreshProfile().catch(() => {
|
||||||
// refreshProfile already handled failures.
|
// refreshProfile already handled failures.
|
||||||
});
|
});
|
||||||
}, [refreshProfile]);
|
}, [handleAuthFailure, refreshProfile]);
|
||||||
|
|
||||||
const login = React.useCallback((redirectPath?: string) => {
|
const login = React.useCallback((redirectPath?: string) => {
|
||||||
const target = redirectPath ?? window.location.pathname + window.location.search;
|
const target = redirectPath ?? window.location.pathname + window.location.search;
|
||||||
|
|||||||
@@ -166,8 +166,15 @@ export async function startOAuthFlow(redirectPath?: string): Promise<void> {
|
|||||||
|
|
||||||
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||||
sessionStorage.setItem(STATE_KEY, state);
|
sessionStorage.setItem(STATE_KEY, state);
|
||||||
|
localStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||||
|
localStorage.setItem(STATE_KEY, state);
|
||||||
if (redirectPath) {
|
if (redirectPath) {
|
||||||
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||||
|
localStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.debug('[Auth] PKCE store', { state, verifier, redirectPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -190,16 +197,23 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
|
|||||||
|
|
||||||
const code = params.get('code');
|
const code = params.get('code');
|
||||||
const returnedState = params.get('state');
|
const returnedState = params.get('state');
|
||||||
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
|
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY);
|
||||||
const expectedState = sessionStorage.getItem(STATE_KEY);
|
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
||||||
|
clearOAuthSession();
|
||||||
notifyAuthFailure();
|
notifyAuthFailure();
|
||||||
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
sessionStorage.removeItem(STATE_KEY);
|
sessionStorage.removeItem(STATE_KEY);
|
||||||
|
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
|
localStorage.removeItem(STATE_KEY);
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
@@ -216,6 +230,7 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
clearOAuthSession();
|
||||||
console.error('[Auth] Authorization code exchange failed', response.status);
|
console.error('[Auth] Authorization code exchange failed', response.status);
|
||||||
notifyAuthFailure();
|
notifyAuthFailure();
|
||||||
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
||||||
@@ -227,6 +242,9 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
|
|||||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||||
if (redirectTarget) {
|
if (redirectTarget) {
|
||||||
sessionStorage.removeItem(REDIRECT_KEY);
|
sessionStorage.removeItem(REDIRECT_KEY);
|
||||||
|
localStorage.removeItem(REDIRECT_KEY);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(REDIRECT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirectTarget;
|
return redirectTarget;
|
||||||
@@ -236,4 +254,7 @@ export function clearOAuthSession(): void {
|
|||||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
sessionStorage.removeItem(STATE_KEY);
|
sessionStorage.removeItem(STATE_KEY);
|
||||||
sessionStorage.removeItem(REDIRECT_KEY);
|
sessionStorage.removeItem(REDIRECT_KEY);
|
||||||
|
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
|
localStorage.removeItem(STATE_KEY);
|
||||||
|
localStorage.removeItem(REDIRECT_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
|
|
||||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
||||||
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
|
|
||||||
|
|
||||||
type StripeCheckoutProps = {
|
type StripeCheckoutProps = {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
@@ -268,6 +267,10 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
const { t, i18n } = useTranslation("onboarding");
|
const { t, i18n } = useTranslation("onboarding");
|
||||||
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
||||||
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
||||||
|
const stripePromise = React.useMemo(
|
||||||
|
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||||
|
[stripePublishableKey]
|
||||||
|
);
|
||||||
|
|
||||||
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
||||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||||
@@ -335,7 +338,7 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [requiresPayment, packageDetails, t]);
|
}, [requiresPayment, packageDetails, stripePromise, t]);
|
||||||
|
|
||||||
const priceText =
|
const priceText =
|
||||||
progress.selectedPackage?.priceText ??
|
progress.selectedPackage?.priceText ??
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ export default function AuthCallbackPage() {
|
|||||||
const { completeLogin } = useAuth();
|
const { completeLogin } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const hasHandledRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (hasHandledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasHandledRef.current = true;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
completeLogin(params)
|
completeLogin(params)
|
||||||
.then((redirectTo) => {
|
.then((redirectTo) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react';
|
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -220,22 +220,30 @@ export default function EventFormPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="package_id">Package</Label>
|
<Label htmlFor="package_id">Package</Label>
|
||||||
<Select value={form.package_id.toString()} onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value) }))}>
|
<Select
|
||||||
|
value={form.package_id.toString()}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
|
||||||
|
disabled={packagesLoading || !packages?.length}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Waehlen Sie ein Package" />
|
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
{packages?.length ? (
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{packagesLoading ? (
|
{packages.map((pkg) => (
|
||||||
<SelectItem value="">Laden...</SelectItem>
|
|
||||||
) : (
|
|
||||||
packages?.map((pkg) => (
|
|
||||||
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
||||||
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
|
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
) : null}
|
||||||
</Select>
|
</Select>
|
||||||
|
{packagesLoading ? (
|
||||||
|
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
|
||||||
|
) : null}
|
||||||
|
{!packagesLoading && (!packages || packages.length === 0) ? (
|
||||||
|
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
|
||||||
|
) : null}
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">Package-Details</Button>
|
<Button variant="outline" size="sm">Package-Details</Button>
|
||||||
|
|||||||
19
resources/js/admin/pages/LogoutPage.tsx
Normal file
19
resources/js/admin/pages/LogoutPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../auth/context';
|
||||||
|
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600">
|
||||||
|
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
|
||||||
|
Abmeldung wird vorbereitet ...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
|||||||
import EmotionsPage from './pages/EmotionsPage';
|
import EmotionsPage from './pages/EmotionsPage';
|
||||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||||
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
|
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
|
||||||
|
import LogoutPage from './pages/LogoutPage';
|
||||||
import { useAuth } from './auth/context';
|
import { useAuth } from './auth/context';
|
||||||
import {
|
import {
|
||||||
ADMIN_BASE_PATH,
|
ADMIN_BASE_PATH,
|
||||||
@@ -71,6 +72,7 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <LandingGate /> },
|
{ index: true, element: <LandingGate /> },
|
||||||
{ path: 'login', element: <LoginPage /> },
|
{ path: 'login', element: <LoginPage /> },
|
||||||
|
{ path: 'logout', element: <LogoutPage /> },
|
||||||
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
@@ -92,7 +94,6 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||||
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
||||||
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
|
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
|
||||||
{ path: '', element: <Navigate to="dashboard" replace /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -102,4 +103,3 @@ export const router = createBrowserRouter([
|
|||||||
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
|
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,10 @@ import AppLayout from './layouts/app/AppLayout';
|
|||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { Elements } from '@stripe/react-stripe-js';
|
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
|
||||||
import { ConsentProvider } from './contexts/consent';
|
import { ConsentProvider } from './contexts/consent';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
// Initialize Stripe
|
|
||||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
|
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => title ? `${title} - ${appName}` : appName,
|
title: (title) => title ? `${title} - ${appName}` : appName,
|
||||||
resolve: (name) => resolvePageComponent(
|
resolve: (name) => resolvePageComponent(
|
||||||
@@ -42,14 +37,12 @@ createInertiaApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<Elements stripe={stripePromise}>
|
|
||||||
<ConsentProvider>
|
<ConsentProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
</Elements>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface MatomoTrackerProps {
|
|||||||
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
|
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const { hasConsent } = useConsent();
|
const { hasConsent } = useConsent();
|
||||||
|
const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce;
|
||||||
const analyticsConsent = hasConsent('analytics');
|
const analyticsConsent = hasConsent('analytics');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,6 +56,19 @@ const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
|
|||||||
script.async = true;
|
script.async = true;
|
||||||
script.src = `${base}/matomo.js`;
|
script.src = `${base}/matomo.js`;
|
||||||
script.dataset.matomo = base;
|
script.dataset.matomo = base;
|
||||||
|
if (scriptNonce) {
|
||||||
|
script.setAttribute('nonce', scriptNonce);
|
||||||
|
} else if (typeof window !== 'undefined' && (window as any).__CSP_NONCE) {
|
||||||
|
script.setAttribute('nonce', (window as any).__CSP_NONCE);
|
||||||
|
} else {
|
||||||
|
const metaNonce = document
|
||||||
|
.querySelector('meta[name="csp-nonce"]')
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
if (metaNonce) {
|
||||||
|
script.setAttribute('nonce', metaNonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
||||||
},
|
},
|
||||||
|
access_rate_limited: {
|
||||||
|
title: 'Zu viele Aufrufe',
|
||||||
|
description: 'Es gab sehr viele Aufrufe in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||||
|
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
|
||||||
|
},
|
||||||
|
gallery_expired: {
|
||||||
|
title: 'Galerie nicht mehr verfuegbar',
|
||||||
|
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
|
||||||
|
ctaLabel: 'Neuen Code anfordern',
|
||||||
|
},
|
||||||
event_not_public: {
|
event_not_public: {
|
||||||
title: 'Event nicht oeffentlich',
|
title: 'Event nicht oeffentlich',
|
||||||
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
||||||
@@ -404,6 +414,16 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
description: 'There were too many attempts in a short time. Wait a bit and try again.',
|
description: 'There were too many attempts in a short time. Wait a bit and try again.',
|
||||||
hint: 'Tip: You can retry in a few minutes.',
|
hint: 'Tip: You can retry in a few minutes.',
|
||||||
},
|
},
|
||||||
|
access_rate_limited: {
|
||||||
|
title: 'Too many requests',
|
||||||
|
description: 'There were too many requests in a short time. Please wait a moment and try again.',
|
||||||
|
hint: 'Tip: You can retry shortly.',
|
||||||
|
},
|
||||||
|
gallery_expired: {
|
||||||
|
title: 'Gallery unavailable',
|
||||||
|
description: 'The gallery for this event is no longer accessible.',
|
||||||
|
ctaLabel: 'Request new code',
|
||||||
|
},
|
||||||
event_not_public: {
|
event_not_public: {
|
||||||
title: 'Event not public',
|
title: 'Event not public',
|
||||||
description: 'This event is not publicly accessible right now.',
|
description: 'This event is not publicly accessible right now.',
|
||||||
|
|||||||
@@ -197,8 +197,12 @@ function getErrorContent(
|
|||||||
return build('token_expired', { ctaHref: '/event' });
|
return build('token_expired', { ctaHref: '/event' });
|
||||||
case 'token_rate_limited':
|
case 'token_rate_limited':
|
||||||
return build('token_rate_limited');
|
return build('token_rate_limited');
|
||||||
|
case 'access_rate_limited':
|
||||||
|
return build('access_rate_limited');
|
||||||
case 'event_not_public':
|
case 'event_not_public':
|
||||||
return build('event_not_public');
|
return build('event_not_public');
|
||||||
|
case 'gallery_expired':
|
||||||
|
return build('gallery_expired', { ctaHref: '/event' });
|
||||||
case 'network_error':
|
case 'network_error':
|
||||||
return build('network_error');
|
return build('network_error');
|
||||||
case 'server_error':
|
case 'server_error':
|
||||||
@@ -219,4 +223,3 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const API_ERROR_CODES: FetchEventErrorCode[] = [
|
|||||||
'token_expired',
|
'token_expired',
|
||||||
'token_revoked',
|
'token_revoked',
|
||||||
'token_rate_limited',
|
'token_rate_limited',
|
||||||
|
'access_rate_limited',
|
||||||
|
'gallery_expired',
|
||||||
'event_not_public',
|
'event_not_public',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,9 +80,9 @@ function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 429) return 'token_rate_limited';
|
if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited';
|
||||||
if (status === 404) return 'event_not_public';
|
if (status === 404) return 'event_not_public';
|
||||||
if (status === 410) return 'token_expired';
|
if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired';
|
||||||
if (status === 401) return 'invalid_token';
|
if (status === 401) return 'invalid_token';
|
||||||
if (status === 403) return 'token_revoked';
|
if (status === 403) return 'token_revoked';
|
||||||
if (status >= 500) return 'server_error';
|
if (status >= 500) return 'server_error';
|
||||||
@@ -98,6 +100,10 @@ function defaultMessageForCode(code: FetchEventErrorCode): string {
|
|||||||
return 'Dieser Zugriffscode ist abgelaufen.';
|
return 'Dieser Zugriffscode ist abgelaufen.';
|
||||||
case 'token_rate_limited':
|
case 'token_rate_limited':
|
||||||
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||||
|
case 'access_rate_limited':
|
||||||
|
return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
|
||||||
|
case 'gallery_expired':
|
||||||
|
return 'Die Galerie ist nicht mehr verfügbar.';
|
||||||
case 'event_not_public':
|
case 'event_not_public':
|
||||||
return 'Dieses Event ist nicht öffentlich verfügbar.';
|
return 'Dieses Event ist nicht öffentlich verfügbar.';
|
||||||
case 'network_error':
|
case 'network_error':
|
||||||
|
|||||||
@@ -243,7 +243,10 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
||||||
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
||||||
|
|
||||||
const stripePromise = useMemo(() => loadStripe(stripePublishableKey), [stripePublishableKey]);
|
const stripePromise = useMemo(
|
||||||
|
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||||
|
[stripePublishableKey]
|
||||||
|
);
|
||||||
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
||||||
const isReseller = selectedPackage?.type === 'reseller';
|
const isReseller = selectedPackage?.type === 'reseller';
|
||||||
|
|
||||||
@@ -299,6 +302,12 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stripePromise) {
|
||||||
|
setStatus('error');
|
||||||
|
setStatusDetail(t('checkout.payment_step.stripe_not_loaded'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!authUser) {
|
if (!authUser) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setStatusDetail(t('checkout.payment_step.auth_required'));
|
setStatusDetail(t('checkout.payment_step.auth_required'));
|
||||||
@@ -351,7 +360,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, t]);
|
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]);
|
||||||
|
|
||||||
const providerLabel = useCallback((provider: Provider) => {
|
const providerLabel = useCallback((provider: Provider) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
@@ -457,7 +466,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
|
|
||||||
{renderStatusAlert()}
|
{renderStatusAlert()}
|
||||||
|
|
||||||
{paymentMethod === 'stripe' && clientSecret && (
|
{paymentMethod === 'stripe' && clientSecret && stripePromise && (
|
||||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||||
<StripePaymentForm
|
<StripePaymentForm
|
||||||
selectedPackage={selectedPackage}
|
selectedPackage={selectedPackage}
|
||||||
|
|||||||
6
resources/js/types/index.d.ts
vendored
6
resources/js/types/index.d.ts
vendored
@@ -28,6 +28,12 @@ export interface SharedData {
|
|||||||
auth: Auth;
|
auth: Auth;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
supportedLocales?: string[];
|
supportedLocales?: string[];
|
||||||
|
security?: {
|
||||||
|
csp?: {
|
||||||
|
scriptNonce?: string;
|
||||||
|
styleNonce?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ return [
|
|||||||
'layouts_fallback' => 'Layout-Übersicht öffnen',
|
'layouts_fallback' => 'Layout-Übersicht öffnen',
|
||||||
'token_expiry' => 'Läuft ab am :date',
|
'token_expiry' => 'Läuft ab am :date',
|
||||||
],
|
],
|
||||||
|
'analytics' => [
|
||||||
|
'success_total' => 'Erfolgreiche Zugriffe',
|
||||||
|
'failure_total' => 'Fehlgeschlagene Zugriffe',
|
||||||
|
'rate_limited_total' => 'Rate-Limit erreicht',
|
||||||
|
'recent_24h' => 'Aufrufe (24h)',
|
||||||
|
'last_seen_at' => 'Letzte Aktivität: :date',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'legal_pages' => [
|
'legal_pages' => [
|
||||||
@@ -329,6 +336,77 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'refresh_tokens' => [
|
||||||
|
'menu' => 'Refresh Tokens',
|
||||||
|
'single' => 'Refresh Token',
|
||||||
|
'fields' => [
|
||||||
|
'tenant' => 'Mandant',
|
||||||
|
'client' => 'Client',
|
||||||
|
'status' => 'Status',
|
||||||
|
'revoked_reason' => 'Widerrufsgrund',
|
||||||
|
'created_at' => 'Erstellt',
|
||||||
|
'last_used_at' => 'Zuletzt verwendet',
|
||||||
|
'expires_at' => 'Gültig bis',
|
||||||
|
'ip_address' => 'IP-Adresse',
|
||||||
|
'user_agent' => 'User Agent',
|
||||||
|
'note' => 'Notiz',
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'active' => 'Aktiv',
|
||||||
|
'revoked' => 'Widerrufen',
|
||||||
|
'expired' => 'Abgelaufen',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'status' => 'Status',
|
||||||
|
'tenant' => 'Mandant',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'revoke' => 'Token widerrufen',
|
||||||
|
],
|
||||||
|
'reasons' => [
|
||||||
|
'manual' => 'Manuell',
|
||||||
|
'operator' => 'Operator-Aktion',
|
||||||
|
'rotated' => 'Automatisch rotiert',
|
||||||
|
'ip_mismatch' => 'IP-Abweichung',
|
||||||
|
'expired' => 'Abgelaufen',
|
||||||
|
'invalid_secret' => 'Ungültiges Secret',
|
||||||
|
'tenant_missing' => 'Mandant entfernt',
|
||||||
|
'max_active_limit' => 'Maximale Anzahl überschritten',
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
'details' => 'Token-Details',
|
||||||
|
'security' => 'Sicherheitskontext',
|
||||||
|
],
|
||||||
|
'audit' => [
|
||||||
|
'heading' => 'Audit-Log',
|
||||||
|
'event' => 'Ereignis',
|
||||||
|
'events' => [
|
||||||
|
'issued' => 'Ausgestellt',
|
||||||
|
'refresh_attempt' => 'Refresh versucht',
|
||||||
|
'refreshed' => 'Refresh erfolgreich',
|
||||||
|
'client_mismatch' => 'Client stimmt nicht überein',
|
||||||
|
'invalid_secret' => 'Ungültiges Secret',
|
||||||
|
'ip_mismatch' => 'IP-Abweichung',
|
||||||
|
'expired' => 'Abgelaufen',
|
||||||
|
'revoked' => 'Widerrufen',
|
||||||
|
'rotated' => 'Rotiert',
|
||||||
|
'tenant_missing' => 'Mandant fehlt',
|
||||||
|
'max_active_limit' => 'Begrenzung erreicht',
|
||||||
|
],
|
||||||
|
'performed_by' => 'Ausgeführt von',
|
||||||
|
'ip_address' => 'IP-Adresse',
|
||||||
|
'context' => 'Kontext',
|
||||||
|
'performed_at' => 'Zeitpunkt',
|
||||||
|
'empty' => [
|
||||||
|
'heading' => 'Noch keine Einträge',
|
||||||
|
'description' => 'Sobald das Token verwendet wird, erscheinen hier Einträge.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'revoked' => 'Refresh Token wurde widerrufen.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'shell' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant‑Admin',
|
'tenant_admin_title' => 'Tenant‑Admin',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,4 +49,11 @@ return [
|
|||||||
'subject' => 'Neue Kontakt-Anfrage',
|
'subject' => 'Neue Kontakt-Anfrage',
|
||||||
'body' => 'Kontakt-Anfrage von :name (:email): :message',
|
'body' => 'Kontakt-Anfrage von :name (:email): :message',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'contact_confirmation' => [
|
||||||
|
'subject' => 'Vielen Dank für Ihre Nachricht, :name!',
|
||||||
|
'greeting' => 'Hallo :name,',
|
||||||
|
'body' => 'Vielen Dank für Ihre Nachricht an das Fotospiel-Team. Wir melden uns so schnell wie möglich zurück.',
|
||||||
|
'footer' => 'Viele Grüße<br>Ihr Fotospiel-Team',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -160,4 +160,7 @@ return [
|
|||||||
'currency' => [
|
'currency' => [
|
||||||
'euro' => '€',
|
'euro' => '€',
|
||||||
],
|
],
|
||||||
|
'contact' => [
|
||||||
|
'success' => 'Danke! Wir melden uns schnellstmöglich.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ return [
|
|||||||
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
|
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
|
||||||
'open_admin' => 'Open admin app',
|
'open_admin' => 'Open admin app',
|
||||||
],
|
],
|
||||||
|
'analytics' => [
|
||||||
|
'success_total' => 'Successful checks',
|
||||||
|
'failure_total' => 'Failures',
|
||||||
|
'rate_limited_total' => 'Rate limited',
|
||||||
|
'recent_24h' => 'Requests (24h)',
|
||||||
|
'last_seen_at' => 'Last activity: :date',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'legal_pages' => [
|
'legal_pages' => [
|
||||||
@@ -315,6 +322,77 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'refresh_tokens' => [
|
||||||
|
'menu' => 'Refresh tokens',
|
||||||
|
'single' => 'Refresh token',
|
||||||
|
'fields' => [
|
||||||
|
'tenant' => 'Tenant',
|
||||||
|
'client' => 'Client',
|
||||||
|
'status' => 'Status',
|
||||||
|
'revoked_reason' => 'Revoked reason',
|
||||||
|
'created_at' => 'Created',
|
||||||
|
'last_used_at' => 'Last used',
|
||||||
|
'expires_at' => 'Expires at',
|
||||||
|
'ip_address' => 'IP address',
|
||||||
|
'user_agent' => 'User agent',
|
||||||
|
'note' => 'Operator note',
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'active' => 'Active',
|
||||||
|
'revoked' => 'Revoked',
|
||||||
|
'expired' => 'Expired',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'status' => 'Status',
|
||||||
|
'tenant' => 'Tenant',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'revoke' => 'Revoke token',
|
||||||
|
],
|
||||||
|
'reasons' => [
|
||||||
|
'manual' => 'Manual',
|
||||||
|
'operator' => 'Operator action',
|
||||||
|
'rotated' => 'Rotated (auto)',
|
||||||
|
'ip_mismatch' => 'IP mismatch',
|
||||||
|
'expired' => 'Expired',
|
||||||
|
'invalid_secret' => 'Invalid secret attempt',
|
||||||
|
'tenant_missing' => 'Tenant removed',
|
||||||
|
'max_active_limit' => 'Exceeded active token limit',
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
'details' => 'Token details',
|
||||||
|
'security' => 'Security context',
|
||||||
|
],
|
||||||
|
'audit' => [
|
||||||
|
'heading' => 'Audit log',
|
||||||
|
'event' => 'Event',
|
||||||
|
'events' => [
|
||||||
|
'issued' => 'Issued',
|
||||||
|
'refresh_attempt' => 'Refresh attempted',
|
||||||
|
'refreshed' => 'Refresh succeeded',
|
||||||
|
'client_mismatch' => 'Client mismatch',
|
||||||
|
'invalid_secret' => 'Invalid secret',
|
||||||
|
'ip_mismatch' => 'IP mismatch',
|
||||||
|
'expired' => 'Expired',
|
||||||
|
'revoked' => 'Revoked',
|
||||||
|
'rotated' => 'Rotated',
|
||||||
|
'tenant_missing' => 'Tenant missing',
|
||||||
|
'max_active_limit' => 'Pruned (active limit)',
|
||||||
|
],
|
||||||
|
'performed_by' => 'Actor',
|
||||||
|
'ip_address' => 'IP address',
|
||||||
|
'context' => 'Context',
|
||||||
|
'performed_at' => 'Timestamp',
|
||||||
|
'empty' => [
|
||||||
|
'heading' => 'No audit entries yet',
|
||||||
|
'description' => 'Token activity will appear here once it is used.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'revoked' => 'Refresh token revoked.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'shell' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant Admin',
|
'tenant_admin_title' => 'Tenant Admin',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,4 +49,11 @@ return [
|
|||||||
'subject' => 'New Contact Request',
|
'subject' => 'New Contact Request',
|
||||||
'body' => 'Contact request from :name (:email): :message',
|
'body' => 'Contact request from :name (:email): :message',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'contact_confirmation' => [
|
||||||
|
'subject' => 'Thank you for reaching out, :name!',
|
||||||
|
'greeting' => 'Hi :name,',
|
||||||
|
'body' => 'Thank you for your message to the Fotospiel team. We will get back to you as soon as possible.',
|
||||||
|
'footer' => 'Best regards,<br>The Fotospiel Team',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -160,4 +160,7 @@ return [
|
|||||||
'currency' => [
|
'currency' => [
|
||||||
'euro' => '€',
|
'euro' => '€',
|
||||||
],
|
],
|
||||||
|
'contact' => [
|
||||||
|
'success' => 'Thanks! We will get back to you soon.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite('resources/js/admin/main.tsx')
|
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
@php
|
||||||
|
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
|
||||||
|
@endphp
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
@if($scriptNonce)
|
||||||
|
<meta name="csp-nonce" content="{{ $scriptNonce }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
<script>
|
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
|
||||||
(function() {
|
(function() {
|
||||||
const appearance = '{{ $appearance ?? "system" }}';
|
const appearance = '{{ $appearance ?? "system" }}';
|
||||||
|
window.__CSP_NONCE = '{{ $scriptNonce }}';
|
||||||
|
|
||||||
if (appearance === 'system') {
|
if (appearance === 'system') {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@@ -20,17 +28,6 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
|
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
background-color: oklch(1 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
background-color: oklch(0.145 0 0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||||
|
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||||
@inertiaHead
|
@inertiaHead
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $package->name]) }}</title>
|
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $packageName]) }}</title>
|
||||||
<style>
|
<style>
|
||||||
.cta-button {
|
.cta-button {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<h1>{{ __('emails.abandoned_checkout.greeting', ['name' => $user->fullName]) }}</h1>
|
<h1>{{ __('emails.abandoned_checkout.greeting', ['name' => $user->fullName]) }}</h1>
|
||||||
|
|
||||||
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $package->name]) }}</p>
|
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $packageName]) }}</p>
|
||||||
|
|
||||||
<a href="{{ $resumeUrl }}" class="cta-button">
|
<a href="{{ $resumeUrl }}" class="cta-button">
|
||||||
{{ __('emails.abandoned_checkout.cta_button') }}
|
{{ __('emails.abandoned_checkout.cta_button') }}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
@component('mail::message')
|
@component('mail::message')
|
||||||
# Hallo {{ $name }},
|
# {{ __('emails.contact_confirmation.greeting', ['name' => $name]) }}
|
||||||
|
|
||||||
vielen Dank fuer Ihre Nachricht an das Fotospiel Team. Wir melden uns so schnell wie moeglich bei Ihnen.
|
{{ __('emails.contact_confirmation.body') }}
|
||||||
|
|
||||||
Falls Sie weitere Informationen hinzufuegen moechten, antworten Sie einfach auf diese E-Mail.
|
{{ __('emails.contact_confirmation.footer') }}
|
||||||
|
|
||||||
Viele Gruesse
|
|
||||||
Ihr Fotospiel Team
|
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{{ __('emails.purchase.subject', ['package' => $package->name]) }}</title>
|
<title>{{ __('emails.purchase.subject', ['package' => $packageName]) }}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
|
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
|
||||||
<p>{{ __('emails.purchase.package', ['package' => $package->name]) }}</p>
|
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p>
|
||||||
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
|
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
|
||||||
<p>{{ __('emails.purchase.activation') }}</p>
|
<p>{{ __('emails.purchase.activation') }}</p>
|
||||||
<p>{!! __('emails.purchase.footer') !!}</p>
|
<p>{!! __('emails.purchase.footer') !!}</p>
|
||||||
|
|||||||
@@ -68,6 +68,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$analytics = $token['analytics'] ?? [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if (!empty($analytics))
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.success_total') }}</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ number_format($analytics['success_total'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.failure_total') }}</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ number_format($analytics['failure_total'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.rate_limited_total') }}</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ number_format($analytics['rate_limited_total'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.recent_24h') }}</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold">
|
||||||
|
{{ number_format($analytics['recent_24h'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
@if (!empty($analytics['last_seen_at']))
|
||||||
|
<div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (!empty($token['layouts']))
|
@if (!empty($token['layouts']))
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
@php
|
||||||
|
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
|
||||||
|
@endphp
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -7,7 +11,13 @@
|
|||||||
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
|
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
|
||||||
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
|
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
@if($scriptNonce)
|
||||||
|
<meta name="csp-nonce" content="{{ $scriptNonce }}">
|
||||||
|
@endif
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
|
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
|
||||||
|
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
|
||||||
|
window.__CSP_NONCE = '{{ $scriptNonce }}';
|
||||||
|
</script>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$currentLocale = app()->getLocale();
|
$currentLocale = app()->getLocale();
|
||||||
@@ -20,17 +30,6 @@
|
|||||||
<link rel="alternate" hreflang="{{ $locale }}" href="{{ url("/$locale$path") }}">
|
<link rel="alternate" hreflang="{{ $locale }}" href="{{ url("/$locale$path") }}">
|
||||||
@endforeach
|
@endforeach
|
||||||
<link rel="alternate" hreflang="x-default" href="{{ url('/de' . $path) }}">
|
<link rel="alternate" hreflang="x-default" href="{{ url('/de' . $path) }}">
|
||||||
<style>
|
|
||||||
@keyframes aurora {
|
|
||||||
0%, 100% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
}
|
|
||||||
.bg-aurora {
|
|
||||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: aurora 15s ease infinite;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 text-gray-900">
|
<body class="bg-gray-50 text-gray-900">
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const tabLinks = document.querySelectorAll('.tab-link');
|
const tabLinks = document.querySelectorAll('.tab-link');
|
||||||
tabLinks.forEach(link => {
|
tabLinks.forEach(link => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
@auth
|
@auth
|
||||||
@if(auth()->user()->email_verified_at)
|
@if(auth()->user()->email_verified_at)
|
||||||
<script>
|
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
|
||||||
window.location.href = '/event-admin';
|
window.location.href = '/event-admin';
|
||||||
</script>
|
</script>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos');
|
Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos');
|
||||||
Route::get('/gallery/{token}/photos/{photo}/download', [EventPublicController::class, 'galleryPhotoDownload'])
|
Route::get('/gallery/{token}/photos/{photo}/download', [EventPublicController::class, 'galleryPhotoDownload'])
|
||||||
->whereNumber('photo')
|
->whereNumber('photo')
|
||||||
|
->middleware('signed')
|
||||||
->name('gallery.photos.download');
|
->name('gallery.photos.download');
|
||||||
|
Route::get('/gallery/{token}/photos/{photo}/{variant}', [EventPublicController::class, 'galleryPhotoAsset'])
|
||||||
|
->whereNumber('photo')
|
||||||
|
->where('variant', 'thumbnail|full')
|
||||||
|
->middleware('signed')
|
||||||
|
->name('gallery.photos.asset');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog
|
|||||||
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
|
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
|
||||||
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
|
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
|
||||||
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
||||||
|
Route::view('/event-admin/auth/callback', 'admin')->name('tenant.admin.auth.callback');
|
||||||
|
Route::view('/event-admin/login', 'admin')->name('tenant.admin.login');
|
||||||
|
Route::view('/event-admin/logout', 'admin')->name('tenant.admin.logout');
|
||||||
Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
|
Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
|
||||||
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
||||||
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
||||||
|
Route::view('/e/{token}/{path?}', 'guest')
|
||||||
|
->where('token', '.*')
|
||||||
|
->where('path', '.*')
|
||||||
|
->name('guest.event');
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy');
|
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export default defineConfig({
|
|||||||
// Falls ihr auf gemounteten FS seid und Events fehlen:
|
// Falls ihr auf gemounteten FS seid und Events fehlen:
|
||||||
// usePolling: true, interval: 500,
|
// usePolling: true, interval: 500,
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
'/fonts': {
|
||||||
|
target: appUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
|
|||||||
Reference in New Issue
Block a user