From fb23a0a2f3de5ec4d9d6e181d11db3c8fdb76d1a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Jan 2026 16:56:51 +0100 Subject: [PATCH] Add photobooth connect codes and uploader scaffold --- .beads/issues.jsonl | 1 + .beads/last-touched | 2 +- .../Api/PhotoboothConnectController.php | 45 +++++ .../PhotoboothConnectCodeController.php | 47 +++++ .../PhotoboothConnectRedeemRequest.php | 37 ++++ .../PhotoboothConnectCodeStoreRequest.php | 28 +++ app/Models/PhotoboothConnectCode.php | 25 +++ app/Providers/AppServiceProvider.php | 4 + .../PhotoboothConnectCodeService.php | 80 ++++++++ .../PhotoboothUploader.sln | 18 ++ .../PhotoboothUploader/App.xaml | 7 + .../PhotoboothUploader/App.xaml.cs | 17 ++ .../PhotoboothUploader/MainWindow.xaml | 28 +++ .../PhotoboothUploader/MainWindow.xaml.cs | 177 ++++++++++++++++++ .../Models/PhotoboothConnectResponse.cs | 30 +++ .../Models/PhotoboothSettings.cs | 11 ++ .../PhotoboothUploader.csproj | 18 ++ .../Services/PhotoboothConnectClient.cs | 46 +++++ .../Services/SettingsStore.cs | 43 +++++ .../Services/UploadService.cs | 143 ++++++++++++++ config/photobooth.php | 4 + .../PhotoboothConnectCodeFactory.php | 29 +++ ..._create_photobooth_connect_codes_table.php | 31 +++ .../seeders/PhotoboothConnectCodeSeeder.php | 16 ++ firefox_i0ktsA4zsn.png | Bin 121312 -> 0 bytes routes/api.php | 7 + .../Photobooth/PhotoboothConnectCodeTest.php | 100 ++++++++++ 27 files changed, 993 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/PhotoboothConnectController.php create mode 100644 app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php create mode 100644 app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php create mode 100644 app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php create mode 100644 app/Models/PhotoboothConnectCode.php create mode 100644 app/Services/Photobooth/PhotoboothConnectCodeService.php create mode 100644 clients/photobooth-uploader/PhotoboothUploader.sln create mode 100644 clients/photobooth-uploader/PhotoboothUploader/App.xaml create mode 100644 clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml create mode 100644 clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj create mode 100644 clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs create mode 100644 clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs create mode 100644 database/factories/PhotoboothConnectCodeFactory.php create mode 100644 database/migrations/2026_01_12_164023_create_photobooth_connect_codes_table.php create mode 100644 database/seeders/PhotoboothConnectCodeSeeder.php delete mode 100644 firefox_i0ktsA4zsn.png create mode 100644 tests/Feature/Photobooth/PhotoboothConnectCodeTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index da9bdbc..3055b4e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,6 +9,7 @@ {"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"} {"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"} +{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T16:51:27.198056063+01:00"} {"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"} {"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"} {"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"} diff --git a/.beads/last-touched b/.beads/last-touched index 7c0dc08..4d1f588 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-9em +fotospiel-app-29r diff --git a/app/Http/Controllers/Api/PhotoboothConnectController.php b/app/Http/Controllers/Api/PhotoboothConnectController.php new file mode 100644 index 0000000..aef8377 --- /dev/null +++ b/app/Http/Controllers/Api/PhotoboothConnectController.php @@ -0,0 +1,45 @@ +service->redeem($request->input('code')); + + if (! $record) { + return response()->json([ + 'message' => __('Ungültiger oder abgelaufener Verbindungscode.'), + ], 422); + } + + $record->loadMissing('event.photoboothSetting'); + $event = $record->event; + $setting = $event?->photoboothSetting; + + if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') { + return response()->json([ + 'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'), + ], 409); + } + + return response()->json([ + 'data' => [ + 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'), + 'username' => $setting->username, + 'password' => $setting->password, + 'expires_at' => optional($setting->expires_at)->toIso8601String(), + 'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format'] + ?? config('photobooth.sparkbooth.response_format', 'json'), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php b/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php new file mode 100644 index 0000000..f473b21 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php @@ -0,0 +1,47 @@ +assertEventBelongsToTenant($request, $event); + + $event->loadMissing('photoboothSetting'); + $setting = $event->photoboothSetting; + + if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') { + return response()->json([ + 'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'), + ], 409); + } + + $expiresInMinutes = $request->input('expires_in_minutes'); + $result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null); + + return response()->json([ + 'data' => [ + 'code' => $result['code'], + 'expires_at' => $result['expires_at']->toIso8601String(), + ], + ]); + } + + protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void + { + $tenantId = (int) $request->attributes->get('tenant_id'); + + if ($tenantId !== (int) $event->tenant_id) { + abort(403, 'Event gehört nicht zu diesem Tenant.'); + } + } +} diff --git a/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php b/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php new file mode 100644 index 0000000..d99296d --- /dev/null +++ b/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'], + ]; + } + + protected function prepareForValidation(): void + { + $code = preg_replace('/\D+/', '', (string) $this->input('code')); + + $this->merge([ + 'code' => $code, + ]); + } +} diff --git a/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php b/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php new file mode 100644 index 0000000..6651e4c --- /dev/null +++ b/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'], + ]; + } +} diff --git a/app/Models/PhotoboothConnectCode.php b/app/Models/PhotoboothConnectCode.php new file mode 100644 index 0000000..b6ef33c --- /dev/null +++ b/app/Models/PhotoboothConnectCode.php @@ -0,0 +1,25 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'expires_at' => 'datetime', + 'redeemed_at' => 'datetime', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 64ede0a..a235fea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown')); }); + RateLimiter::for('photobooth-connect', function (Request $request) { + return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown')); + }); + RateLimiter::for('tenant-auth', function (Request $request) { return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown')); }); diff --git a/app/Services/Photobooth/PhotoboothConnectCodeService.php b/app/Services/Photobooth/PhotoboothConnectCodeService.php new file mode 100644 index 0000000..55ad823 --- /dev/null +++ b/app/Services/Photobooth/PhotoboothConnectCodeService.php @@ -0,0 +1,80 @@ +where('code_hash', $candidateHash) + ->whereNull('redeemed_at') + ->where('expires_at', '>=', now()) + ->exists(); + + if (! $exists) { + $code = $candidate; + $hash = $candidateHash; + break; + } + } + + if (! $code || ! $hash) { + $code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + $hash = hash('sha256', $code); + } + + $expiresAt = now()->addMinutes($expiresInMinutes); + + $record = PhotoboothConnectCode::query()->create([ + 'event_id' => $event->getKey(), + 'code_hash' => $hash, + 'expires_at' => $expiresAt, + ]); + + return [ + 'code' => $code, + 'record' => $record, + 'expires_at' => $expiresAt, + ]; + } + + public function redeem(string $code): ?PhotoboothConnectCode + { + $hash = hash('sha256', $code); + + /** @var PhotoboothConnectCode|null $record */ + $record = PhotoboothConnectCode::query() + ->where('code_hash', $hash) + ->whereNull('redeemed_at') + ->where('expires_at', '>=', now()) + ->first(); + + if (! $record) { + return null; + } + + $record->forceFill([ + 'redeemed_at' => now(), + ])->save(); + + return $record; + } +} diff --git a/clients/photobooth-uploader/PhotoboothUploader.sln b/clients/photobooth-uploader/PhotoboothUploader.sln new file mode 100644 index 0000000..fa1a0eb --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35013.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoboothUploader", "PhotoboothUploader\PhotoboothUploader.csproj", "{CDF88A75-8B20-4F54-96FC-A640B0D19A10}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/clients/photobooth-uploader/PhotoboothUploader/App.xaml b/clients/photobooth-uploader/PhotoboothUploader/App.xaml new file mode 100644 index 0000000..8c2cbd7 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/App.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs b/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs new file mode 100644 index 0000000..aa92a62 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; + +namespace PhotoboothUploader; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var window = new MainWindow(); + window.Activate(); + } +} diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml new file mode 100644 index 0000000..046f0b2 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + +