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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml.cs b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml.cs
new file mode 100644
index 0000000..b9d92b1
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml.cs
@@ -0,0 +1,177 @@
+using Microsoft.UI.Xaml;
+using PhotoboothUploader.Models;
+using PhotoboothUploader.Services;
+using System.Linq;
+using System.IO;
+using Windows.Storage;
+using Windows.Storage.Pickers;
+using WinRT.Interop;
+
+namespace PhotoboothUploader;
+
+public sealed partial class MainWindow : Window
+{
+ private const string DefaultBaseUrl = "https://fotospiel.app";
+ private PhotoboothConnectClient _client;
+ private readonly SettingsStore _settingsStore = new();
+ private readonly UploadService _uploadService = new();
+ private PhotoboothSettings _settings;
+ private FileSystemWatcher? _watcher;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _settings = _settingsStore.Load();
+ _settings.BaseUrl ??= DefaultBaseUrl;
+ _client = new PhotoboothConnectClient(_settings.BaseUrl);
+ _settingsStore.Save(_settings);
+ ApplySettings();
+ }
+
+ private async void ConnectButton_Click(object sender, RoutedEventArgs e)
+ {
+ var code = (CodeBox.Text ?? string.Empty).Trim();
+
+ if (code.Length != 6 || code.Any(ch => ch is < '0' or > '9'))
+ {
+ StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
+ return;
+ }
+
+ ConnectButton.IsEnabled = false;
+ StatusText.Text = "Verbinde...";
+
+ var response = await _client.RedeemAsync(code);
+
+ if (response.Data is null)
+ {
+ StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
+ ConnectButton.IsEnabled = true;
+ return;
+ }
+
+ _settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
+ _settings.Username = response.Data.Username;
+ _settings.Password = response.Data.Password;
+ _settings.ResponseFormat = response.Data.ResponseFormat;
+ _settingsStore.Save(_settings);
+
+ StatusText.Text = "Verbunden. Upload bereit.";
+ PickFolderButton.IsEnabled = true;
+ StartUploadPipelineIfReady();
+ ConnectButton.IsEnabled = true;
+ }
+
+ private async void PickFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ var picker = new FolderPicker
+ {
+ SuggestedStartLocation = PickerLocationId.PicturesLibrary,
+ };
+
+ picker.FileTypeFilter.Add("*");
+ InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
+
+ StorageFolder? folder = await picker.PickSingleFolderAsync();
+
+ if (folder is null)
+ {
+ return;
+ }
+
+ _settings.WatchFolder = folder.Path;
+ _settingsStore.Save(_settings);
+
+ FolderText.Text = folder.Path;
+ StartUploadPipelineIfReady();
+ }
+
+ private void ApplySettings()
+ {
+ if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
+ {
+ FolderText.Text = _settings.WatchFolder;
+ }
+
+ if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
+ {
+ StatusText.Text = "Verbunden. Upload bereit.";
+ PickFolderButton.IsEnabled = true;
+ StartUploadPipelineIfReady();
+ }
+ }
+
+ private void StartUploadPipelineIfReady()
+ {
+ if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
+ {
+ return;
+ }
+
+ _uploadService.Start(_settings, UpdateStatus);
+ StartWatcher(_settings.WatchFolder);
+ }
+
+ private void StartWatcher(string folder)
+ {
+ _watcher?.Dispose();
+
+ _watcher = new FileSystemWatcher(folder)
+ {
+ IncludeSubdirectories = false,
+ EnableRaisingEvents = true,
+ };
+
+ _watcher.Created += OnFileChanged;
+ _watcher.Changed += OnFileChanged;
+ _watcher.Renamed += OnFileRenamed;
+ }
+
+ private void OnFileChanged(object sender, FileSystemEventArgs e)
+ {
+ if (!IsSupportedImage(e.FullPath))
+ {
+ return;
+ }
+
+ _uploadService.Enqueue(e.FullPath);
+ }
+
+ private void OnFileRenamed(object sender, RenamedEventArgs e)
+ {
+ if (!IsSupportedImage(e.FullPath))
+ {
+ return;
+ }
+
+ _uploadService.Enqueue(e.FullPath);
+ }
+
+ private bool IsSupportedImage(string path)
+ {
+ var extension = Path.GetExtension(path)?.ToLowerInvariant();
+
+ return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
+ }
+
+ private void UpdateStatus(string message)
+ {
+ DispatcherQueue.TryEnqueue(() => StatusText.Text = message);
+ }
+
+ private string? ResolveUploadUrl(string? uploadUrl)
+ {
+ if (string.IsNullOrWhiteSpace(uploadUrl))
+ {
+ return uploadUrl;
+ }
+
+ if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
+ {
+ return uploadUrl;
+ }
+
+ var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
+ return new Uri(baseUri, uploadUrl).ToString();
+ }
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs
new file mode 100644
index 0000000..9b4f2d2
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothConnectResponse.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace PhotoboothUploader.Models;
+
+public sealed class PhotoboothConnectResponse
+{
+ [JsonPropertyName("data")]
+ public PhotoboothConnectPayload? Data { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+}
+
+public sealed class PhotoboothConnectPayload
+{
+ [JsonPropertyName("upload_url")]
+ public string? UploadUrl { get; set; }
+
+ [JsonPropertyName("username")]
+ public string? Username { get; set; }
+
+ [JsonPropertyName("password")]
+ public string? Password { get; set; }
+
+ [JsonPropertyName("expires_at")]
+ public string? ExpiresAt { get; set; }
+
+ [JsonPropertyName("response_format")]
+ public string? ResponseFormat { get; set; }
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
new file mode 100644
index 0000000..11f72ae
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Models/PhotoboothSettings.cs
@@ -0,0 +1,11 @@
+namespace PhotoboothUploader.Models;
+
+public sealed class PhotoboothSettings
+{
+ public string? BaseUrl { get; set; }
+ public string? UploadUrl { get; set; }
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+ public string? ResponseFormat { get; set; }
+ public string? WatchFolder { get; set; }
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj b/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj
new file mode 100644
index 0000000..d4d7417
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/PhotoboothUploader.csproj
@@ -0,0 +1,18 @@
+
+
+ WinExe
+ net8.0-windows10.0.19041.0
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs
new file mode 100644
index 0000000..187c412
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/PhotoboothConnectClient.cs
@@ -0,0 +1,46 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using PhotoboothUploader.Models;
+
+namespace PhotoboothUploader.Services;
+
+public sealed class PhotoboothConnectClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public PhotoboothConnectClient(string baseUrl)
+ {
+ _httpClient = new HttpClient
+ {
+ BaseAddress = new Uri(baseUrl),
+ };
+ }
+
+ public async Task RedeemAsync(string code, CancellationToken cancellationToken = default)
+ {
+ var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
+ var payload = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken);
+
+ if (payload is null)
+ {
+ return new PhotoboothConnectResponse
+ {
+ Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
+ };
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return new PhotoboothConnectResponse
+ {
+ Message = payload.Message ?? "Verbindung fehlgeschlagen.",
+ };
+ }
+
+ return payload;
+ }
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs
new file mode 100644
index 0000000..a5fcb3c
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/SettingsStore.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using PhotoboothUploader.Models;
+
+namespace PhotoboothUploader.Services;
+
+public sealed class SettingsStore
+{
+ private readonly JsonSerializerOptions _options = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true,
+ };
+
+ public string SettingsPath { get; }
+
+ public SettingsStore()
+ {
+ var basePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Fotospiel",
+ "PhotoboothUploader");
+
+ Directory.CreateDirectory(basePath);
+ SettingsPath = Path.Combine(basePath, "settings.json");
+ }
+
+ public PhotoboothSettings Load()
+ {
+ if (!File.Exists(SettingsPath))
+ {
+ return new PhotoboothSettings();
+ }
+
+ var json = File.ReadAllText(SettingsPath);
+ return JsonSerializer.Deserialize(json, _options) ?? new PhotoboothSettings();
+ }
+
+ public void Save(PhotoboothSettings settings)
+ {
+ var json = JsonSerializer.Serialize(settings, _options);
+ File.WriteAllText(SettingsPath, json);
+ }
+}
diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
new file mode 100644
index 0000000..8628813
--- /dev/null
+++ b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs
@@ -0,0 +1,143 @@
+using System.Collections.Concurrent;
+using System.Net.Http.Headers;
+using System.Threading.Channels;
+using PhotoboothUploader.Models;
+
+namespace PhotoboothUploader.Services;
+
+public sealed class UploadService
+{
+ private readonly Channel _queue = Channel.CreateUnbounded();
+ private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase);
+ private CancellationTokenSource? _cts;
+
+ public void Start(PhotoboothSettings settings, Action setStatus)
+ {
+ Stop();
+
+ _cts = new CancellationTokenSource();
+ _ = Task.Run(() => WorkerAsync(settings, setStatus, _cts.Token));
+ }
+
+ public void Stop()
+ {
+ _cts?.Cancel();
+ _cts = null;
+ _pending.Clear();
+ }
+
+ public void Enqueue(string path)
+ {
+ if (!_pending.TryAdd(path, 0))
+ {
+ return;
+ }
+
+ _queue.Writer.TryWrite(path);
+ }
+
+ private async Task WorkerAsync(PhotoboothSettings settings, Action setStatus, CancellationToken token)
+ {
+ if (string.IsNullOrWhiteSpace(settings.UploadUrl))
+ {
+ return;
+ }
+
+ using var client = new HttpClient();
+
+ while (await _queue.Reader.WaitToReadAsync(token))
+ {
+ while (_queue.Reader.TryRead(out var path))
+ {
+ try
+ {
+ await WaitForFileReadyAsync(path, token);
+ await UploadAsync(client, settings, path, token);
+ setStatus($"Hochgeladen: {Path.GetFileName(path)}");
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch
+ {
+ setStatus($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
+ }
+ finally
+ {
+ _pending.TryRemove(path, out _);
+ }
+ }
+ }
+ }
+
+ private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
+ {
+ var lastSize = -1L;
+
+ for (var attempts = 0; attempts < 10; attempts++)
+ {
+ token.ThrowIfCancellationRequested();
+
+ if (!File.Exists(path))
+ {
+ await Task.Delay(500, token);
+ continue;
+ }
+
+ var info = new FileInfo(path);
+ var size = info.Length;
+
+ if (size > 0 && size == lastSize)
+ {
+ return;
+ }
+
+ lastSize = size;
+ await Task.Delay(700, token);
+ }
+ }
+
+ private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
+ {
+ if (!File.Exists(path))
+ {
+ return;
+ }
+
+ using var content = new MultipartFormDataContent();
+
+ if (!string.IsNullOrWhiteSpace(settings.Username))
+ {
+ content.Add(new StringContent(settings.Username), "username");
+ }
+
+ if (!string.IsNullOrWhiteSpace(settings.Password))
+ {
+ content.Add(new StringContent(settings.Password), "password");
+ }
+
+ if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
+ {
+ content.Add(new StringContent(settings.ResponseFormat), "format");
+ }
+
+ var stream = File.OpenRead(path);
+ var fileContent = new StreamContent(stream);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
+ content.Add(fileContent, "media", Path.GetFileName(path));
+
+ var response = await client.PostAsync(settings.UploadUrl, content, token);
+ response.EnsureSuccessStatusCode();
+ }
+
+ private static string ResolveContentType(string path)
+ {
+ return Path.GetExtension(path)?.ToLowerInvariant() switch
+ {
+ ".png" => "image/png",
+ ".webp" => "image/webp",
+ _ => "image/jpeg",
+ };
+ }
+}
diff --git a/config/photobooth.php b/config/photobooth.php
index f95ece7..e22ee61 100644
--- a/config/photobooth.php
+++ b/config/photobooth.php
@@ -34,4 +34,8 @@ return [
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
],
+ 'connect_code' => [
+ 'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
+ 'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
+ ],
];
diff --git a/database/factories/PhotoboothConnectCodeFactory.php b/database/factories/PhotoboothConnectCodeFactory.php
new file mode 100644
index 0000000..685f978
--- /dev/null
+++ b/database/factories/PhotoboothConnectCodeFactory.php
@@ -0,0 +1,29 @@
+
+ */
+class PhotoboothConnectCodeFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ $rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
+
+ return [
+ 'event_id' => Event::factory(),
+ 'code_hash' => hash('sha256', $rawCode),
+ 'expires_at' => now()->addMinutes(10),
+ 'redeemed_at' => null,
+ ];
+ }
+}
diff --git a/database/migrations/2026_01_12_164023_create_photobooth_connect_codes_table.php b/database/migrations/2026_01_12_164023_create_photobooth_connect_codes_table.php
new file mode 100644
index 0000000..18ba499
--- /dev/null
+++ b/database/migrations/2026_01_12_164023_create_photobooth_connect_codes_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->foreignId('event_id')->constrained()->cascadeOnDelete();
+ $table->string('code_hash', 64)->unique();
+ $table->timestamp('expires_at');
+ $table->timestamp('redeemed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('photobooth_connect_codes');
+ }
+};
diff --git a/database/seeders/PhotoboothConnectCodeSeeder.php b/database/seeders/PhotoboothConnectCodeSeeder.php
new file mode 100644
index 0000000..1513472
--- /dev/null
+++ b/database/seeders/PhotoboothConnectCodeSeeder.php
@@ -0,0 +1,16 @@
+name('api.v1.')->group(function () {
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload');
+ Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
+ ->middleware('throttle:photobooth-connect')
+ ->name('photobooth.connect');
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
->whereNumber('photo')
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
+ Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
+ ->name('tenant.events.photobooth.connect-codes.store');
});
Route::get('members', [EventMemberController::class, 'index'])
diff --git a/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
new file mode 100644
index 0000000..68bdb6b
--- /dev/null
+++ b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
@@ -0,0 +1,100 @@
+for($this->tenant)->create([
+ 'slug' => 'connect-code-event',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
+
+ $response->assertOk()
+ ->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
+ ->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
+
+ $this->assertDatabaseCount('photobooth_connect_codes', 1);
+ }
+
+ #[Test]
+ public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
+ {
+ $event = Event::factory()->for($this->tenant)->create([
+ 'slug' => 'connect-code-redeem',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
+ $codeResponse->assertOk();
+
+ $code = (string) $codeResponse->json('data.code');
+
+ $redeem = $this->postJson('/api/v1/photobooth/connect', [
+ 'code' => $code,
+ ]);
+
+ $redeem->assertOk()
+ ->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
+ ->assertJsonPath('data.username', 'pbconnect')
+ ->assertJsonPath('data.password', 'SECRET12');
+
+ $this->assertDatabaseHas('photobooth_connect_codes', [
+ 'event_id' => $event->id,
+ ]);
+ }
+
+ #[Test]
+ public function it_rejects_expired_connect_codes(): void
+ {
+ $event = Event::factory()->for($this->tenant)->create([
+ 'slug' => 'connect-code-expired',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $code = '123456';
+
+ PhotoboothConnectCode::query()->create([
+ 'event_id' => $event->id,
+ 'code_hash' => hash('sha256', $code),
+ 'expires_at' => now()->subMinute(),
+ ]);
+
+ $response = $this->postJson('/api/v1/photobooth/connect', [
+ 'code' => $code,
+ ]);
+
+ $response->assertStatus(422);
+ }
+}