$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; } }