From 8cc09188819feb0710e3988491c9bdfbfe6481b2 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 7 Feb 2026 10:10:45 +0100 Subject: [PATCH] feat(ai-edits): add output storage backfill flow and coverage --- .../AiEditsBackfillStorageCommand.php | 144 +++++ .../Api/EventPublicAiEditController.php | 44 ++ .../Api/Tenant/AiEditController.php | 44 ++ app/Jobs/PollAiEditRequest.php | 24 +- app/Jobs/ProcessAiEditRequest.php | 28 +- .../AiEditing/AiEditOutputStorageService.php | 520 ++++++++++++++++++ bootstrap/app.php | 1 + config/ai-editing.php | 12 + docs/ops/README.md | 1 + docs/ops/ai-magic-edits-ops.md | 237 ++++++++ .../guest-v2/components/AiMagicEditSheet.tsx | 21 +- resources/js/guest-v2/services/aiEditsApi.ts | 1 + .../Api/Event/EventAiEditControllerTest.php | 63 +++ .../Api/Tenant/TenantAiEditControllerTest.php | 59 ++ .../AiEditsBackfillStorageCommandTest.php | 121 ++++ tests/Feature/Jobs/PollAiEditRequestTest.php | 90 +++ .../Feature/Jobs/ProcessAiEditRequestTest.php | 86 +++ .../AiEditOutputStorageServiceTest.php | 132 +++++ 18 files changed, 1610 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/AiEditsBackfillStorageCommand.php create mode 100644 app/Services/AiEditing/AiEditOutputStorageService.php create mode 100644 docs/ops/ai-magic-edits-ops.md create mode 100644 tests/Feature/Console/AiEditsBackfillStorageCommandTest.php create mode 100644 tests/Unit/Services/AiEditOutputStorageServiceTest.php diff --git a/app/Console/Commands/AiEditsBackfillStorageCommand.php b/app/Console/Commands/AiEditsBackfillStorageCommand.php new file mode 100644 index 00000000..27f93e18 --- /dev/null +++ b/app/Console/Commands/AiEditsBackfillStorageCommand.php @@ -0,0 +1,144 @@ +option('limit')); + $requestId = $this->normalizeRequestId($this->option('request-id')); + $pretend = (bool) $this->option('pretend'); + + $query = AiEditOutput::query() + ->with('request') + ->whereNotNull('provider_url') + ->where(function (Builder $builder): void { + $builder + ->whereNull('storage_path') + ->orWhere('storage_path', ''); + }) + ->orderBy('id'); + + if ($requestId !== null) { + $query->where('request_id', $requestId); + } + + $candidateCount = (clone $query)->count(); + $outputs = $query->limit($limit)->get(); + + if ($outputs->isEmpty()) { + $this->info('No AI outputs require storage backfill.'); + + return self::SUCCESS; + } + + $this->line(sprintf( + 'AI output backfill candidates: %d (processing up to %d).', + $candidateCount, + $limit + )); + + if ($pretend) { + $this->table( + ['Output ID', 'Request ID', 'Provider URL'], + $outputs->map(static fn (AiEditOutput $output): array => [ + (string) $output->id, + (string) $output->request_id, + (string) $output->provider_url, + ])->all() + ); + + $this->info('Pretend mode enabled. No records were changed.'); + + return self::SUCCESS; + } + + $processed = 0; + $stored = 0; + $failed = 0; + + foreach ($outputs as $output) { + $processed++; + + $request = $output->request; + if (! $request instanceof AiEditRequest) { + $failed++; + $this->warn(sprintf('Output %d skipped: missing request relation.', $output->id)); + + continue; + } + + $persisted = $this->outputStorage->persist($request, [ + 'provider_url' => $output->provider_url, + 'provider_asset_id' => $output->provider_asset_id, + 'storage_disk' => $output->storage_disk, + 'storage_path' => $output->storage_path, + 'mime_type' => $output->mime_type, + 'width' => $output->width, + 'height' => $output->height, + 'bytes' => $output->bytes, + 'checksum' => $output->checksum, + 'metadata' => $output->metadata, + ]); + + $output->forceFill([ + 'provider_url' => $persisted['provider_url'] ?? $output->provider_url, + 'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk, + 'storage_path' => $persisted['storage_path'] ?? $output->storage_path, + 'mime_type' => $persisted['mime_type'] ?? $output->mime_type, + 'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width, + 'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height, + 'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes, + 'checksum' => $persisted['checksum'] ?? $output->checksum, + 'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata, + ])->save(); + + $storagePath = trim((string) ($output->storage_path ?? '')); + if ($storagePath !== '') { + $stored++; + } else { + $failed++; + $this->warn(sprintf('Output %d could not be persisted locally.', $output->id)); + } + } + + $this->info(sprintf( + 'AI output backfill complete: processed=%d stored=%d failed=%d.', + $processed, + $stored, + $failed + )); + + return self::SUCCESS; + } + + private function normalizeRequestId(mixed $value): ?int + { + if (! is_numeric($value)) { + return null; + } + + $requestId = (int) $value; + + return $requestId > 0 ? $requestId : null; + } +} diff --git a/app/Http/Controllers/Api/EventPublicAiEditController.php b/app/Http/Controllers/Api/EventPublicAiEditController.php index f50cc97d..0421307a 100644 --- a/app/Http/Controllers/Api/EventPublicAiEditController.php +++ b/app/Http/Controllers/Api/EventPublicAiEditController.php @@ -20,6 +20,8 @@ use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -492,6 +494,11 @@ class EventPublicAiEditController extends BaseController 'storage_disk' => $output->storage_disk, 'storage_path' => $output->storage_path, 'provider_url' => $output->provider_url, + 'url' => $this->resolveOutputUrl( + $output->storage_disk, + $output->storage_path, + $output->provider_url + ), 'mime_type' => $output->mime_type, 'width' => $output->width, 'height' => $output->height, @@ -502,4 +509,41 @@ class EventPublicAiEditController extends BaseController ])->values(), ]; } + + private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string + { + $resolvedStoragePath = $this->normalizeOptionalString($storagePath); + if ($resolvedStoragePath !== null) { + if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) { + return $resolvedStoragePath; + } + + $disk = $this->resolveStorageDisk($storageDisk); + + try { + return Storage::disk($disk)->url($resolvedStoragePath); + } catch (\Throwable $exception) { + Log::debug('Falling back to raw AI output storage path', [ + 'disk' => $disk, + 'path' => $resolvedStoragePath, + 'error' => $exception->getMessage(), + ]); + + return '/'.ltrim($resolvedStoragePath, '/'); + } + } + + return $this->normalizeOptionalString($providerUrl); + } + + private function resolveStorageDisk(?string $disk): string + { + $candidate = trim((string) ($disk ?: config('filesystems.default', 'public'))); + + if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) { + return (string) config('filesystems.default', 'public'); + } + + return $candidate; + } } diff --git a/app/Http/Controllers/Api/Tenant/AiEditController.php b/app/Http/Controllers/Api/Tenant/AiEditController.php index ac79aba5..aa8ac671 100644 --- a/app/Http/Controllers/Api/Tenant/AiEditController.php +++ b/app/Http/Controllers/Api/Tenant/AiEditController.php @@ -24,6 +24,8 @@ use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -561,6 +563,11 @@ class AiEditController extends Controller 'storage_disk' => $output->storage_disk, 'storage_path' => $output->storage_path, 'provider_url' => $output->provider_url, + 'url' => $this->resolveOutputUrl( + $output->storage_disk, + $output->storage_path, + $output->provider_url + ), 'mime_type' => $output->mime_type, 'width' => $output->width, 'height' => $output->height, @@ -571,4 +578,41 @@ class AiEditController extends Controller ])->values(), ]; } + + private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string + { + $resolvedStoragePath = $this->normalizeOptionalString($storagePath); + if ($resolvedStoragePath !== null) { + if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) { + return $resolvedStoragePath; + } + + $disk = $this->resolveStorageDisk($storageDisk); + + try { + return Storage::disk($disk)->url($resolvedStoragePath); + } catch (\Throwable $exception) { + Log::debug('Falling back to raw AI output storage path', [ + 'disk' => $disk, + 'path' => $resolvedStoragePath, + 'error' => $exception->getMessage(), + ]); + + return '/'.ltrim($resolvedStoragePath, '/'); + } + } + + return $this->normalizeOptionalString($providerUrl); + } + + private function resolveStorageDisk(?string $disk): string + { + $candidate = trim((string) ($disk ?: config('filesystems.default', 'public'))); + + if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) { + return (string) config('filesystems.default', 'public'); + } + + return $candidate; + } } diff --git a/app/Jobs/PollAiEditRequest.php b/app/Jobs/PollAiEditRequest.php index baa1f0d6..75d15353 100644 --- a/app/Jobs/PollAiEditRequest.php +++ b/app/Jobs/PollAiEditRequest.php @@ -6,6 +6,7 @@ use App\Models\AiEditOutput; use App\Models\AiEditRequest; use App\Models\AiProviderRun; use App\Services\AiEditing\AiEditingRuntimeConfig; +use App\Services\AiEditing\AiEditOutputStorageService; use App\Services\AiEditing\AiImageProviderManager; use App\Services\AiEditing\AiObservabilityService; use App\Services\AiEditing\AiStatusNotificationService; @@ -51,6 +52,7 @@ class PollAiEditRequest implements ShouldQueue AiAbuseEscalationService $abuseEscalation, AiObservabilityService $observability, AiStatusNotificationService $statusNotifications, + AiEditOutputStorageService $outputStorage, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { @@ -119,21 +121,31 @@ class PollAiEditRequest implements ShouldQueue } foreach ($result->outputs as $output) { + $persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []); AiEditOutput::query()->updateOrCreate( [ 'request_id' => $request->id, - 'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', $this->providerTaskId), + 'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', $this->providerTaskId), ], [ - 'provider_url' => Arr::get($output, 'provider_url'), - 'mime_type' => Arr::get($output, 'mime_type'), - 'width' => Arr::get($output, 'width'), - 'height' => Arr::get($output, 'height'), + 'storage_disk' => Arr::get($persistedOutput, 'storage_disk'), + 'storage_path' => Arr::get($persistedOutput, 'storage_path'), + 'provider_url' => Arr::get($persistedOutput, 'provider_url'), + 'mime_type' => Arr::get($persistedOutput, 'mime_type'), + 'width' => Arr::get($persistedOutput, 'width'), + 'height' => Arr::get($persistedOutput, 'height'), + 'bytes' => Arr::get($persistedOutput, 'bytes'), + 'checksum' => Arr::get($persistedOutput, 'checksum'), 'is_primary' => true, 'safety_state' => 'passed', 'safety_reasons' => [], 'generated_at' => now(), - 'metadata' => ['provider' => $request->provider], + 'metadata' => array_merge( + ['provider' => $request->provider], + is_array(Arr::get($persistedOutput, 'metadata')) + ? Arr::get($persistedOutput, 'metadata') + : [] + ), ] ); } diff --git a/app/Jobs/ProcessAiEditRequest.php b/app/Jobs/ProcessAiEditRequest.php index a52ed586..6b42fa3e 100644 --- a/app/Jobs/ProcessAiEditRequest.php +++ b/app/Jobs/ProcessAiEditRequest.php @@ -6,6 +6,7 @@ use App\Models\AiEditOutput; use App\Models\AiEditRequest; use App\Models\AiProviderRun; use App\Services\AiEditing\AiEditingRuntimeConfig; +use App\Services\AiEditing\AiEditOutputStorageService; use App\Services\AiEditing\AiImageProviderManager; use App\Services\AiEditing\AiObservabilityService; use App\Services\AiEditing\AiProviderResult; @@ -51,6 +52,7 @@ class ProcessAiEditRequest implements ShouldQueue AiAbuseEscalationService $abuseEscalation, AiObservabilityService $observability, AiStatusNotificationService $statusNotifications, + AiEditOutputStorageService $outputStorage, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { @@ -90,6 +92,7 @@ class ProcessAiEditRequest implements ShouldQueue $abuseEscalation, $observability, $statusNotifications, + $outputStorage, $runtimeConfig, $usageLedger ); @@ -160,6 +163,7 @@ class ProcessAiEditRequest implements ShouldQueue AiAbuseEscalationService $abuseEscalation, AiObservabilityService $observability, AiStatusNotificationService $statusNotifications, + AiEditOutputStorageService $outputStorage, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { @@ -200,23 +204,33 @@ class ProcessAiEditRequest implements ShouldQueue return; } - DB::transaction(function () use ($request, $result): void { + DB::transaction(function () use ($request, $result, $outputStorage): void { foreach ($result->outputs as $output) { + $persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []); AiEditOutput::query()->updateOrCreate( [ 'request_id' => $request->id, - 'provider_asset_id' => (string) Arr::get($output, 'provider_asset_id', ''), + 'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', ''), ], [ - 'provider_url' => Arr::get($output, 'provider_url'), - 'mime_type' => Arr::get($output, 'mime_type'), - 'width' => Arr::get($output, 'width'), - 'height' => Arr::get($output, 'height'), + 'storage_disk' => Arr::get($persistedOutput, 'storage_disk'), + 'storage_path' => Arr::get($persistedOutput, 'storage_path'), + 'provider_url' => Arr::get($persistedOutput, 'provider_url'), + 'mime_type' => Arr::get($persistedOutput, 'mime_type'), + 'width' => Arr::get($persistedOutput, 'width'), + 'height' => Arr::get($persistedOutput, 'height'), + 'bytes' => Arr::get($persistedOutput, 'bytes'), + 'checksum' => Arr::get($persistedOutput, 'checksum'), 'is_primary' => true, 'safety_state' => 'passed', 'safety_reasons' => [], 'generated_at' => now(), - 'metadata' => ['provider' => $request->provider], + 'metadata' => array_merge( + ['provider' => $request->provider], + is_array(Arr::get($persistedOutput, 'metadata')) + ? Arr::get($persistedOutput, 'metadata') + : [] + ), ] ); } diff --git a/app/Services/AiEditing/AiEditOutputStorageService.php b/app/Services/AiEditing/AiEditOutputStorageService.php new file mode 100644 index 00000000..d2bb01ab --- /dev/null +++ b/app/Services/AiEditing/AiEditOutputStorageService.php @@ -0,0 +1,520 @@ + $output + * @return array{ + * provider_url: ?string, + * provider_asset_id: ?string, + * storage_disk: ?string, + * storage_path: ?string, + * mime_type: ?string, + * width: ?int, + * height: ?int, + * bytes: ?int, + * checksum: ?string, + * metadata: array + * } + */ + public function persist(AiEditRequest $request, array $output): array + { + $normalized = $this->normalizeOutput($output); + + if (! $this->outputsEnabled()) { + return $normalized; + } + + $providerUrl = $normalized['provider_url']; + if (! is_string($providerUrl) || trim($providerUrl) === '') { + return $normalized; + } + + $event = Event::query() + ->with([ + 'tenant', + 'eventPackage.package', + 'eventPackages.package', + 'storageAssignments.storageTarget', + ]) + ->find($request->event_id); + + if (! $event) { + return $this->withStorageFailure( + $normalized, + 'event_not_found', + 'Could not resolve event while persisting AI output.' + ); + } + + try { + $download = $this->downloadImage($providerUrl); + $persisted = $this->storeLocally($request, $event, $download); + + return array_merge($normalized, $persisted); + } catch (Throwable $exception) { + Log::warning('AI output local persistence failed', [ + 'request_id' => $request->id, + 'event_id' => $request->event_id, + 'provider' => $request->provider, + 'provider_url' => $providerUrl, + 'error' => $exception->getMessage(), + ]); + + return $this->withStorageFailure( + $normalized, + 'output_storage_failed', + $exception->getMessage() + ); + } + } + + /** + * @return array{ + * binary: string, + * mime_type: string, + * width: int, + * height: int, + * bytes: int, + * extension: string + * } + */ + private function downloadImage(string $url): array + { + $this->assertSourceUrlAllowed($url); + + $response = Http::accept('*/*') + ->connectTimeout(max(1, (int) config('ai-editing.outputs.connect_timeout_seconds', 10))) + ->timeout(max(1, (int) config('ai-editing.outputs.timeout_seconds', 60))) + ->withHeaders([ + 'User-Agent' => (string) config('ai-editing.outputs.user_agent', 'fotospiel-ai-output-fetcher/1.0'), + ]) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException(sprintf( + 'Provider output download returned HTTP %d.', + $response->status() + )); + } + + $maxBytes = max(1024, (int) config('ai-editing.outputs.max_download_bytes', 15 * 1024 * 1024)); + $contentLength = (int) ($response->header('Content-Length') ?: 0); + if ($contentLength > $maxBytes) { + throw new RuntimeException(sprintf( + 'Provider output exceeds maximum allowed size (%d bytes).', + $maxBytes + )); + } + + $binary = $response->body(); + if (! is_string($binary) || $binary === '') { + throw new RuntimeException('Provider output is empty.'); + } + + $bytes = strlen($binary); + if ($bytes > $maxBytes) { + throw new RuntimeException(sprintf( + 'Downloaded output exceeds maximum allowed size (%d bytes).', + $maxBytes + )); + } + + $imageInfo = @getimagesizefromstring($binary); + if (! is_array($imageInfo)) { + throw new RuntimeException('Provider output is not a valid image.'); + } + + $mimeType = $this->normalizeMimeType( + $imageInfo['mime'] ?? $response->header('Content-Type') + ); + if (! str_starts_with($mimeType, 'image/')) { + throw new RuntimeException('Provider output did not return an image mime type.'); + } + + return [ + 'binary' => $binary, + 'mime_type' => $mimeType, + 'width' => (int) ($imageInfo[0] ?? 0), + 'height' => (int) ($imageInfo[1] ?? 0), + 'bytes' => $bytes, + 'extension' => $this->extensionForMimeType($mimeType, $url), + ]; + } + + /** + * @param array{ + * binary: string, + * mime_type: string, + * width: int, + * height: int, + * bytes: int, + * extension: string + * } $download + * @return array{ + * storage_disk: ?string, + * storage_path: ?string, + * mime_type: ?string, + * width: ?int, + * height: ?int, + * bytes: ?int, + * checksum: ?string, + * metadata: array + * } + */ + private function storeLocally(AiEditRequest $request, Event $event, array $download): array + { + $disk = $this->eventStorageManager->getHotDiskForEvent($event); + $eventSlug = trim((string) ($event->slug ?? '')); + if ($eventSlug === '') { + $eventSlug = 'event-'.$event->id; + } + + $baseName = Str::uuid()->toString(); + $baseDirectory = sprintf('events/%s/ai-edits/%d', $eventSlug, $request->id); + $originalPath = sprintf('%s/original/%s.%s', $baseDirectory, $baseName, $download['extension']); + Storage::disk($disk)->put($originalPath, $download['binary']); + + $thumbnailPath = sprintf('%s/thumbnails/%s_thumb.jpg', $baseDirectory, $baseName); + $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $originalPath, $thumbnailPath, 640, 82); + + $watermarkConfig = WatermarkConfigResolver::resolve($event); + $finalPath = $originalPath; + $finalThumbnailPath = $thumbnailRelative; + $watermarkApplied = false; + + if ($this->shouldApplyWatermark($watermarkConfig)) { + $watermarkedPath = sprintf('%s/watermarked/%s.%s', $baseDirectory, $baseName, $download['extension']); + $resolvedWatermarkedPath = ImageHelper::copyWithWatermark($disk, $originalPath, $watermarkedPath, $watermarkConfig); + if (is_string($resolvedWatermarkedPath) && $resolvedWatermarkedPath !== '') { + $finalPath = $resolvedWatermarkedPath; + $watermarkApplied = true; + } + + if ($thumbnailRelative) { + $watermarkedThumbPath = sprintf('%s/watermarked/%s_thumb.jpg', $baseDirectory, $baseName); + $resolvedWatermarkedThumbPath = ImageHelper::copyWithWatermark($disk, $thumbnailRelative, $watermarkedThumbPath, $watermarkConfig); + if (is_string($resolvedWatermarkedThumbPath) && $resolvedWatermarkedThumbPath !== '') { + $finalThumbnailPath = $resolvedWatermarkedThumbPath; + } + } + } + + $finalBinary = Storage::disk($disk)->get($finalPath); + $finalImageInfo = is_string($finalBinary) ? @getimagesizefromstring($finalBinary) : false; + $finalMimeType = is_array($finalImageInfo) + ? $this->normalizeMimeType($finalImageInfo['mime'] ?? $download['mime_type']) + : $download['mime_type']; + $finalWidth = is_array($finalImageInfo) ? (int) ($finalImageInfo[0] ?? 0) : $download['width']; + $finalHeight = is_array($finalImageInfo) ? (int) ($finalImageInfo[1] ?? 0) : $download['height']; + $finalBytes = Storage::disk($disk)->exists($finalPath) + ? (int) Storage::disk($disk)->size($finalPath) + : $download['bytes']; + $checksum = is_string($finalBinary) && $finalBinary !== '' + ? hash('sha256', $finalBinary) + : null; + + $metadata = [ + 'storage' => [ + 'original_path' => $originalPath, + 'thumbnail_path' => $thumbnailRelative, + 'watermarked_path' => $watermarkApplied ? $finalPath : null, + 'watermarked_thumbnail_path' => $finalThumbnailPath !== $thumbnailRelative ? $finalThumbnailPath : null, + 'watermark_applied' => $watermarkApplied, + 'stored_at' => now()->toIso8601String(), + ], + ]; + + $this->recordAssetSafely($event, $disk, $originalPath, [ + 'variant' => 'ai_original', + 'mime_type' => $download['mime_type'], + 'size_bytes' => $download['bytes'], + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $request->photo_id, + 'meta' => ['ai_request_id' => $request->id], + ]); + + if ($finalPath !== $originalPath) { + $this->recordAssetSafely($event, $disk, $finalPath, [ + 'variant' => 'ai_watermarked', + 'mime_type' => $finalMimeType, + 'size_bytes' => $finalBytes, + 'checksum' => $checksum, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $request->photo_id, + 'meta' => ['ai_request_id' => $request->id], + ]); + } + + if ($thumbnailRelative) { + $this->recordAssetSafely($event, $disk, $thumbnailRelative, [ + 'variant' => 'ai_thumbnail', + 'mime_type' => 'image/jpeg', + 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) + ? (int) Storage::disk($disk)->size($thumbnailRelative) + : null, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $request->photo_id, + 'meta' => ['ai_request_id' => $request->id], + ]); + } + + if ($finalThumbnailPath && $finalThumbnailPath !== $thumbnailRelative) { + $this->recordAssetSafely($event, $disk, $finalThumbnailPath, [ + 'variant' => 'ai_watermarked_thumbnail', + 'mime_type' => 'image/jpeg', + 'size_bytes' => Storage::disk($disk)->exists($finalThumbnailPath) + ? (int) Storage::disk($disk)->size($finalThumbnailPath) + : null, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $request->photo_id, + 'meta' => ['ai_request_id' => $request->id], + ]); + } + + return [ + 'storage_disk' => $disk, + 'storage_path' => $finalPath, + 'mime_type' => $finalMimeType, + 'width' => $finalWidth > 0 ? $finalWidth : null, + 'height' => $finalHeight > 0 ? $finalHeight : null, + 'bytes' => $finalBytes > 0 ? $finalBytes : null, + 'checksum' => $checksum, + 'metadata' => $metadata, + ]; + } + + /** + * @param array $attributes + */ + private function recordAssetSafely(Event $event, string $disk, string $path, array $attributes): void + { + try { + $this->eventStorageManager->recordAsset($event, $disk, $path, $attributes); + } catch (Throwable $exception) { + Log::warning('AI output asset metadata recording failed', [ + 'event_id' => $event->id, + 'disk' => $disk, + 'path' => $path, + 'error' => $exception->getMessage(), + ]); + } + } + + private function shouldApplyWatermark(array $watermarkConfig): bool + { + return ($watermarkConfig['type'] ?? 'none') !== 'none' + && trim((string) ($watermarkConfig['asset'] ?? '')) !== '' + && ! (bool) ($watermarkConfig['serve_originals'] ?? false); + } + + private function outputsEnabled(): bool + { + return (bool) config('ai-editing.outputs.enabled', true); + } + + private function assertSourceUrlAllowed(string $url): void + { + $parsed = parse_url($url); + if (! is_array($parsed)) { + throw new RuntimeException('Provider output URL is invalid.'); + } + + $scheme = strtolower((string) ($parsed['scheme'] ?? '')); + if (! in_array($scheme, ['http', 'https'], true)) { + throw new RuntimeException('Provider output URL scheme is not allowed.'); + } + + $host = strtolower((string) ($parsed['host'] ?? '')); + if ($host === '') { + throw new RuntimeException('Provider output URL host is missing.'); + } + + if ( + $host === 'localhost' + || str_ends_with($host, '.local') + || str_ends_with($host, '.internal') + || str_ends_with($host, '.invalid') + || str_ends_with($host, '.test') + ) { + throw new RuntimeException('Provider output URL host is blocked.'); + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + $isPublicIp = filter_var( + $host, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + + if (! $isPublicIp) { + throw new RuntimeException('Provider output URL host is not publicly routable.'); + } + } + + $allowedHosts = array_values(array_filter(array_map( + static fn (mixed $value): string => strtolower(trim((string) $value)), + (array) config('ai-editing.outputs.allowed_hosts', []) + ))); + + if ($allowedHosts === []) { + return; + } + + foreach ($allowedHosts as $allowedHost) { + if ($allowedHost === '') { + continue; + } + + if ($host === $allowedHost || str_ends_with($host, '.'.$allowedHost)) { + return; + } + } + + throw new RuntimeException('Provider output URL host is not in allowed host list.'); + } + + /** + * @param array $output + * @return array{ + * provider_url: ?string, + * provider_asset_id: ?string, + * storage_disk: ?string, + * storage_path: ?string, + * mime_type: ?string, + * width: ?int, + * height: ?int, + * bytes: ?int, + * checksum: ?string, + * metadata: array + * } + */ + private function normalizeOutput(array $output): array + { + return [ + 'provider_url' => $this->normalizeOptionalString((string) Arr::get($output, 'provider_url', '')), + 'provider_asset_id' => $this->normalizeOptionalString((string) Arr::get($output, 'provider_asset_id', '')), + 'storage_disk' => $this->normalizeOptionalString((string) Arr::get($output, 'storage_disk', '')), + 'storage_path' => $this->normalizeOptionalString((string) Arr::get($output, 'storage_path', '')), + 'mime_type' => $this->normalizeOptionalString((string) Arr::get($output, 'mime_type', '')), + 'width' => is_numeric(Arr::get($output, 'width')) ? (int) Arr::get($output, 'width') : null, + 'height' => is_numeric(Arr::get($output, 'height')) ? (int) Arr::get($output, 'height') : null, + 'bytes' => is_numeric(Arr::get($output, 'bytes')) ? (int) Arr::get($output, 'bytes') : null, + 'checksum' => $this->normalizeOptionalString((string) Arr::get($output, 'checksum', '')), + 'metadata' => is_array(Arr::get($output, 'metadata')) ? (array) Arr::get($output, 'metadata') : [], + ]; + } + + private function normalizeOptionalString(?string $value): ?string + { + if ($value === null) { + return null; + } + + $trimmed = trim($value); + + return $trimmed !== '' ? $trimmed : null; + } + + private function normalizeMimeType(?string $value): string + { + $mimeType = strtolower(trim((string) $value)); + + if ($mimeType === '') { + return 'image/jpeg'; + } + + $parts = explode(';', $mimeType); + + return trim((string) $parts[0]) !== '' ? trim((string) $parts[0]) : 'image/jpeg'; + } + + private function extensionForMimeType(string $mimeType, string $url): string + { + $map = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + ]; + + if (array_key_exists($mimeType, $map)) { + return $map[$mimeType]; + } + + $path = (string) parse_url($url, PHP_URL_PATH); + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension !== '') { + return $extension; + } + + return 'jpg'; + } + + /** + * @param array{ + * provider_url: ?string, + * provider_asset_id: ?string, + * storage_disk: ?string, + * storage_path: ?string, + * mime_type: ?string, + * width: ?int, + * height: ?int, + * bytes: ?int, + * checksum: ?string, + * metadata: array + * } $output + * @return array{ + * provider_url: ?string, + * provider_asset_id: ?string, + * storage_disk: ?string, + * storage_path: ?string, + * mime_type: ?string, + * width: ?int, + * height: ?int, + * bytes: ?int, + * checksum: ?string, + * metadata: array + * } + */ + private function withStorageFailure(array $output, string $code, string $message): array + { + $metadata = $output['metadata']; + $metadata['storage'] = array_merge( + is_array($metadata['storage'] ?? null) ? $metadata['storage'] : [], + [ + 'failed' => true, + 'error_code' => $code, + 'error_message' => Str::limit($message, 500, ''), + 'failed_at' => now()->toIso8601String(), + ] + ); + + $output['metadata'] = $metadata; + + return $output; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 29f82707..678216bc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -32,6 +32,7 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\CheckUploadQueuesCommand::class, \App\Console\Commands\AiEditsRecoverStuckCommand::class, \App\Console\Commands\AiEditsPruneCommand::class, + \App\Console\Commands\AiEditsBackfillStorageCommand::class, \App\Console\Commands\PurgeExpiredDataExports::class, \App\Console\Commands\ProcessTenantRetention::class, \App\Console\Commands\SendGuestFeedbackReminders::class, diff --git a/config/ai-editing.php b/config/ai-editing.php index be6aafb7..84a91497 100644 --- a/config/ai-editing.php +++ b/config/ai-editing.php @@ -61,6 +61,18 @@ return [ ], ], + 'outputs' => [ + 'enabled' => (bool) env('AI_EDITING_LOCAL_OUTPUTS_ENABLED', true), + 'max_download_bytes' => (int) env('AI_EDITING_OUTPUT_MAX_DOWNLOAD_BYTES', 15 * 1024 * 1024), + 'connect_timeout_seconds' => (int) env('AI_EDITING_OUTPUT_CONNECT_TIMEOUT_SECONDS', 10), + 'timeout_seconds' => (int) env('AI_EDITING_OUTPUT_TIMEOUT_SECONDS', 60), + 'user_agent' => env('AI_EDITING_OUTPUT_USER_AGENT', 'fotospiel-ai-output-fetcher/1.0'), + 'allowed_hosts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('AI_EDITING_OUTPUT_ALLOWED_HOSTS', '')) + ))), + ], + 'observability' => [ 'failure_rate_alert_threshold' => (float) env('AI_EDITING_FAILURE_RATE_ALERT_THRESHOLD', 0.35), 'failure_rate_min_samples' => (int) env('AI_EDITING_FAILURE_RATE_MIN_SAMPLES', 10), diff --git a/docs/ops/README.md b/docs/ops/README.md index aaec6ddf..c69140b2 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -9,5 +9,6 @@ This section consolidates everything platform operators need: deployment guides, - `media-storage-spec.md` — Upload/archival flow overview. - `guest-notification-ops.md` — Push notification queue monitoring. - `queue-workers.md` — Worker container instructions referencing scripts in `/scripts/`. +- `ai-magic-edits-ops.md` — AI Magic Edits Betrieb, Entitlements, Monitoring und Incident-Playbooks. Future additions (e.g., escalations, on-call checklists, Terraform notes) should live here as well so all ops content remains in one location. diff --git a/docs/ops/ai-magic-edits-ops.md b/docs/ops/ai-magic-edits-ops.md new file mode 100644 index 00000000..67189e25 --- /dev/null +++ b/docs/ops/ai-magic-edits-ops.md @@ -0,0 +1,237 @@ +--- +title: AI Magic Edits (Ops Runbook) +sidebar_label: AI Magic Edits +--- + +Dieses Runbook beschreibt den operativen Betrieb von AI Magic Edits (AI Styling) inklusive Entitlements, Provider-Betrieb, Monitoring, Recovery und Incident-Handling. + +## 1. Scope und aktueller Stand + +- Feature-Name im System: `ai_styling` (User-facing: "AI Magic Edits"/"AI-Styling"). +- Architektur ist provider-agnostisch umgesetzt (`AiImageProvider` Interface), aktuell ist `runware.ai` als erster Provider aktiv. +- Das Guest-PWA-Rollout ist derzeit clientseitig hinter einem Flag deaktiviert: + - `resources/js/guest-v2/lib/featureFlags.ts` → `GUEST_AI_MAGIC_EDITS_ENABLED = false`. +- Im Event Admin Control Room wird der AI-Bereich nur angezeigt, wenn das Event das Capability `ai_styling` hat. + +## 2. Entitlements und Freischaltung + +### 2.1 Paket-/Addon-Logik + +- Erforderliches Paket-Feature: `ai_styling` (`config/ai-editing.php`). +- Standard-Addon-Key: `ai_styling_unlock` (`config/ai-editing.php`, `config/package-addons.php`). +- Entitlement wird event-spezifisch aufgelöst über: + - aktives Event-Paket (`eventPackage.package.features`, Tabelle `event_packages`) + - aktive, abgeschlossene Addons (`eventPackage.addons`, Tabelle `event_package_addons`, `status=completed`, optionales Ablaufdatum in `metadata`). +- Quelle der Berechtigung: + - `granted_by=package` (Feature im Paket enthalten, z.B. Premium) + - `granted_by=addon` (Addon für Event aktiv) + - sonst gesperrt (`feature_locked`). + +### 2.2 Superadmin-Konfiguration + +- Paket-Feature-Verwaltung erfolgt über die bestehende Package-Resource (Feature `ai_styling` setzen). +- Addon-Katalog: + - Key `ai_styling_unlock` in `config/package-addons.php` + - kaufbar nur, wenn im Checkout-Katalog ein valider Preis hinterlegt ist. + +## 3. Runtime-Steuerung (Superadmin) + +Die zentrale Laufzeitsteuerung liegt in `AI Editing Settings` (Rare Admin Cluster): + +- `is_enabled`: Globaler Kill-Switch. +- `status_message`: Text bei globaler Deaktivierung (`feature_disabled`). +- `default_provider`: aktuell `runware`. +- `fallback_provider`: reserviert für späteres Failover. +- `runware_mode`: `live` oder `fake`. +- `queue_auto_dispatch`: automatische Queue-Dispatches direkt nach Request-Erstellung. +- `queue_name`: Ziel-Queue für AI Jobs. +- `queue_max_polls`: maximale Poll-Versuche für Provider-Status. +- `blocked_terms`: globale Prompt-Blockliste. + +Wichtiger Betriebsaspekt: +- Standard ist `queue_auto_dispatch=false`. In diesem Modus werden neue Requests zwar erstellt, aber nicht automatisch verarbeitet. +- Für Live-Betrieb muss entweder `queue_auto_dispatch=true` gesetzt oder ein separater Dispatch-Prozess etabliert sein. + +## 4. Styles und Event-Policy + +### 4.1 Globale Styles + +- Verwaltung über `AI Styles` Resource. +- Styles definieren u.a.: + - `provider`/`provider_model` + - `prompt_template`/`negative_prompt_template` + - `is_active`, `is_premium` + - optionales Entitlement-Metadatenprofil (`metadata.entitlements.*`). + +### 4.2 Event-spezifische Policy + +Event-Settings unter `settings.ai_editing` unterstützen: + +- `enabled` (Feature pro Event an/aus) +- `allow_custom_prompt` +- `allowed_style_keys` (leer = alle erlaubten Styles) +- `policy_message` (Rückmeldung bei Blockierung/Deaktivierung) + +## 5. API/Queue-Verarbeitung + +### 5.1 Endpunkte + +- Guest: + - `POST /api/v1/events/{token}/photos/{photo}/ai-edits` + - `GET /api/v1/events/{token}/ai-edits/{requestId}` + - `GET /api/v1/events/{token}/ai-styles` +- Tenant: + - `GET /api/v1/tenant/events/{eventSlug}/ai-edits` + - `GET /api/v1/tenant/events/{eventSlug}/ai-edits/summary` + - `POST /api/v1/tenant/events/{eventSlug}/ai-edits` + - `GET /api/v1/tenant/events/{eventSlug}/ai-edits/{aiEditRequest}` + - `GET /api/v1/tenant/events/{eventSlug}/ai-styles` + +### 5.2 Schutzschichten pro Request + +Reihenfolge der Kernprüfungen: + +1. Global aktiviert (`is_enabled`) +2. Entitlement vorhanden (Paket/Addon) +3. Event-Policy erlaubt Request +4. Budget erlaubt Request (Soft/Hard Cap) +5. Style-Zulässigkeit +6. Prompt-Safety (blocked terms) +7. Queue/Provider-Verarbeitung + +### 5.3 Queue-Jobs + +- `ProcessAiEditRequest`: Provider-Submit und ggf. Übergang in Polling. +- `PollAiEditRequest`: Provider-Statusabfrage bis `queue_max_polls`. +- Terminalstatus: + - `succeeded` + - `failed` + - `blocked` + +### 5.4 Output-Speicherung und Wasserzeichen + +- Bei erfolgreichem Provider-Result versucht das Backend, den Output lokal zu persistieren: + - Download von `provider_url` + - Speicherung unter Event-Pfad (`events/{eventSlug}/ai-edits/...`) + - Watermark-Anwendung über dieselbe Logik wie normale Uploads (`WatermarkConfigResolver` + `ImageHelper`). +- Primäre Auslieferung erfolgt über lokale `storage_path`/`url`; `provider_url` bleibt als Fallback erhalten. +- Backfill für Alt-Daten ohne lokale Pfade: + - `php artisan ai-edits:backfill-storage` + +## 6. Provider-Betrieb (Runware) + +### 6.1 Erforderliche ENV/Config + +- `RUNWARE_API_KEY` +- `RUNWARE_BASE_URL` (Default `https://api.runware.ai/v1`) +- `RUNWARE_TIMEOUT` (Sekunden) +- `AI_EDITING_DEFAULT_PROVIDER` (Default `runware`) +- `AI_EDITING_RUNWARE_MODE` (`live` oder `fake`) + +### 6.2 Fake-Mode + +- `runware_mode=fake` liefert synthetische Antworten ohne externen API-Call. +- Geeignet für interne Validierung von Flow/UI/Queue-Logik. +- Nicht für Kosten- oder Qualitätsaussagen nutzen. + +## 7. Monitoring und Alerts + +### 7.1 Wichtige Kennzahlen + +- Request-Statusverteilung je Event (`queued`, `processing`, `succeeded`, `failed`, `blocked`) +- Failure-Rate +- Moderation-Hit-Rate +- Provider-Failure-Rate +- durchschnittliche Provider-Latenz +- Monatsausgaben (`ai_usage_ledgers`) + +Das Tenant-Summary-Endpoint liefert diese Daten inkl. Alert-Flags: +- `observability.alerts.failure_rate_threshold_reached` +- `observability.alerts.latency_threshold_reached` + +### 7.2 Schwellenwerte aus Config + +- `ai-editing.observability.failure_rate_alert_threshold` (Default `0.35`) +- `ai-editing.observability.failure_rate_min_samples` (Default `10`) +- `ai-editing.observability.latency_warning_ms` (Default `15000`) +- Budget-Alert-Cooldown: `ai-editing.billing.budget.alert_cooldown_minutes` (Default `30`) +- Abuse-Eskalation: `ai-editing.abuse.escalation_threshold_per_hour` (Default `25`) + +### 7.3 Relevante Log-Signale + +- `AI provider latency warning` +- `AI failure-rate alert threshold reached` +- `AI budget threshold reached` +- `AI abuse escalation threshold reached` + +## 8. Betriebs-Kommandos + +### 8.1 Stuck Requests analysieren/recovern + +- Dry-run: + - `php artisan ai-edits:recover-stuck --minutes=30` +- Requeue: + - `php artisan ai-edits:recover-stuck --minutes=30 --requeue` +- Hart auf failed setzen: + - `php artisan ai-edits:recover-stuck --minutes=30 --fail` + +### 8.2 Retention-Pruning + +- Dry-run: + - `php artisan ai-edits:prune --pretend` +- Ausführen (mit optionalen Overrides): + - `php artisan ai-edits:prune --request-days=90 --ledger-days=365` + +Retention Defaults: +- Requests: `AI_EDITING_REQUEST_RETENTION_DAYS` (90) +- Ledger: `AI_EDITING_USAGE_LEDGER_RETENTION_DAYS` (365) + +Hinweis: +- `ai-edits:prune` läuft täglich per Scheduler (`02:30`), kann aber bei Bedarf manuell ausgeführt werden. + +### 8.3 Backfill lokaler AI-Outputs + +- Dry-run: + - `php artisan ai-edits:backfill-storage --pretend` +- Ausführen: + - `php artisan ai-edits:backfill-storage --limit=200` +- Für einzelne Request-ID: + - `php artisan ai-edits:backfill-storage --request-id=123` + +## 9. Incident-Playbooks + +### 9.1 Provider-Ausfall / hohe Failure-Rate + +1. `is_enabled=false` als Kill-Switch setzen (optional mit `status_message`). +2. `runware_mode=fake` nur für interne Funktionstests aktivieren. +3. Queue-Backlog prüfen, ggf. `ai-edits:recover-stuck --requeue` nach Stabilisierung. +4. Fehlerraten im Summary pro Event kontrollieren. + +### 9.2 Kostenanstieg / Budget-Hard-Cap + +1. Budget-Alerts (`ai_budget_soft_cap`/`ai_budget_hard_cap`) prüfen. +2. Tenant-Budget in `tenant.settings.ai_editing.budget.*` validieren. +3. Bei Bedarf zeitweises Override `override_until` setzen. +4. Bei Missbrauchsspitzen zusätzlich Abuse-Signale prüfen. + +### 9.3 Safety-/Abuse-Spike + +1. `blocked_terms` nachschärfen. +2. Event-Policy enger setzen (`allow_custom_prompt=false`, `allowed_style_keys` einschränken). +3. Warn-Logs mit `scope_hash`, `event_id`, `tenant_id` korrelieren. + +## 10. Datenschutz und Datenhaltung + +- Keine Secrets in Logs/Docs schreiben (`RUNWARE_API_KEY`, `.env`). +- Prompt-/Negativprompt-Inhalte liegen in `ai_edit_requests`; mit PII vorsichtig umgehen. +- Nur notwendige Aufbewahrungsdauer halten; Pruning regelmäßig durchführen. +- Keine Erweiterung der Retention ohne abgestimmte Privacy-Änderung. + +## 11. Go-Live-Checkliste + +1. Paket-/Addon-Freischaltung verifiziert (`ai_styling`, `ai_styling_unlock`). +2. `RUNWARE_API_KEY` gesetzt und Provider-Erreichbarkeit geprüft. +3. `queue_auto_dispatch=true` und Worker auf korrekter Queue aktiv. +4. Budget-Limits und Alerting pro Tenant geprüft. +5. Safety-Baseline (`blocked_terms`) gesetzt. +6. Recovery- und Prune-Commands in Ops-Routine aufgenommen. diff --git a/resources/js/guest-v2/components/AiMagicEditSheet.tsx b/resources/js/guest-v2/components/AiMagicEditSheet.tsx index a65c5a01..25da7d22 100644 --- a/resources/js/guest-v2/components/AiMagicEditSheet.tsx +++ b/resources/js/guest-v2/components/AiMagicEditSheet.tsx @@ -72,28 +72,39 @@ function resolveOutputUrl(request: GuestAiEditRequest | null): string | null { (output) => output.is_primary && ( - (typeof output.provider_url === 'string' && output.provider_url) + (typeof output.url === 'string' && output.url) || (typeof output.storage_path === 'string' && output.storage_path) + || (typeof output.provider_url === 'string' && output.provider_url) ) ); - if (primary?.provider_url) { - return primary.provider_url; + if (primary?.url) { + return primary.url; } if (primary?.storage_path) { return normalizeStorageUrl(primary.storage_path); } + if (primary?.provider_url) { + return primary.provider_url; + } const first = request.outputs.find( (output) => - (typeof output.provider_url === 'string' && output.provider_url) + (typeof output.url === 'string' && output.url) || (typeof output.storage_path === 'string' && output.storage_path) + || (typeof output.provider_url === 'string' && output.provider_url) ); + if (first?.url) { + return first.url; + } + if (first?.storage_path) { + return normalizeStorageUrl(first.storage_path); + } if (first?.provider_url) { return first.provider_url; } - return normalizeStorageUrl(first?.storage_path); + return null; } export default function AiMagicEditSheet({ diff --git a/resources/js/guest-v2/services/aiEditsApi.ts b/resources/js/guest-v2/services/aiEditsApi.ts index a175d1fc..9a1b1771 100644 --- a/resources/js/guest-v2/services/aiEditsApi.ts +++ b/resources/js/guest-v2/services/aiEditsApi.ts @@ -27,6 +27,7 @@ export type GuestAiEditOutput = { storage_disk?: string | null; storage_path?: string | null; provider_url?: string | null; + url?: string | null; mime_type?: string | null; width?: number | null; height?: number | null; diff --git a/tests/Feature/Api/Event/EventAiEditControllerTest.php b/tests/Feature/Api/Event/EventAiEditControllerTest.php index 847cf11c..7f91452e 100644 --- a/tests/Feature/Api/Event/EventAiEditControllerTest.php +++ b/tests/Feature/Api/Event/EventAiEditControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Api\Event; use App\Models\AiEditingSetting; +use App\Models\AiEditOutput; use App\Models\AiEditRequest; use App\Models\AiStyle; use App\Models\AiUsageLedger; @@ -13,6 +14,7 @@ use App\Models\Package; use App\Models\Photo; use App\Services\EventJoinTokenService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class EventAiEditControllerTest extends TestCase @@ -802,6 +804,67 @@ class EventAiEditControllerTest extends TestCase ->assertJsonPath('error.meta.allowed_style_keys.0', $allowed->key); } + public function test_guest_show_serializes_local_output_url_when_storage_path_is_present(): void + { + $event = Event::factory()->create([ + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $style = AiStyle::query()->create([ + 'key' => 'guest-output-url-style', + 'name' => 'Guest Output URL', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'prompt' => 'Create edit.', + 'idempotency_key' => 'guest-output-url-1', + 'queued_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + $storagePath = 'events/demo-event/ai-edits/output-1.jpg'; + AiEditOutput::query()->create([ + 'request_id' => $request->id, + 'provider_asset_id' => 'guest-output-asset-1', + 'storage_disk' => 'public', + 'storage_path' => $storagePath, + 'provider_url' => 'https://provider.example/output.jpg', + 'mime_type' => 'image/jpeg', + 'is_primary' => true, + 'safety_state' => 'passed', + 'generated_at' => now(), + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest-output-url']) + ->getAttribute('plain_token'); + + $response = $this->withHeaders(['X-Device-Id' => 'guest-device-output-url']) + ->getJson("/api/v1/events/{$token}/ai-edits/{$request->id}"); + + $response->assertOk() + ->assertJsonPath('data.outputs.0.storage_path', $storagePath) + ->assertJsonPath('data.outputs.0.url', Storage::disk('public')->url($storagePath)); + } + private function attachEntitledEventPackage(Event $event): EventPackage { $package = Package::factory()->endcustomer()->create([ diff --git a/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php index 24b76904..cc8e9223 100644 --- a/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php +++ b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Api\Tenant; use App\Models\AiEditingSetting; +use App\Models\AiEditOutput; use App\Models\AiEditRequest; use App\Models\AiStyle; use App\Models\AiUsageLedger; @@ -11,6 +12,7 @@ use App\Models\EventPackage; use App\Models\EventPackageAddon; use App\Models\Package; use App\Models\Photo; +use Illuminate\Support\Facades\Storage; use Tests\Feature\Tenant\TenantTestCase; class TenantAiEditControllerTest extends TenantTestCase @@ -835,6 +837,63 @@ class TenantAiEditControllerTest extends TenantTestCase ->assertJsonPath('data.budget.hard_stop_enabled', true); } + public function test_tenant_show_serializes_local_output_url_when_storage_path_is_present(): void + { + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'approved', + ]); + + $style = AiStyle::query()->create([ + 'key' => 'tenant-output-url-style', + 'name' => 'Tenant Output URL', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $this->tenant->id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'prompt' => 'Create edit.', + 'idempotency_key' => 'tenant-output-url-1', + 'queued_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + $storagePath = 'events/demo-event/ai-edits/output-tenant-1.jpg'; + AiEditOutput::query()->create([ + 'request_id' => $request->id, + 'provider_asset_id' => 'tenant-output-asset-1', + 'storage_disk' => 'public', + 'storage_path' => $storagePath, + 'provider_url' => 'https://provider.example/output.jpg', + 'mime_type' => 'image/jpeg', + 'is_primary' => true, + 'safety_state' => 'passed', + 'generated_at' => now(), + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/{$request->id}"); + + $response->assertOk() + ->assertJsonPath('data.outputs.0.storage_path', $storagePath) + ->assertJsonPath('data.outputs.0.url', Storage::disk('public')->url($storagePath)); + } + private function attachEntitledEventPackage(Event $event): EventPackage { $package = Package::factory()->endcustomer()->create([ diff --git a/tests/Feature/Console/AiEditsBackfillStorageCommandTest.php b/tests/Feature/Console/AiEditsBackfillStorageCommandTest.php new file mode 100644 index 00000000..7ef1fee4 --- /dev/null +++ b/tests/Feature/Console/AiEditsBackfillStorageCommandTest.php @@ -0,0 +1,121 @@ + true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + 'watermark.base.asset' => 'branding/test-watermark.png', + ]); + + Storage::fake('public'); + Storage::disk('public')->put('branding/test-watermark.png', $this->tinyPngBinary()); + + Http::fake([ + 'https://cdn.runware.ai/*' => Http::response($this->tinyPngBinary(), 200, [ + 'Content-Type' => 'image/png', + ]), + ]); + + [$request, $output] = $this->createOutputWithoutStoragePath(); + + $this->artisan('ai-edits:backfill-storage --limit=50') + ->assertExitCode(0); + + $request->refresh(); + $output->refresh(); + + $this->assertNotNull($output->storage_disk); + $this->assertNotNull($output->storage_path); + $this->assertNotSame('', trim((string) $output->storage_path)); + $this->assertTrue(Storage::disk((string) $output->storage_disk)->exists((string) $output->storage_path)); + } + + public function test_it_supports_pretend_mode_without_writing_changes(): void + { + config([ + 'ai-editing.outputs.enabled' => true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + ]); + + [, $output] = $this->createOutputWithoutStoragePath(); + + $this->artisan('ai-edits:backfill-storage --pretend --limit=10') + ->assertExitCode(0); + + $output->refresh(); + + $this->assertNull($output->storage_disk); + $this->assertNull($output->storage_path); + } + + /** + * @return array{0: AiEditRequest, 1: AiEditOutput} + */ + private function createOutputWithoutStoragePath(): array + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'backfill-style', + 'name' => 'Backfill Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'backfill-request-1', + 'queued_at' => now()->subMinutes(2), + 'started_at' => now()->subMinute(), + 'completed_at' => now()->subSeconds(20), + ]); + + $output = AiEditOutput::query()->create([ + 'request_id' => $request->id, + 'provider_asset_id' => 'backfill-asset-1', + 'provider_url' => 'https://cdn.runware.ai/outputs/backfill-image.png', + 'is_primary' => true, + 'safety_state' => 'passed', + 'generated_at' => now(), + ]); + + return [$request, $output]; + } + + private function tinyPngBinary(): string + { + return (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+XnV0AAAAASUVORK5CYII='); + } +} diff --git a/tests/Feature/Jobs/PollAiEditRequestTest.php b/tests/Feature/Jobs/PollAiEditRequestTest.php index 5bf59092..e29feb7a 100644 --- a/tests/Feature/Jobs/PollAiEditRequestTest.php +++ b/tests/Feature/Jobs/PollAiEditRequestTest.php @@ -12,6 +12,8 @@ use App\Models\Photo; use App\Services\AiEditing\AiProviderResult; use App\Services\AiEditing\Providers\RunwareAiImageProvider; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use RuntimeException; use Tests\TestCase; @@ -24,6 +26,7 @@ class PollAiEditRequestTest extends TestCase parent::setUp(); AiEditingSetting::flushCache(); + config(['ai-editing.outputs.enabled' => false]); } public function test_it_marks_request_failed_when_poll_attempts_are_exhausted(): void @@ -139,4 +142,91 @@ class PollAiEditRequestTest extends TestCase $this->assertSame('Polling crashed', $request->failure_message); $this->assertNotNull($request->completed_at); } + + public function test_it_persists_polled_output_to_local_storage_when_enabled(): void + { + config([ + 'ai-editing.outputs.enabled' => true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + 'watermark.base.asset' => 'branding/test-watermark.png', + ]); + + Storage::fake('public'); + Storage::disk('public')->put('branding/test-watermark.png', $this->tinyPngBinary()); + + AiEditingSetting::query()->create(array_merge( + AiEditingSetting::defaults(), + [ + 'runware_mode' => 'live', + 'queue_auto_dispatch' => false, + 'queue_max_polls' => 3, + ] + )); + + Http::fake([ + 'https://cdn.runware.ai/*' => Http::response($this->tinyPngBinary(), 200, [ + 'Content-Type' => 'image/png', + ]), + ]); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'poll-storage-style', + 'name' => 'Poll Storage', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'poll-storage-1', + 'queued_at' => now()->subMinutes(2), + 'started_at' => now()->subMinute(), + ]); + + $provider = \Mockery::mock(RunwareAiImageProvider::class); + $provider->shouldReceive('poll') + ->once() + ->withArgs(function (AiEditRequest $polledRequest, string $taskId): bool { + return $polledRequest->id > 0 && $taskId === 'runware-task-storage'; + }) + ->andReturn(AiProviderResult::succeeded(outputs: [[ + 'provider_url' => 'https://cdn.runware.ai/outputs/poll-image.png', + 'provider_asset_id' => 'poll-asset-123', + 'mime_type' => 'image/png', + ]])); + $this->app->instance(RunwareAiImageProvider::class, $provider); + + PollAiEditRequest::dispatchSync($request->id, 'runware-task-storage', 1); + + $request->refresh(); + $output = $request->outputs()->first(); + + $this->assertSame(AiEditRequest::STATUS_SUCCEEDED, $request->status); + $this->assertNotNull($output); + $this->assertNotNull($output?->storage_disk); + $this->assertNotNull($output?->storage_path); + $this->assertNotSame('', trim((string) $output?->storage_path)); + $this->assertTrue(Storage::disk((string) $output?->storage_disk)->exists((string) $output?->storage_path)); + } + + private function tinyPngBinary(): string + { + return (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+XnV0AAAAASUVORK5CYII='); + } } diff --git a/tests/Feature/Jobs/ProcessAiEditRequestTest.php b/tests/Feature/Jobs/ProcessAiEditRequestTest.php index 25fc5363..c67ef4cd 100644 --- a/tests/Feature/Jobs/ProcessAiEditRequestTest.php +++ b/tests/Feature/Jobs/ProcessAiEditRequestTest.php @@ -12,6 +12,8 @@ use App\Models\Photo; use App\Services\AiEditing\AiProviderResult; use App\Services\AiEditing\Providers\RunwareAiImageProvider; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use RuntimeException; use Tests\TestCase; @@ -24,6 +26,7 @@ class ProcessAiEditRequestTest extends TestCase parent::setUp(); AiEditingSetting::flushCache(); + config(['ai-editing.outputs.enabled' => false]); } public function test_it_processes_ai_edit_request_with_fake_runware_provider(): void @@ -292,4 +295,87 @@ class ProcessAiEditRequestTest extends TestCase $this->assertSame('Queue worker timeout', $request->failure_message); $this->assertNotNull($request->completed_at); } + + public function test_it_persists_provider_output_to_local_storage_when_enabled(): void + { + config([ + 'ai-editing.outputs.enabled' => true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + 'watermark.base.asset' => 'branding/test-watermark.png', + ]); + + Storage::fake('public'); + Storage::disk('public')->put('branding/test-watermark.png', $this->tinyPngBinary()); + + AiEditingSetting::query()->create(array_merge( + AiEditingSetting::defaults(), + [ + 'runware_mode' => 'live', + 'queue_auto_dispatch' => false, + ] + )); + + Http::fake([ + 'https://cdn.runware.ai/*' => Http::response($this->tinyPngBinary(), 200, [ + 'Content-Type' => 'image/png', + ]), + ]); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $style = AiStyle::query()->create([ + 'key' => 'storage-style', + 'name' => 'Storage Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_QUEUED, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'job-storage-enabled-1', + 'queued_at' => now(), + ]); + + $provider = \Mockery::mock(RunwareAiImageProvider::class); + $provider->shouldReceive('submit') + ->once() + ->andReturn(AiProviderResult::succeeded(outputs: [[ + 'provider_url' => 'https://cdn.runware.ai/outputs/final-image.png', + 'provider_asset_id' => 'asset-123', + 'mime_type' => 'image/png', + ]])); + $this->app->instance(RunwareAiImageProvider::class, $provider); + + ProcessAiEditRequest::dispatchSync($request->id); + + $request->refresh(); + $output = $request->outputs()->first(); + + $this->assertSame(AiEditRequest::STATUS_SUCCEEDED, $request->status); + $this->assertNotNull($output); + $this->assertNotNull($output?->storage_disk); + $this->assertNotNull($output?->storage_path); + $this->assertNotSame('', trim((string) $output?->storage_path)); + $this->assertTrue(Storage::disk((string) $output?->storage_disk)->exists((string) $output?->storage_path)); + } + + private function tinyPngBinary(): string + { + return (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+XnV0AAAAASUVORK5CYII='); + } } diff --git a/tests/Unit/Services/AiEditOutputStorageServiceTest.php b/tests/Unit/Services/AiEditOutputStorageServiceTest.php new file mode 100644 index 00000000..68ab3e71 --- /dev/null +++ b/tests/Unit/Services/AiEditOutputStorageServiceTest.php @@ -0,0 +1,132 @@ + true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + 'watermark.base.asset' => 'branding/test-watermark.png', + ]); + + Storage::fake('public'); + Storage::disk('public')->put('branding/test-watermark.png', $this->tinyPngBinary()); + + Http::fake([ + 'https://cdn.runware.ai/*' => Http::response($this->tinyPngBinary(), 200, [ + 'Content-Type' => 'image/png', + ]), + ]); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'persist-style', + 'name' => 'Persist Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'storage-service-1', + 'queued_at' => now()->subMinutes(2), + 'started_at' => now()->subMinute(), + ]); + + $service = app(AiEditOutputStorageService::class); + $persisted = $service->persist($request, [ + 'provider_url' => 'https://cdn.runware.ai/outputs/image-1.png', + 'provider_asset_id' => 'asset-storage-1', + 'mime_type' => 'image/png', + ]); + + $this->assertSame('public', $persisted['storage_disk']); + $this->assertNotNull($persisted['storage_path']); + $this->assertNotSame('', trim((string) $persisted['storage_path'])); + $this->assertTrue(Storage::disk('public')->exists((string) $persisted['storage_path'])); + $this->assertIsArray($persisted['metadata']); + $this->assertIsArray($persisted['metadata']['storage'] ?? null); + } + + public function test_it_records_storage_failure_for_blocked_output_host(): void + { + config([ + 'ai-editing.outputs.enabled' => true, + 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], + 'filesystems.default' => 'public', + ]); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'blocked-host-style', + 'name' => 'Blocked Host', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'storage-service-blocked-host', + 'queued_at' => now()->subMinutes(2), + 'started_at' => now()->subMinute(), + ]); + + $service = app(AiEditOutputStorageService::class); + $persisted = $service->persist($request, [ + 'provider_url' => 'https://example.invalid/fake-image.png', + 'provider_asset_id' => 'asset-storage-blocked', + ]); + + $this->assertNull($persisted['storage_path']); + $this->assertIsArray($persisted['metadata']); + $this->assertTrue((bool) ($persisted['metadata']['storage']['failed'] ?? false)); + $this->assertSame('output_storage_failed', $persisted['metadata']['storage']['error_code'] ?? null); + } + + private function tinyPngBinary(): string + { + return (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+XnV0AAAAASUVORK5CYII='); + } +}