Add photobooth connect codes and uploader scaffold
This commit is contained in:
@@ -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-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-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-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-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-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"}
|
{"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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-9em
|
fotospiel-app-29r
|
||||||
|
|||||||
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||||
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PhotoboothConnectController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||||
|
|
||||||
|
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->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'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||||
|
|
||||||
|
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Photobooth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectRedeemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PhotoboothConnectCode extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'redeemed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
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) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||||
});
|
});
|
||||||
|
|||||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Photobooth;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\PhotoboothConnectCode;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeService
|
||||||
|
{
|
||||||
|
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||||
|
{
|
||||||
|
$length = (int) config('photobooth.connect_code.length', 6);
|
||||||
|
$length = max(4, min(8, $length));
|
||||||
|
|
||||||
|
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||||
|
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||||
|
|
||||||
|
$code = null;
|
||||||
|
$hash = null;
|
||||||
|
$max = (10 ** $length) - 1;
|
||||||
|
|
||||||
|
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||||
|
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||||
|
$candidateHash = hash('sha256', $candidate);
|
||||||
|
|
||||||
|
$exists = PhotoboothConnectCode::query()
|
||||||
|
->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
clients/photobooth-uploader/PhotoboothUploader.sln
Normal file
18
clients/photobooth-uploader/PhotoboothUploader.sln
Normal file
@@ -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
|
||||||
7
clients/photobooth-uploader/PhotoboothUploader/App.xaml
Normal file
7
clients/photobooth-uploader/PhotoboothUploader/App.xaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Application
|
||||||
|
x:Class="PhotoboothUploader.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
17
clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs
Normal file
17
clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Window
|
||||||
|
x:Class="PhotoboothUploader.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Fotospiel Photobooth Uploader"
|
||||||
|
Height="360"
|
||||||
|
Width="520">
|
||||||
|
<Grid Padding="24">
|
||||||
|
<StackPanel Spacing="16" MaxWidth="420">
|
||||||
|
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
|
||||||
|
<TextBox x:Name="CodeBox" MaxLength="6" PlaceholderText="123456">
|
||||||
|
<TextBox.InputScope>
|
||||||
|
<InputScope>
|
||||||
|
<InputScopeName NameValue="Number" />
|
||||||
|
</InputScope>
|
||||||
|
</TextBox.InputScope>
|
||||||
|
</TextBox>
|
||||||
|
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
|
||||||
|
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<UseWinUI>true</UseWinUI>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240404000" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ApplicationDefinition Include="App.xaml" />
|
||||||
|
<Page Include="MainWindow.xaml" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings, _options);
|
||||||
|
File.WriteAllText(SettingsPath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> _queue = Channel.CreateUnbounded<string>();
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public void Start(PhotoboothSettings settings, Action<string> 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<string> 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,4 +34,8 @@ return [
|
|||||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
'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'),
|
'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),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||||
|
*/
|
||||||
|
class PhotoboothConnectCodeFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('photobooth_connect_codes', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
|
|||||||
use App\Http\Controllers\Api\LiveShowController;
|
use App\Http\Controllers\Api\LiveShowController;
|
||||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||||
use App\Http\Controllers\Api\PackageController;
|
use App\Http\Controllers\Api\PackageController;
|
||||||
|
use App\Http\Controllers\Api\PhotoboothConnectController;
|
||||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||||
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||||
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
|
|||||||
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
|
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
|
||||||
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
||||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\ProfileController;
|
use App\Http\Controllers\Api\Tenant\ProfileController;
|
||||||
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
|
|
||||||
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||||
->name('photobooth.sparkbooth.upload');
|
->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'])
|
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
|
||||||
->whereNumber('photo')
|
->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('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
|
||||||
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
||||||
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
|
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'])
|
Route::get('members', [EventMemberController::class, 'index'])
|
||||||
|
|||||||
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Photobooth;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventPhotoboothSetting;
|
||||||
|
use App\Models\PhotoboothConnectCode;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
|
class PhotoboothConnectCodeTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_creates_a_connect_code_for_sparkbooth(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user