From 011cc1f3af4beba51bd01af2efd345bb7245c874 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 18:16:53 +0100 Subject: [PATCH 01/10] docs(project): add Azure Blob media implementation plan --- docs/project/2026-10-25-azure-blob-media-plan.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/project/2026-10-25-azure-blob-media-plan.md diff --git a/docs/project/2026-10-25-azure-blob-media-plan.md b/docs/project/2026-10-25-azure-blob-media-plan.md new file mode 100644 index 0000000000..81ef4550bb --- /dev/null +++ b/docs/project/2026-10-25-azure-blob-media-plan.md @@ -0,0 +1,7 @@ +# Azure Blob media support + +Add an optional private Azure Blob disk while preserving the existing S3 and local disks. Physical blob names are flat; a reversible adapter preserves logical paths. Media identity is a stable path or ID. Expiring read URLs are created for authorized consumers rather than persisted in media JSON. Use workload identity and user-delegation SAS, with account keys disabled. + +Implement and verify flat filesystem behavior, bounded block uploads, URL authorization/freshness, canonical serialization and credential redaction. Missing owned attachments fail publication before any provider call. Reuse existing media ownership and publication checkpoints. No database migration or existing-media transfer is included. + +The source package ends with reviewed, CI-checked draft delivery. Entra/RBAC/SAS acceptance requires a separate synthetic Azure qualification; emulator/fake tests cannot prove it. Provisioning, release, deployment, publishing and data deletion require separate approval. From d33205ce77ec60be3a8050eeb9eeaba1ee8483b2 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 18:49:12 +0100 Subject: [PATCH 02/10] enhance(media): support private Azure Blob storage --- app/Casts/StoredMedia.php | 28 + app/Dto/MediaItem.php | 7 +- .../Controllers/App/MediaFileController.php | 11 + app/Http/Resources/Api/IdeaResource.php | 2 +- app/Http/Resources/Api/PostResource.php | 4 +- app/Http/Resources/Api/UploadResource.php | 2 +- app/Jobs/PublishToSocialPlatform.php | 49 +- app/Models/Idea.php | 2 +- app/Models/Media.php | 2 +- app/Models/Post.php | 6 +- app/Models/SocialAccount.php | 2 +- app/Providers/AppServiceProvider.php | 59 ++ app/Services/Media/AzureBlockUploader.php | 348 ++++++++++++ app/Services/Media/ChunkedCloudUploader.php | 28 +- .../Social/AbstractLinkedInPublisher.php | 6 +- app/Services/Social/BlueskyPublisher.php | 4 +- .../Social/Concerns/FitsImageToCanvas.php | 2 +- .../Social/Discord/DiscordPublisher.php | 2 +- app/Services/Social/FacebookPublisher.php | 10 +- .../Social/GoogleBusinessPublisher.php | 8 +- app/Services/Social/InstagramPublisher.php | 12 +- app/Services/Social/MastodonPublisher.php | 2 +- app/Services/Social/PinterestPublisher.php | 8 +- .../Social/Telegram/TelegramPublisher.php | 2 +- app/Services/Social/ThreadsPublisher.php | 8 +- app/Services/Social/TikTokPublisher.php | 8 +- app/Services/Social/XPublisher.php | 2 +- app/Services/Social/YouTubePublisher.php | 2 +- app/Support/Media/AzureUserDelegationSas.php | 530 ++++++++++++++++++ app/Support/Media/FlatBlobAdapter.php | 288 ++++++++++ app/Support/Media/MediaUrl.php | 151 +++++ .../Media/RedactStorageCredentials.php | 48 ++ composer.json | 2 + composer.lock | 263 ++++++++- config/filesystems.php | 10 + config/logging.php | 9 + routes/app.php | 1 + tests/Browser/MediaEditorTest.php | 28 + tests/Feature/ChunkedCloudUploadTest.php | 240 +++++++- ...ublishToSocialPlatformMediaRecheckTest.php | 18 + tests/Feature/Media/AzureBlobStorageTest.php | 132 +++++ .../Media/AzureUserDelegationSasTest.php | 352 ++++++++++++ .../Support/Media/FlatBlobAdapterTest.php | 221 ++++++++ .../media/generate_user_delegation_sas.py | 110 ++++ tests/fixtures/media/user-delegation-sas.json | 48 ++ 45 files changed, 3020 insertions(+), 57 deletions(-) create mode 100644 app/Casts/StoredMedia.php create mode 100644 app/Services/Media/AzureBlockUploader.php create mode 100644 app/Support/Media/AzureUserDelegationSas.php create mode 100644 app/Support/Media/FlatBlobAdapter.php create mode 100644 app/Support/Media/MediaUrl.php create mode 100644 app/Support/Media/RedactStorageCredentials.php create mode 100644 tests/Feature/Media/AzureBlobStorageTest.php create mode 100644 tests/Unit/Support/Media/AzureUserDelegationSasTest.php create mode 100644 tests/Unit/Support/Media/FlatBlobAdapterTest.php create mode 100644 tests/fixtures/media/generate_user_delegation_sas.py create mode 100644 tests/fixtures/media/user-delegation-sas.json diff --git a/app/Casts/StoredMedia.php b/app/Casts/StoredMedia.php new file mode 100644 index 0000000000..e39acf9456 --- /dev/null +++ b/app/Casts/StoredMedia.php @@ -0,0 +1,28 @@ + */ +final class StoredMedia implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + if ($value === null) { + return null; + } + $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + + return $key === 'meta' ? MediaUrl::meta($decoded) : MediaUrl::canonicalItems($decoded); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + return $value === null ? null : json_encode($key === 'meta' ? MediaUrl::meta($value) : MediaUrl::canonicalItems($value), JSON_THROW_ON_ERROR); + } +} diff --git a/app/Dto/MediaItem.php b/app/Dto/MediaItem.php index 1c8257e8b7..6ad9b5f6f4 100644 --- a/app/Dto/MediaItem.php +++ b/app/Dto/MediaItem.php @@ -64,7 +64,7 @@ public function toArray(): array return [ 'id' => $this->id, 'path' => $this->path, - 'url' => $this->url, + 'url' => \App\Support\Media\MediaUrl::verifiesStorage() ? \App\Support\Media\MediaUrl::canonical($this->path, $this->id) : $this->url, 'type' => $this->type?->value, 'mime_type' => $this->mime_type, 'original_filename' => $this->original_filename, @@ -82,6 +82,11 @@ public function isVideo(): bool return $this->kind() === Type::Video; } + public function publishUrl(): string + { + return \App\Support\Media\MediaUrl::publishing($this->path, $this->url); + } + public function isImage(): bool { return $this->kind() === Type::Image; diff --git a/app/Http/Controllers/App/MediaFileController.php b/app/Http/Controllers/App/MediaFileController.php index 252e1d1ea2..dfbfd9dd6d 100644 --- a/app/Http/Controllers/App/MediaFileController.php +++ b/app/Http/Controllers/App/MediaFileController.php @@ -19,6 +19,17 @@ */ class MediaFileController extends Controller { + public function view(Request $request, Media $media): \Illuminate\Http\RedirectResponse + { + abort_unless($media->workspace !== null, 404); + abort_unless($request->user()->can('view', $media->workspace), 404); + + return redirect()->away(\App\Support\Media\MediaUrl::temporary($media->path))->withHeaders([ + 'Cache-Control' => 'private, no-store', + 'Referrer-Policy' => 'no-referrer', + ]); + } + public function show(Request $request, Media $media): StreamedResponse { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Resources/Api/IdeaResource.php b/app/Http/Resources/Api/IdeaResource.php index 04ea4a9553..e7af56ac52 100644 --- a/app/Http/Resources/Api/IdeaResource.php +++ b/app/Http/Resources/Api/IdeaResource.php @@ -24,7 +24,7 @@ public function toArray(Request $request): array 'title' => $this->title, 'body' => $this->body, 'position' => $this->position, - 'media' => $this->media ?? [], + 'media' => \App\Support\Media\MediaUrl::apiItems($this->media ?? [], $this->workspace_id), 'labels' => LabelResource::collection($this->whenLoaded('labels')), 'created_at' => $this->created_at->format('Y-m-d H:i:s'), 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php index 9bb12ca671..3da50712e7 100644 --- a/app/Http/Resources/Api/PostResource.php +++ b/app/Http/Resources/Api/PostResource.php @@ -27,7 +27,7 @@ public function toArray(Request $request): array 'post_group_id' => $this->post_group_id, 'author' => $this->person($this->user), 'content' => $this->content, - 'media' => $this->media, + 'media' => \App\Support\Media\MediaUrl::apiItems($this->media ?? [], $this->workspace_id), 'status' => $this->status?->value, 'schedule_mode' => $this->schedule_mode?->value, 'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'), @@ -45,7 +45,7 @@ public function toArray(Request $request): array 'publish_status' => $this->publish_status->value, 'platform' => $this->platform?->value, 'content_type' => $this->content_type?->value, - 'meta' => $this->meta, + 'meta' => $this->meta === null ? null : \App\Support\Media\MediaUrl::meta($this->meta, true, $this->workspace_id), 'platform_url' => $this->platform_url, 'error_message' => $this->error_message, 'display_name' => $this->display_name, diff --git a/app/Http/Resources/Api/UploadResource.php b/app/Http/Resources/Api/UploadResource.php index c1a9507e25..44097c2a5e 100644 --- a/app/Http/Resources/Api/UploadResource.php +++ b/app/Http/Resources/Api/UploadResource.php @@ -27,7 +27,7 @@ public function toArray(Request $request): array 'mime_type' => $this->mime_type, 'size' => $this->size, 'original_filename' => $this->original_filename, - 'url' => $this->url, + 'url' => \App\Support\Media\MediaUrl::verifiesStorage() ? \App\Support\Media\MediaUrl::temporary($this->path) : $this->url, 'expires_at' => $this->created_at->copy()->addHours((int) config('trypost.media.upload_retention_hours'))->toIso8601String(), ]; } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 90110b27bc..ef9a370912 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -115,6 +115,10 @@ public function handle(): void return; } + if ($this->failForMissingStoredMedia()) { + return; + } + if ($this->failForInvalidMedia()) { return; } @@ -251,6 +255,46 @@ private function failForMissingScopes(): bool return true; } + private function failForMissingStoredMedia(): bool + { + if (! \App\Support\Media\MediaUrl::verifiesStorage()) { + return false; + } + + $context = $this->post->error_context; + if (PublishCheckpoint::instagramWorkflow($context) !== null || PublishCheckpoint::tiktokPublishId($context) !== null) { + return false; + } + + $items = [...($this->post->media ?? [])]; + foreach (ThreadReplies::of($this->post->meta) as $reply) { + array_push($items, ...$reply['media']); + } + + try { + foreach ($items as $item) { + $path = data_get($item, 'path'); + if (is_string($path) && $path !== '' && ! \App\Support\Media\MediaUrl::exists($path)) { + $this->failAndFinalize('A media attachment is unavailable.', $this->failureContext([ + 'reason' => 'media_missing', + 'category' => ErrorCategory::MediaFormat->value, + ])); + + return true; + } + } + } catch (\Throwable) { + $this->failAndFinalize('Media storage is unavailable.', $this->failureContext([ + 'reason' => 'media_unavailable', + 'category' => ErrorCategory::MediaFormat->value, + ])); + + return true; + } + + return false; + } + /** * Media can become invalid after scheduling (bounds changed in a release, * an edit through another path): recheck it against the same rule every @@ -310,6 +354,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void ...($hasOwnPolicy ? ['max_retries' => $maxRetries, 'retry_delay_seconds' => $retryDelaySeconds] : []), 'detail' => $e->getMessage(), ]; + $context = \App\Support\Media\RedactStorageCredentials::clean($context); if ($retryCount > $maxRetries) { $this->reportCaughtPublishFailure($e, [ @@ -513,8 +558,8 @@ private function markPlatformAsFailed(string $message, ?array $context = null): $failureContext = [...$previousContext, ...($context ?? [])]; $this->post->markPublicationFailed( - ThreadProgress::failureMessage($this->post, $message, $failureContext), - $failureContext === [] ? null : $failureContext, + \App\Support\Media\RedactStorageCredentials::clean(ThreadProgress::failureMessage($this->post, $message, $failureContext)), + $failureContext === [] ? null : \App\Support\Media\RedactStorageCredentials::clean($failureContext), ); } diff --git a/app/Models/Idea.php b/app/Models/Idea.php index e7bf1172a6..86f08315f2 100644 --- a/app/Models/Idea.php +++ b/app/Models/Idea.php @@ -40,7 +40,7 @@ class Idea extends Model protected function casts(): array { return [ - 'media' => 'array', + 'media' => \App\Casts\StoredMedia::class, 'position' => 'integer', ]; } diff --git a/app/Models/Media.php b/app/Models/Media.php index e93c2dfc4a..4e22e28b39 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -164,7 +164,7 @@ public function issueUploadToken(): void protected function url(): Attribute { return Attribute::make( - get: fn () => Storage::url($this->path), + get: fn () => \App\Support\Media\MediaUrl::browser($this), ); } diff --git a/app/Models/Post.php b/app/Models/Post.php index bee8c9e20e..fae4b09b4a 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -115,7 +115,7 @@ protected function casts(): array 'recurrence_origin_at' => 'datetime', 'created_via' => CreatedVia::class, 'origin' => Origin::class, - 'media' => 'array', + 'media' => \App\Casts\StoredMedia::class, 'scheduled_at' => 'datetime', 'published_at' => 'datetime', 'approval_requested_at' => 'datetime', @@ -124,7 +124,7 @@ protected function casts(): array 'publish_status' => PublishStatus::class, 'platform' => Platform::class, 'content_type' => ContentType::class, - 'meta' => 'array', + 'meta' => \App\Casts\StoredMedia::class, 'error_context' => 'array', 'thread_reply_ids' => 'array', 'submitted_at' => 'datetime', @@ -460,7 +460,7 @@ protected function displayAvatar(): Attribute { return Attribute::make( get: fn (): ?string => $this->socialAccount?->avatar_url - ?: ($this->platform_avatar ? Storage::url($this->platform_avatar) : null), + ?: \App\Support\Media\MediaUrl::avatar($this->platform_avatar), ); } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index e74c1edfe1..07dcfa3d9d 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -322,7 +322,7 @@ public function needsProactiveTokenRefresh(): bool protected function avatarUrl(): Attribute { return Attribute::make( - get: fn (?string $value) => $value ? Storage::url($value) : null, + get: fn (?string $value) => \App\Support\Media\MediaUrl::avatar($value), ); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 67c7db6ec2..bc6dc323ab 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -40,12 +40,23 @@ use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; use App\Support\Auth\PasswordRequirements; +use App\Support\Media\AzureUserDelegationSas; +use App\Support\Media\FlatBlobAdapter; +use AzureOss\Identity\TokenCredential; +use AzureOss\Identity\WorkloadIdentityCredential; +use AzureOss\Storage\Blob\BlobContainerClient; +use AzureOss\Storage\Blob\Models\BlobContainerClientOptions; +use AzureOss\Storage\BlobFlysystem\AzureBlobStorageAdapter; +use AzureOss\Storage\Common\ApiVersion; +use AzureOss\Storage\Common\Middleware\HttpClientOptions; use Carbon\CarbonImmutable; +use GuzzleHttp\Psr7\Uri; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Notifications\Messages\MailMessage; @@ -53,6 +64,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Cashier\Cashier; @@ -61,6 +73,7 @@ use Laravel\Nightwatch\Records\CacheEvent; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\GoogleProvider; +use League\Flysystem\Filesystem; use PostHog\PostHog; use SocialiteProviders\Facebook\FacebookExtendSocialite; use SocialiteProviders\LinkedIn\LinkedInExtendSocialite; @@ -75,6 +88,29 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->singleton(TokenCredential::class, fn () => new WorkloadIdentityCredential); + $this->app->singleton(AzureUserDelegationSas::class, fn ($app) => new AzureUserDelegationSas( + $app->make(TokenCredential::class), + config('filesystems.disks.azure.account_name'), + config('filesystems.disks.azure.container'), + )); + $this->app->singleton(BlobContainerClient::class, function ($app) { + $config = config('filesystems.disks.azure'); + if (! preg_match('/^[a-z0-9]{3,24}$/D', (string) $config['account_name']) + || ! preg_match('/^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$/D', (string) $config['container'])) { + throw new \RuntimeException('Invalid Azure media storage configuration.'); + } + + return new BlobContainerClient( + new Uri('https://'.$config['account_name'].'.blob.core.windows.net/'.$config['container']), + $app->make(TokenCredential::class), + new BlobContainerClientOptions( + new HttpClientOptions(timeout: 120, connectTimeout: 10), + ApiVersion::V2024_08_04, + ), + ); + }); + if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) { $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class); @@ -92,6 +128,7 @@ public function boot(): void $this->configureRateLimiting(); $this->configureSocialite(); $this->configureStripeWebhooks(); + $this->configureAzureStorage(); Cashier::useCustomerModel(Account::class); Cashier::useSubscriptionModel(Subscription::class); @@ -99,6 +136,28 @@ public function boot(): void Cashier::keepPastDueSubscriptionsActive(); } + private function configureAzureStorage(): void + { + Storage::extend('azure-flat', function ($app, array $config) { + $adapter = new FlatBlobAdapter( + new AzureBlobStorageAdapter( + $app->make(BlobContainerClient::class), + visibilityHandling: AzureBlobStorageAdapter::ON_VISIBILITY_IGNORE, + ), + ); + $disk = new FilesystemAdapter(new Filesystem($adapter, [ + 'initialTransferSize' => 8 * 1024 * 1024, + 'maximumTransferSize' => 8 * 1024 * 1024, + 'maximumConcurrency' => 1, + ]), $adapter, $config); + $disk->buildTemporaryUrlsUsing(fn ($path, $expiry) => $app->make(AzureUserDelegationSas::class)->url( + FlatBlobAdapter::encodePath($path), $expiry, + )); + + return $disk; + }); + } + protected function configureMorphMap(): void { Relation::enforceMorphMap([ diff --git a/app/Services/Media/AzureBlockUploader.php b/app/Services/Media/AzureBlockUploader.php new file mode 100644 index 0000000000..1a747def87 --- /dev/null +++ b/app/Services/Media/AzureBlockUploader.php @@ -0,0 +1,348 @@ +encodePath = $encodePath; + $this->httpClientFactory = $httpClientFactory; + } + + /** + * @return array{done: bool, progress: int, path?: string, size?: int, mime_type?: string} + */ + public function receiveChunk( + string $identifier, + string $fileName, + string $chunk, + int $rangeStart, + int $rangeEnd, + int $totalSize, + ): array { + $cacheKey = self::CACHE_PREFIX.$identifier; + + return $this->withSessionLock( + $cacheKey, + fn (): array => $this->receiveChunkLocked( + $cacheKey, + $fileName, + $chunk, + $rangeStart, + $rangeEnd, + $totalSize, + ), + ); + } + + public function readRange(string $key, int $offset, int $length): string + { + if ($offset < 0 || $length < 1) { + throw new InvalidArgumentException('The requested byte range is invalid.'); + } + + $client = $this->container->getBlobClient($this->encodedBlobName($key)); + + return $this->downloadRange($client, $offset, $length); + } + + /** + * @return array{done: bool, progress: int, path?: string, size?: int, mime_type?: string} + */ + private function receiveChunkLocked( + string $cacheKey, + string $fileName, + string $chunk, + int $rangeStart, + int $rangeEnd, + int $totalSize, + ): array { + $chunkSize = strlen($chunk); + $isLastChunk = ($rangeEnd + 1) >= $totalSize; + + if ( + $totalSize < 1 + || $rangeStart < 0 + || $rangeEnd < $rangeStart + || $rangeEnd >= $totalSize + || $chunkSize !== ($rangeEnd - $rangeStart + 1) + ) { + throw new InvalidArgumentException('Chunk bytes do not match Content-Range.'); + } + + if (! $isLastChunk && $chunkSize < ChunkedCloudUploader::MIN_PART_BYTES) { + throw new InvalidArgumentException( + 'Non-final multipart parts must be at least '.ChunkedCloudUploader::MIN_PART_BYTES.' bytes.' + ); + } + + $storedState = $this->cache->get($cacheKey); + $state = is_array($storedState) ? $storedState : null; + + if ($state !== null && (int) data_get($state, 'total_size') !== $totalSize) { + throw new InvalidArgumentException('Chunk bytes do not match the upload size.'); + } + + if ($rangeStart === 0) { + if ($state === null) { + $state = $this->startUpload($fileName, $chunk, $totalSize); + $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); + } + } elseif ($state === null) { + throw new RuntimeException('Chunked upload session expired or missing.'); + } + + $nextOffset = (int) data_get($state, 'next_offset', 0); + + if ($rangeStart < $nextOffset) { + if ($rangeEnd >= $nextOffset) { + throw new InvalidArgumentException('Chunk overlaps already accepted bytes.'); + } + + return $this->status($state, completed: $nextOffset >= $totalSize); + } + + if ($rangeStart !== $nextOffset) { + throw new InvalidArgumentException('Chunk is out of order.'); + } + + $blockNumber = count(data_get($state, 'block_ids', [])) + 1; + + if ($blockNumber > 99_999_999) { + throw new InvalidArgumentException('The upload has too many parts.'); + } + + $blockId = $this->blockId($blockNumber); + $client = $this->container->getBlockBlobClient((string) data_get($state, 'blob_name')); + + $this->stageBlock($client, $blockId, $chunk); + + $state['block_ids'][] = $blockId; + $state['next_offset'] = $rangeEnd + 1; + $state['bytes_received'] = (int) data_get($state, 'bytes_received', 0) + $chunkSize; + + if (! $isLastChunk) { + $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); + + return $this->status($state, completed: false); + } + + $mimeType = $this->detectMimeType( + (string) data_get($state, 'header'), + MediaType::extensionOf($fileName), + ); + + $this->commitBlocks($client, data_get($state, 'block_ids'), $mimeType); + $state['mime_type'] = $mimeType; + $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); + + return $this->status($state, completed: true); + } + + /** + * @return array{ + * path: string, + * blob_name: string, + * mime_type: string, + * header: string, + * block_ids: array, + * next_offset: int, + * bytes_received: int, + * total_size: int + * } + */ + private function startUpload(string $fileName, string $chunk, int $totalSize): array + { + $extension = MediaType::extensionOf($fileName); + $suffix = $extension === '' ? '' : ".{$extension}"; + $path = 'medias/'.Str::uuid().$suffix; + $header = substr($chunk, 0, self::MIME_SNIFF_BYTES); + + return [ + 'path' => $path, + 'blob_name' => $this->encodedBlobName($path), + 'mime_type' => $this->detectMimeType($header, $extension), + 'header' => $header, + 'block_ids' => [], + 'next_offset' => 0, + 'bytes_received' => 0, + 'total_size' => $totalSize, + ]; + } + + private function blockId(int $blockNumber): string + { + return base64_encode(sprintf('%08d', $blockNumber)); + } + + /** + * @param array $state + * @return array{done: bool, progress: int, path?: string, size?: int, mime_type?: string} + */ + private function status(array $state, bool $completed): array + { + $totalSize = (int) data_get($state, 'total_size', 0); + $received = (int) data_get($state, 'bytes_received', 0); + $progress = $totalSize > 0 + ? (int) round(min($received, $totalSize) / $totalSize * 100) + : 0; + + if (! $completed) { + return [ + 'done' => false, + 'progress' => $progress, + ]; + } + + return [ + 'done' => true, + 'progress' => 100, + 'path' => (string) data_get($state, 'path'), + 'size' => $received, + 'mime_type' => (string) data_get($state, 'mime_type'), + ]; + } + + private function detectMimeType(string $header, string $extension): string + { + $detected = (new finfo(FILEINFO_MIME_TYPE))->buffer($header) ?: null; + + if (is_string($detected) && $detected !== 'application/octet-stream') { + return $detected; + } + + return MediaType::mimeTypeFromExtension($extension) ?? 'application/octet-stream'; + } + + private function encodedBlobName(string $path): string + { + return $this->encodePath === null + ? FlatBlobAdapter::encodePath($path) + : ($this->encodePath)($path); + } + + /** + * @template T + * + * @param Closure(): T $callback + * @return T + */ + private function withSessionLock(string $cacheKey, Closure $callback): mixed + { + if (! $this->cache instanceof Repository || ! $this->cache->getStore() instanceof LockProvider) { + return $callback(); + } + + return $this->cache->lock($cacheKey.':lock', 30)->block(10, $callback); + } + + protected function stageBlock(BlockBlobClient $client, string $blockId, string $chunk): void + { + $client->stageBlock($blockId, $chunk); + } + + /** + * @param array $blockIds + */ + protected function commitBlocks(BlockBlobClient $client, array $blockIds, string $mimeType): void + { + $client->commitBlockList($blockIds, new CommitBlockListOptions( + httpHeaders: new BlobHttpHeaders(contentType: $mimeType), + )); + } + + private function downloadRange( + BlobClient $client, + int $offset, + int $length, + ): string { + $lastByte = $offset + $length - 1; + $response = $this->httpClient($client)->get($client->uri, [ + RequestOptions::HEADERS => [ + 'Range' => "bytes={$offset}-{$lastByte}", + ], + RequestOptions::HTTP_ERRORS => true, + RequestOptions::STREAM => true, + ]); + + $contentRange = $response->getHeaderLine('Content-Range'); + + if ( + $response->getStatusCode() !== 206 + || preg_match('~^bytes\s+'.preg_quote("{$offset}-{$lastByte}", '~').'/\d+$~i', $contentRange) !== 1 + ) { + throw new RuntimeException('Azure stored object returned an invalid byte range.'); + } + + $stream = $response->getBody(); + $contents = ''; + + while (strlen($contents) <= $length && ! $stream->eof()) { + $next = $stream->read(min(8192, $length + 1 - strlen($contents))); + + if ($next === '') { + break; + } + + $contents .= $next; + } + + if (strlen($contents) !== $length) { + throw new RuntimeException('Azure stored object returned an invalid byte range.'); + } + + return $contents; + } + + private function httpClient(BlobClient $client): ClientInterface + { + if ($this->httpClientFactory !== null) { + return ($this->httpClientFactory)($client); + } + + // The installed SDK has no ranged download option, so reuse its authenticated transport. + return (new ClientFactory)->create($client->uri, $client->credential); + } +} diff --git a/app/Services/Media/ChunkedCloudUploader.php b/app/Services/Media/ChunkedCloudUploader.php index e632132acd..1acb616196 100644 --- a/app/Services/Media/ChunkedCloudUploader.php +++ b/app/Services/Media/ChunkedCloudUploader.php @@ -29,6 +29,7 @@ public function __construct( private readonly ?S3Client $client = null, private readonly ?string $bucket = null, private readonly ?string $disk = null, + private readonly ?AzureBlockUploader $azure = null, ) {} /** @@ -47,11 +48,15 @@ public function shouldUseMultipart(string $fileName, ?string $disk = null): bool public function isObjectStorageDisk(?string $disk = null): bool { - return $this->diskOption('driver', $disk) === 's3'; + return in_array($this->diskOption('driver', $disk), ['s3', 'azure-flat'], true); } public function readRange(string $key, int $offset, int $length): string { + if ($this->usesAzure()) { + return $this->azure()->readRange($key, $offset, $length); + } + $lastByte = $offset + $length - 1; $object = $this->s3()->getObject([ @@ -74,6 +79,17 @@ public function receiveChunk( int $rangeEnd, int $totalSize, ): array { + if ($this->usesAzure()) { + return $this->azure()->receiveChunk( + $identifier, + $fileName, + $chunk, + $rangeStart, + $rangeEnd, + $totalSize, + ); + } + $cacheKey = self::CACHE_PREFIX.$identifier; $chunkSize = strlen($chunk); $isLastChunk = ($rangeEnd + 1) >= $totalSize; @@ -252,6 +268,16 @@ private function s3(): S3Client return $adapter->getClient(); } + private function usesAzure(): bool + { + return $this->diskOption('driver') === 'azure-flat'; + } + + private function azure(): AzureBlockUploader + { + return $this->azure ?? app(AzureBlockUploader::class); + } + private function bucket(): string { if (filled($this->bucket)) { diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 8d0f16c393..8a6dde07f0 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -389,7 +389,7 @@ private function uploadImage($mediaItem): ?string } try { - $this->downloadToTempFile($mediaItem->url, $tempFile); + $this->downloadToTempFile($mediaItem->publishUrl(), $tempFile); return $this->uploadLocalImage($tempFile); } finally { @@ -484,7 +484,7 @@ private function uploadVideo($mediaItem): ?string private function doUploadVideo(string $tempFile, $mediaItem): ?string { - $this->downloadToTempFile($mediaItem->url, $tempFile); + $this->downloadToTempFile($mediaItem->publishUrl(), $tempFile); $fileSize = (int) filesize($tempFile); @@ -599,7 +599,7 @@ private function uploadDocument($mediaItem): ?string $tempFile = tempnam(sys_get_temp_dir(), 'li_document_'); try { - $this->downloadToTempFile($mediaItem->url, $tempFile); + $this->downloadToTempFile($mediaItem->publishUrl(), $tempFile); $stream = fopen($tempFile, 'r'); diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index e1e045c2a3..7f63467684 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -171,7 +171,7 @@ private function mediaEmbed(SocialAccount $account, string $service, Collection foreach ($medias->take(4) as $media) { if ($media->isImage()) { - $blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type); + $blob = $this->uploadBlob($account, $service, $media->publishUrl(), $media->mime_type); if (! $blob) { throw new BlueskyPublishException( userMessage: 'Media upload to Bluesky failed. Please try again.', @@ -194,7 +194,7 @@ private function mediaEmbed(SocialAccount $account, string $service, Collection } $video = $medias->first(fn (MediaItem $media): bool => $media->isVideo()); - $videoBlob = $video ? $this->uploadVideo($account, $service, $video->url, $video->mime_type) : null; + $videoBlob = $video ? $this->uploadVideo($account, $service, $video->publishUrl(), $video->mime_type) : null; if ($video && ! $videoBlob) { throw new BlueskyPublishException( diff --git a/app/Services/Social/Concerns/FitsImageToCanvas.php b/app/Services/Social/Concerns/FitsImageToCanvas.php index 78f9ad2fde..53c05df8de 100644 --- a/app/Services/Social/Concerns/FitsImageToCanvas.php +++ b/app/Services/Social/Concerns/FitsImageToCanvas.php @@ -40,7 +40,7 @@ protected function fitImageToCanvas(string $imageUrl, int $width, int $height): $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; Storage::put($path, file_get_contents($fitted)); - return Storage::url($path); + return \App\Support\Media\MediaUrl::publishing($path); } finally { @unlink($fitted); } diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php index 2c2dd12bef..cb6c7e268d 100644 --- a/app/Services/Social/Discord/DiscordPublisher.php +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -152,7 +152,7 @@ private function downloadMedia(MediaItem $item): string { $tempFile = tempnam(sys_get_temp_dir(), 'discord_media_'); - $response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->url); + $response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->publishUrl()); if ($response->failed() || (filesize($tempFile) ?: 0) === 0) { @unlink($tempFile); diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 1a0113bcb2..d8c1ae688c 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -162,7 +162,7 @@ private function postTextToFeed(string $pageId, string $accessToken, string $con private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { $response = $this->postToGraph("{$pageId}/photos", [ - 'url' => $media->url, + 'url' => $media->publishUrl(), 'access_token' => $accessToken, ...$this->optionalField('message', $content), ...$this->altText($media), @@ -211,7 +211,7 @@ private function uploadUnpublishedPhoto(string $pageId, string $accessToken, Med { $response = $this->reachOrRetry( fn (): Response => $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $url ?? $media->url, + 'url' => $url ?? $media->publishUrl(), 'published' => 'false', 'access_token' => $accessToken, ...$this->altText($media), @@ -239,7 +239,7 @@ private function uploadUnpublishedPhoto(string $pageId, string $accessToken, Med private function publishVideoPost(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { $response = $this->postToGraph("{$pageId}/videos", [ - 'file_url' => $media->url, + 'file_url' => $media->publishUrl(), 'access_token' => $accessToken, ...$this->optionalField('description', $content), ], 'video post'); @@ -311,7 +311,7 @@ private function publishStory(string $pageId, string $accessToken, MediaItem $me private function publishPhotoStory(string $pageId, string $accessToken, MediaItem $media): array { $dimensions = ContentType::FacebookStory->preferredImageDimensions(); - $fittedUrl = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height')); + $fittedUrl = $this->fitImageToCanvas($media->publishUrl(), data_get($dimensions, 'width'), data_get($dimensions, 'height')); $photoId = $this->uploadUnpublishedPhoto($pageId, $accessToken, $media, $fittedUrl); @@ -409,7 +409,7 @@ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, Medi fn (): Response => $this->socialHttp() ->withHeaders([ 'Authorization' => "OAuth {$accessToken}", - 'file_url' => $media->url, + 'file_url' => $media->publishUrl(), ]) ->send('POST', $uploadUrl), 'video upload', diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php index 9bc2d3dc1b..08a06cb8da 100644 --- a/app/Services/Social/GoogleBusinessPublisher.php +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -173,14 +173,14 @@ private function photo(Post $post): ?array private function imageSourceUrl(MediaItem $media, Post $post): string { if (blank($media->path) || ! Storage::exists($media->path)) { - return $media->url; + return $media->publishUrl(); } $input = tempnam(sys_get_temp_dir(), 'gbp_'); $optimized = null; if ($input === false) { - return $media->url; + return $media->publishUrl(); } try { @@ -189,14 +189,14 @@ private function imageSourceUrl(MediaItem $media, Post $post): string $path = GoogleBusinessDerivativeCleaner::pathFor($post); Storage::put($path, file_get_contents($optimized)); - return Storage::url($path); + return \App\Support\Media\MediaUrl::publishing($path); } catch (Throwable $e) { Log::warning('Google Business Profile image derivative failed; sending the original', [ 'path' => $media->path, 'error' => $e->getMessage(), ]); - return $media->url; + return $media->publishUrl(); } finally { @unlink($input); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 319ada9e19..43c42333a0 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -104,7 +104,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $ private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array { $params = [ - 'image_url' => $media->url, + 'image_url' => $media->publishUrl(), 'caption' => $content, 'access_token' => $accessToken, ]; @@ -125,7 +125,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array { $containerId = $this->createContainer($instagramId, [ - 'video_url' => $media->url, + 'video_url' => $media->publishUrl(), 'caption' => $content, 'media_type' => 'REELS', 'access_token' => $accessToken, @@ -148,10 +148,10 @@ private function publishStory(string $instagramId, string $accessToken, $media): ]; if ($isVideo) { - $params['video_url'] = $media->url; + $params['video_url'] = $media->publishUrl(); } else { $dimensions = ContentType::InstagramStory->preferredImageDimensions(); - $params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height')); + $params['image_url'] = $this->fitImageToCanvas($media->publishUrl(), data_get($dimensions, 'width'), data_get($dimensions, 'height')); $params = [...$params, ...$this->userTagsParam(array_map( fn (array $tag): array => ['username' => $tag['username']], $media->userTags(), @@ -178,11 +178,11 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri ]; if ($isVideo) { - $params['video_url'] = $media->url; + $params['video_url'] = $media->publishUrl(); $params['media_type'] = 'VIDEO'; $params = [...$params, ...$this->thumbOffsetParam($media)]; } else { - $params['image_url'] = $media->url; + $params['image_url'] = $media->publishUrl(); $alt = $media->altTextFor(Platform::Instagram); diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 85497a0f77..838fab817e 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -82,7 +82,7 @@ private function mediaPayload(SocialAccount $account, string $instance, Collecti $mediaIds = []; foreach ($media->take(4) as $item) { - $mediaIds[] = $this->uploadMedia($account, $instance, $item->url, $item->original_filename, $item->isImage() ? $item->altTextFor(Platform::Mastodon) : null); + $mediaIds[] = $this->uploadMedia($account, $instance, $item->publishUrl(), $item->original_filename, $item->isImage() ? $item->altTextFor(Platform::Mastodon) : null); } return $mediaIds === [] ? [] : ['media_ids' => $mediaIds]; diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index e999be95e0..1015a67518 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -137,7 +137,7 @@ private function publishImagePin(Post $post, ?string $content): array $tempFile = tempnam(sys_get_temp_dir(), 'pin_image_'); try { - $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->publishUrl()); if ($downloadResponse->failed()) { throw new PinterestPublishException( @@ -237,7 +237,7 @@ private function publishVideoPin(Post $post, ?string $content): array $videoStream = null; try { - $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->publishUrl()); if ($downloadResponse->failed()) { throw new PinterestPublishException( @@ -258,7 +258,7 @@ private function publishVideoPin(Post $post, ?string $content): array $multipart[] = [ 'name' => 'file', 'contents' => $videoStream, - 'filename' => basename($media->url), + 'filename' => basename($media->publishUrl()), ]; $uploadResponse = Http::asMultipart() @@ -305,7 +305,7 @@ private function publishCarousel(Post $post, ?string $content): array } $items = $medias->map(fn ($media) => [ - 'url' => $media->url, + 'url' => $media->publishUrl(), ])->toArray(); $payload = $this->applyPinTextFields([ diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php index 97eae98950..6f79163f51 100644 --- a/app/Services/Social/Telegram/TelegramPublisher.php +++ b/app/Services/Social/Telegram/TelegramPublisher.php @@ -125,7 +125,7 @@ private function sendMediaGroup(string $chatId, array $items, string $caption): */ private function telegramMedia(MediaItem $media): array { - return ['type' => TelegramMediaType::for($media), 'url' => $media->url]; + return ['type' => TelegramMediaType::for($media), 'url' => $media->publishUrl()]; } private function call(string $method, array $payload): Response diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index c650ae3981..004511d766 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -142,7 +142,7 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ // Step 1: Create container $params = [ 'media_type' => 'IMAGE', - 'image_url' => $media->url, + 'image_url' => $media->publishUrl(), 'text' => $content, ...$this->containerOptions, 'access_token' => $accessToken, @@ -185,7 +185,7 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $ // Step 1: Create container $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'VIDEO', - 'video_url' => $media->url, + 'video_url' => $media->publishUrl(), 'text' => $content, ...$this->containerOptions, 'access_token' => $accessToken, @@ -230,10 +230,10 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c if ($isVideo) { $params['media_type'] = 'VIDEO'; - $params['video_url'] = $media->url; + $params['video_url'] = $media->publishUrl(); } else { $params['media_type'] = 'IMAGE'; - $params['image_url'] = $media->url; + $params['image_url'] = $media->publishUrl(); $alt = $media->altTextFor(Platform::Threads); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index c6e499d8de..daf354c7ec 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -229,7 +229,7 @@ private function publishVideo(Post $post, $media, ?string $content): array 'post_info' => $this->buildVideoPostInfo($post, $content, $media->coverOffsetMs()), 'source_info' => [ 'source' => 'PULL_FROM_URL', - 'video_url' => $media->url, + 'video_url' => $media->publishUrl(), ], ]); @@ -333,7 +333,7 @@ private function resolvePhotoUrl(MediaItem $image): array $width = $image->width(); if ($maxWidth !== null && $width !== null && $width <= $maxWidth) { - return [$image->url, null]; + return [$image->publishUrl(), null]; } return $this->renderCompliantPhoto($image); @@ -351,7 +351,7 @@ private function renderCompliantPhoto(MediaItem $image): array $tempInput = tempnam(sys_get_temp_dir(), 'tiktok_photo_'); try { - $download = Http::sink($tempInput)->timeout(120)->get($image->url); + $download = Http::sink($tempInput)->timeout(120)->get($image->publishUrl()); if ($download->failed()) { throw new TikTokPublishException( @@ -385,7 +385,7 @@ private function hostResizedPhoto(string $tempInput): array @unlink($optimized); } - return [Storage::url($path), $path]; + return [\App\Support\Media\MediaUrl::publishing($path), $path]; } catch (Throwable $e) { Log::error('TikTok photo resize/host failed', [ 'exception' => $e->getMessage(), diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index a2d5d4d609..f858adbbf1 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -232,7 +232,7 @@ private function uploadMedia($mediaItem): ?array $tempFile = tempnam(sys_get_temp_dir(), 'x_media_'); try { - $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->publishUrl()); if ($downloadResponse->failed()) { throw new XPublishException( diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 0f98c6c5db..7a35d22e6d 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -151,7 +151,7 @@ private function downloadVideo(MediaItem $media, string $path): void { $response = Http::withOptions(['sink' => $path]) ->timeout(600) - ->get($media->url); + ->get($media->publishUrl()); if ($response->failed()) { throw new YouTubePublishException( diff --git a/app/Support/Media/AzureUserDelegationSas.php b/app/Support/Media/AzureUserDelegationSas.php new file mode 100644 index 0000000000..989661ff96 --- /dev/null +++ b/app/Support/Media/AzureUserDelegationSas.php @@ -0,0 +1,530 @@ + 63 + || preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/D', $containerName) !== 1 + || str_contains($containerName, '--') + ) { + throw new InvalidArgumentException('Azure blob container name is invalid.'); + } + } + + public function url(string $physicalBlobName, DateTimeInterface $expiresAt): string + { + $this->assertPhysicalBlobName($physicalBlobName); + + $now = CarbonImmutable::now('UTC')->startOfSecond(); + $expiry = CarbonImmutable::instance($expiresAt)->utc()->startOfSecond(); + $latestExpiry = $now->addHours(26); + + if ($expiry->lessThanOrEqualTo($now) || $expiry->greaterThan($latestExpiry)) { + throw new InvalidArgumentException('Azure SAS expiry must be in the future and at most 26 hours away.'); + } + + $start = $now->subMinutes(5); + $delegationKey = $this->delegationKey($start, $expiry, $now); + $canonicalizedResource = "/blob/{$this->accountName}/{$this->containerName}/{$physicalBlobName}"; + $stringToSign = implode("\n", [ + 'r', + $start->format(self::DATE_FORMAT), + $expiry->format(self::DATE_FORMAT), + $canonicalizedResource, + $delegationKey['signed_oid'], + $delegationKey['signed_tid'], + $delegationKey['signed_start'], + $delegationKey['signed_expiry'], + $delegationKey['signed_service'], + $delegationKey['signed_version'], + '', + '', + '', + '', + 'https', + self::SAS_VERSION, + 'b', + '', + '', + '', + '', + '', + '', + '', + ]); + + $signature = base64_encode(hash_hmac('sha256', $stringToSign, $this->decodedKey($delegationKey), true)); + $query = $this->queryString($start, $expiry, $delegationKey, $signature); + + return sprintf( + 'https://%s%s/%s/%s?%s', + $this->accountName, + self::ACCOUNT_HOST_SUFFIX, + $this->containerName, + rawurlencode($physicalBlobName), + $query, + ); + } + + private function assertPhysicalBlobName(string $physicalBlobName): void + { + if ( + $physicalBlobName === '' + || trim($physicalBlobName) === '' + || $physicalBlobName === '.' + || $physicalBlobName === '..' + || str_contains($physicalBlobName, '/') + || str_contains($physicalBlobName, '\\') + || preg_match('/[\x00-\x1F\x7F]/', $physicalBlobName) === 1 + ) { + throw new InvalidArgumentException('Azure blob name must be a flat, non-empty path without traversal.'); + } + } + + /** + * @return array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} + */ + private function delegationKey( + CarbonImmutable $sasStart, + CarbonImmutable $sasExpiry, + CarbonImmutable $now, + ): array { + if ($this->processKeyCovers($sasStart, $sasExpiry, $now)) { + /** @var array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $processDelegationKey */ + $processDelegationKey = $this->processDelegationKey; + + return $processDelegationKey; + } + + $cache = $this->cache ?? Cache::store(); + $cacheKey = $this->cacheKey(); + $cached = $this->cachedDelegationKey($cache, $cacheKey, $sasStart, $sasExpiry, $now); + if ($cached !== null) { + return $cached; + } + + $store = $cache->getStore(); + if (! $store instanceof LockProvider) { + throw new RuntimeException('Azure user delegation SAS caching requires a cache store with lock support.'); + } + + try { + $delegationKey = $store + ->lock("{$cacheKey}:refresh", self::KEY_FETCH_LOCK_SECONDS) + ->block(self::KEY_FETCH_WAIT_SECONDS, function () use ($cache, $cacheKey, $sasStart, $sasExpiry, $now): array { + $cached = $this->cachedDelegationKey($cache, $cacheKey, $sasStart, $sasExpiry, $now); + if ($cached !== null) { + return $cached; + } + + $delegationKey = $this->fetchDelegationKey($sasStart, $sasExpiry, $now); + $this->storeDelegationKey($cache, $cacheKey, $delegationKey, $now); + + return $delegationKey; + }); + } catch (LockTimeoutException) { + throw new RuntimeException('Timed out waiting for the Azure user delegation key.'); + } + + if (! is_array($delegationKey)) { + throw new RuntimeException('Azure user delegation key could not be resolved.'); + } + + return $delegationKey; + } + + /** + * @return array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} + */ + private function cachedDelegationKey( + CacheRepository $cache, + string $cacheKey, + CarbonImmutable $sasStart, + CarbonImmutable $sasExpiry, + CarbonImmutable $now, + ): ?array { + $encrypted = $cache->get($cacheKey); + if (! is_string($encrypted)) { + return null; + } + + try { + $payload = json_decode(Crypt::decryptString($encrypted), true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + $cache->forget($cacheKey); + + return null; + } + + if ( + ! is_array($payload) + || data_get($payload, 'schema') !== 1 + || ! is_int(data_get($payload, 'fetched_at')) + ) { + $cache->forget($cacheKey); + + return null; + } + + $delegationKey = $this->normaliseDelegationKey(data_get($payload, 'key')); + $fetchedAt = data_get($payload, 'fetched_at'); + if ( + $delegationKey === null + || ! is_int($fetchedAt) + || $now->getTimestamp() >= $fetchedAt + self::CACHE_TTL_SECONDS + || ! $this->keyCovers($delegationKey, $sasStart, $sasExpiry, $now) + ) { + $cache->forget($cacheKey); + + return null; + } + + $this->rememberProcessDelegationKey($delegationKey, $fetchedAt); + + return $delegationKey; + } + + private function processKeyCovers( + CarbonImmutable $sasStart, + CarbonImmutable $sasExpiry, + CarbonImmutable $now, + ): bool { + if ($this->processDelegationKey === null || $this->processDelegationKeyFetchedAt === null) { + return false; + } + + if ($now->getTimestamp() >= $this->processDelegationKeyFetchedAt + self::CACHE_TTL_SECONDS) { + $this->forgetProcessDelegationKey(); + + return false; + } + + return $this->keyCovers($this->processDelegationKey, $sasStart, $sasExpiry, $now); + } + + /** + * @return array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} + */ + private function fetchDelegationKey( + CarbonImmutable $sasStart, + CarbonImmutable $sasExpiry, + CarbonImmutable $now, + ): array { + try { + $token = $this->credential->getToken(new TokenRequestContext([self::TOKEN_SCOPE])); + } catch (Throwable) { + throw new RuntimeException('Azure storage access token could not be acquired.'); + } + + try { + $response = Http::withToken($token->token, 'Bearer') + ->accept('application/xml') + ->withHeaders(['x-ms-version' => self::API_VERSION]) + ->timeout(10) + ->connectTimeout(5) + ->withBody($this->delegationKeyRequest($now), 'application/xml') + ->post($this->userDelegationKeyUrl()); + } catch (ConnectionException) { + throw new RuntimeException('Azure user delegation key request failed.'); + } + + if (! $response->successful()) { + throw new RuntimeException("Azure user delegation key request failed with HTTP {$response->status()}."); + } + + $body = $response->body(); + if (strlen($body) > 65536) { + throw new RuntimeException('Azure returned an invalid user delegation key.'); + } + + $delegationKey = $this->parseDelegationKey($body); + if (! $this->keyCovers($delegationKey, $sasStart, $sasExpiry, $now)) { + throw new RuntimeException('Azure returned a user delegation key outside the requested bounds.'); + } + + return $delegationKey; + } + + private function delegationKeyRequest(CarbonImmutable $now): string + { + $start = $now->subMinutes(5)->format(self::DATE_FORMAT); + $expiry = $now->addHours(48)->format(self::DATE_FORMAT); + + return "{$start}{$expiry}"; + } + + private function userDelegationKeyUrl(): string + { + return sprintf( + 'https://%s%s/?restype=service&comp=userdelegationkey', + $this->accountName, + self::ACCOUNT_HOST_SUFFIX, + ); + } + + /** + * @return array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} + */ + private function parseDelegationKey(string $body): array + { + $previous = libxml_use_internal_errors(true); + + try { + $document = new DOMDocument; + $loaded = $document->loadXML($body, LIBXML_NONET | LIBXML_NOBLANKS); + } catch (Throwable) { + $loaded = false; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + $root = $loaded ? $document->documentElement : null; + if (! $root instanceof DOMElement || $root->localName !== 'UserDelegationKey') { + throw new RuntimeException('Azure returned an invalid user delegation key.'); + } + + $values = []; + foreach ($root->childNodes as $child) { + if ($child instanceof DOMElement) { + $values[$child->localName] = trim($child->textContent); + } + } + + $delegationKey = $this->normaliseDelegationKey([ + 'signed_oid' => $values['SignedOid'] ?? null, + 'signed_tid' => $values['SignedTid'] ?? null, + 'signed_start' => $values['SignedStart'] ?? null, + 'signed_expiry' => $values['SignedExpiry'] ?? null, + 'signed_service' => $values['SignedService'] ?? null, + 'signed_version' => $values['SignedVersion'] ?? null, + 'value' => $values['Value'] ?? null, + ]); + + if ($delegationKey === null) { + throw new RuntimeException('Azure returned an invalid user delegation key.'); + } + + return $delegationKey; + } + + private function normaliseDelegationKey(mixed $key): ?array + { + if (! is_array($key)) { + return null; + } + + $normalised = []; + foreach (['signed_oid', 'signed_tid', 'signed_start', 'signed_expiry', 'signed_service', 'signed_version', 'value'] as $field) { + $value = data_get($key, $field); + if (! is_string($value) || $value === '') { + return null; + } + + $normalised[$field] = $value; + } + + if ($normalised['signed_service'] !== 'b' || $normalised['signed_version'] !== self::API_VERSION) { + return null; + } + + if ( + $this->parseDate($normalised['signed_start']) === null + || $this->parseDate($normalised['signed_expiry']) === null + || $this->decodeKeyValue($normalised['value']) === null + ) { + return null; + } + + return $normalised; + } + + /** + * @param array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $delegationKey + */ + private function keyCovers( + array $delegationKey, + CarbonImmutable $sasStart, + CarbonImmutable $sasExpiry, + CarbonImmutable $now, + ): bool { + $keyStart = $this->parseDate($delegationKey['signed_start']); + $keyExpiry = $this->parseDate($delegationKey['signed_expiry']); + + return $keyStart !== null + && $keyExpiry !== null + && $keyStart->lessThanOrEqualTo($sasStart) + && $keyExpiry->greaterThanOrEqualTo($sasExpiry) + && $keyExpiry->greaterThan($now); + } + + private function parseDate(string $value): ?CarbonImmutable + { + try { + return CarbonImmutable::parse($value)->utc(); + } catch (Throwable) { + return null; + } + } + + /** + * @param array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $delegationKey + */ + private function decodedKey(array $delegationKey): string + { + $decoded = $this->decodeKeyValue($delegationKey['value']); + if ($decoded === null) { + throw new RuntimeException('Azure returned an invalid user delegation key.'); + } + + return $decoded; + } + + private function decodeKeyValue(string $value): ?string + { + $decoded = base64_decode($value, true); + + return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null; + } + + /** + * @param array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $delegationKey + */ + private function storeDelegationKey( + CacheRepository $cache, + string $cacheKey, + array $delegationKey, + CarbonImmutable $now, + ): void { + $fetchedAt = $now->getTimestamp(); + + try { + $json = json_encode([ + 'schema' => 1, + 'fetched_at' => $fetchedAt, + 'key' => $delegationKey, + ], JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new RuntimeException('Azure user delegation key could not be cached.'); + } + + $cache->put($cacheKey, Crypt::encryptString($json), self::CACHE_TTL_SECONDS); + $this->rememberProcessDelegationKey($delegationKey, $fetchedAt); + } + + /** + * @param array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $delegationKey + */ + private function rememberProcessDelegationKey(array $delegationKey, int $fetchedAt): void + { + $this->processDelegationKey = $delegationKey; + $this->processDelegationKeyFetchedAt = $fetchedAt; + } + + private function forgetProcessDelegationKey(): void + { + $this->processDelegationKey = null; + $this->processDelegationKeyFetchedAt = null; + } + + private function cacheKey(): string + { + $tenantId = getenv('AZURE_TENANT_ID'); + $clientId = getenv('AZURE_CLIENT_ID'); + + return 'azure-user-delegation-key:v1:'.hash('sha256', implode("\0", [ + $this->accountName, + is_string($tenantId) ? $tenantId : '', + is_string($clientId) ? $clientId : '', + ])); + } + + /** + * @param array{signed_oid: string, signed_tid: string, signed_start: string, signed_expiry: string, signed_service: string, signed_version: string, value: string} $delegationKey + */ + private function queryString( + CarbonImmutable $start, + CarbonImmutable $expiry, + array $delegationKey, + string $signature, + ): string { + $values = [ + 'st' => $start->format(self::DATE_FORMAT), + 'se' => $expiry->format(self::DATE_FORMAT), + 'sp' => 'r', + 'spr' => 'https', + 'sv' => self::SAS_VERSION, + 'sr' => 'b', + 'skoid' => $delegationKey['signed_oid'], + 'sktid' => $delegationKey['signed_tid'], + 'skt' => $delegationKey['signed_start'], + 'ske' => $delegationKey['signed_expiry'], + 'sks' => $delegationKey['signed_service'], + 'skv' => $delegationKey['signed_version'], + 'sig' => $signature, + ]; + + $parts = []; + foreach ($values as $name => $value) { + $parts[] = $name.'='.str_replace('%2F', '/', rawurlencode($value)); + } + + return implode('&', $parts); + } +} diff --git a/app/Support/Media/FlatBlobAdapter.php b/app/Support/Media/FlatBlobAdapter.php new file mode 100644 index 0000000000..c83126f205 --- /dev/null +++ b/app/Support/Media/FlatBlobAdapter.php @@ -0,0 +1,288 @@ +adapter->fileExists(self::encodePath($path)); + } + + public function directoryExists(string $path): bool + { + $directory = self::normalizeDirectoryPath($path); + + if ($directory === '') { + return true; + } + + $prefix = self::encodePath($directory).self::SEPARATOR; + + foreach ($this->adapter->listContents('', true) as $item) { + if ($item instanceof FileAttributes && str_starts_with($item->path(), $prefix)) { + return true; + } + } + + return false; + } + + public function write(string $path, string $contents, Config $config): void + { + $this->adapter->write(self::encodePath($path), $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->adapter->writeStream(self::encodePath($path), $contents, $config); + } + + public function read(string $path): string + { + return $this->adapter->read(self::encodePath($path)); + } + + public function readStream(string $path) + { + return $this->adapter->readStream(self::encodePath($path)); + } + + public function delete(string $path): void + { + $this->adapter->delete(self::encodePath($path)); + } + + public function deleteDirectory(string $path): void + { + $directory = self::normalizeDirectoryPath($path); + + if ($directory === '') { + throw new InvalidArgumentException('Deleting the flat Blob root is not allowed.'); + } + + $prefix = self::encodePath($directory).self::SEPARATOR; + $physicalPaths = []; + + foreach ($this->adapter->listContents('', true) as $item) { + if ($item instanceof FileAttributes && str_starts_with($item->path(), $prefix)) { + $physicalPaths[] = $item->path(); + } + } + + foreach ($physicalPaths as $physicalPath) { + $this->adapter->delete($physicalPath); + } + } + + public function createDirectory(string $path, Config $config): void + { + self::normalizeDirectoryPath($path); + } + + public function setVisibility(string $path, string $visibility): void + { + $this->adapter->setVisibility(self::encodePath($path), $visibility); + } + + public function visibility(string $path): FileAttributes + { + return $this->withLogicalPath($this->adapter->visibility(self::encodePath($path)), $path); + } + + public function mimeType(string $path): FileAttributes + { + return $this->withLogicalPath($this->adapter->mimeType(self::encodePath($path)), $path); + } + + public function lastModified(string $path): FileAttributes + { + return $this->withLogicalPath($this->adapter->lastModified(self::encodePath($path)), $path); + } + + public function fileSize(string $path): FileAttributes + { + return $this->withLogicalPath($this->adapter->fileSize(self::encodePath($path)), $path); + } + + public function listContents(string $path, bool $deep): iterable + { + $directory = self::normalizeDirectoryPath($path); + $directorySegments = $directory === '' ? [] : explode('/', $directory); + $prefix = $directory === '' ? '' : self::encodePath($directory).self::SEPARATOR; + $directories = []; + + foreach ($this->adapter->listContents('', true) as $item) { + if (! $item instanceof FileAttributes || ! str_starts_with($item->path(), $prefix)) { + continue; + } + + $relativePath = substr($item->path(), strlen($prefix)); + $relativeSegments = explode(self::SEPARATOR, $relativePath); + $fileSegment = array_pop($relativeSegments); + + if ($fileSegment === null || $fileSegment === '') { + throw self::invalidPath($item->path(), 'physical Blob paths must include a file name'); + } + + $parentLimit = $deep ? count($relativeSegments) : min(1, count($relativeSegments)); + $logicalParent = $directorySegments; + + for ($index = 0; $index < $parentLimit; $index++) { + $logicalParent[] = $relativeSegments[$index]; + $logicalDirectory = implode('/', $logicalParent); + + if (! isset($directories[$logicalDirectory])) { + $directories[$logicalDirectory] = true; + + yield new DirectoryAttributes($logicalDirectory); + } + } + + if (! $deep && $relativeSegments !== []) { + continue; + } + + $logicalPath = implode('/', [...$directorySegments, ...$relativeSegments, $fileSegment]); + + yield self::withLogicalPath($item, $logicalPath); + } + } + + public function move(string $source, string $destination, Config $config): void + { + $this->copy($source, $destination, $config); + $this->delete($source); + } + + public function copy(string $source, string $destination, Config $config): void + { + $source = self::encodePath($source); + $destination = self::encodePath($destination); + $stream = $this->adapter->readStream($source); + + try { + $headers = $config->get('httpHeaders', []); + $headers['contentType'] = $this->adapter->mimeType($source)->mimeType() ?? 'application/octet-stream'; + $this->adapter->writeStream($destination, $stream, $config->extend([ + 'httpHeaders' => $headers, + 'initialTransferSize' => 8 * 1024 * 1024, + 'maximumTransferSize' => 8 * 1024 * 1024, + 'maximumConcurrency' => 1, + ])); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + } + + /** + * @return list + */ + private static function logicalSegments(string $path): array + { + $segments = []; + + foreach (explode('/', str_replace('\\', '/', $path)) as $segment) { + if ($segment === '') { + continue; + } + + self::assertSegment($segment, $path); + $segments[] = $segment; + } + + if ($segments === []) { + throw self::invalidPath($path, 'file paths must not be empty'); + } + + return $segments; + } + + private static function normalizeDirectoryPath(string $path): string + { + $path = str_replace('\\', '/', $path); + + if (trim($path, '/') === '') { + return ''; + } + + return implode('/', self::logicalSegments($path)); + } + + private static function assertSegment(string $segment, string $path): void + { + if ($segment === '') { + throw self::invalidPath($path, 'path segments must not be empty'); + } + + if ($segment === '.' || $segment === '..') { + throw self::invalidPath($path, 'dot and traversal segments are not allowed'); + } + + if (str_contains($segment, self::SEPARATOR)) { + throw self::invalidPath($path, "path segments must not contain '".self::SEPARATOR."'"); + } + + if (preg_match('/\p{C}/u', $segment) === 1) { + throw self::invalidPath($path, 'control characters are not allowed'); + } + } + + private static function withLogicalPath(FileAttributes $attributes, string $path): FileAttributes + { + return new FileAttributes( + self::decodePath(self::encodePath($path)), + $attributes->fileSize(), + $attributes->visibility(), + $attributes->lastModified(), + $attributes->mimeType(), + $attributes->extraMetadata(), + ); + } + + private static function invalidPath(string $path, string $reason): InvalidArgumentException + { + return new InvalidArgumentException("Invalid flat Blob path '{$path}': {$reason}."); + } +} diff --git a/app/Support/Media/MediaUrl.php b/app/Support/Media/MediaUrl.php new file mode 100644 index 0000000000..e0f457d733 --- /dev/null +++ b/app/Support/Media/MediaUrl.php @@ -0,0 +1,151 @@ + $id]); + } + + $disk = config('filesystems.disks.'.config('filesystems.default')); + + return 'https://'.$disk['account_name'].'.blob.core.windows.net/'.$disk['container'].'/'.rawurlencode(FlatBlobAdapter::encodePath($path)); + } + + public static function browser(Media $media): string + { + if (self::isAzure() && $media->workspace_id !== null) { + return self::canonical($media->path, $media->id); + } + + return self::isAzure() ? (self::avatar($media->path) ?? '') : Storage::url($media->path); + } + + public static function exists(string $path): bool + { + try { + return $path !== '' && Storage::exists($path); + } catch (Throwable) { + throw new RuntimeException('Media storage is unavailable.'); + } + } + + public static function temporary(string $path, int $minutes = 15): string + { + if (! self::exists($path)) { + abort(404); + } + + try { + return self::isAzure() + ? Storage::temporaryUrl($path, now()->addMinutes($minutes)) + : Storage::url($path); + } catch (Throwable) { + throw new RuntimeException('Unable to grant media access.'); + } + } + + public static function publishing(string $path, string $fallback = ''): string + { + if ($path === '') { + return $fallback; + } + if (! self::verifiesStorage()) { + return $fallback !== '' ? $fallback : Storage::url($path); + } + + return self::temporary($path, 26 * 60); + } + + public static function avatar(?string $path): ?string + { + if (! $path) { + return null; + } + + if (! self::isAzure()) { + return Storage::url($path); + } + + return self::exists($path) ? self::temporary($path, 60) : null; + } + + /** Rebuild only owned file references; external URLs and prose are data. */ + public static function canonicalItems(array $value): array + { + if (! self::verifiesStorage()) { + return $value; + } + + if (isset($value['path']) && is_string($value['path']) && $value['path'] !== '' && isset($value['id'])) { + $value['url'] = self::canonical($value['path'], (string) $value['id']); + } + + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = self::canonicalItems($item); + } + } + + return $value; + } + + /** Called only by resources after their existing workspace authorization. */ + public static function apiItems(array $value, ?string $workspaceId = null): array + { + if (! self::verifiesStorage()) { + return $value; + } + + if (isset($value['path']) && is_string($value['path']) && $value['path'] !== '' && isset($value['id'])) { + abort_unless(Media::query()->whereKey($value['id'])->where('path', $value['path']) + ->when($workspaceId !== null, fn ($query) => $query->where('workspace_id', $workspaceId))->exists(), 404); + $value['url'] = self::temporary($value['path']); + } + + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = self::apiItems($item, $workspaceId); + } + } + + return $value; + } + + public static function meta(array $value, bool $forApi = false, ?string $workspaceId = null): array + { + foreach ($value['thread_replies'] ?? [] as $index => $reply) { + if (is_array($reply) && is_array($reply['media'] ?? null)) { + $value['thread_replies'][$index]['media'] = $forApi + ? self::apiItems($reply['media'], $workspaceId) + : self::canonicalItems($reply['media']); + } + } + + return $value; + } +} diff --git a/app/Support/Media/RedactStorageCredentials.php b/app/Support/Media/RedactStorageCredentials.php new file mode 100644 index 0000000000..bd5b1035b8 --- /dev/null +++ b/app/Support/Media/RedactStorageCredentials.php @@ -0,0 +1,48 @@ +pushProcessor(fn (LogRecord $record) => $record->with( + message: self::clean($record->message), + context: self::clean($record->context), + extra: self::clean($record->extra), + )); + } + + public static function clean(mixed $value): mixed + { + if ($value instanceof Throwable) { + return ['type' => $value::class, 'message' => self::clean($value->getMessage())]; + } + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = preg_match('/^(authorization|access_token|client_assertion|sig|delegation_key)$/i', (string) $key) + ? '[redacted]' + : self::clean($item); + } + + return $value; + } + if (is_string($value)) { + return preg_replace_callback('~https?://[^\s<>"\x27]+~i', function (array $match): string { + $url = $match[0]; + + return preg_match('/[?&](sig|skoid|X-Amz-Signature|sv)=/i', $url) + ? explode('?', $url, 2)[0].'?[redacted]' + : $url; + }, $value); + } + + return $value; + } +} diff --git a/composer.json b/composer.json index ccf5e5b64f..e3fda0b816 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,8 @@ ], "require": { "php": "^8.2", + "azure-oss/identity": "1.0.4", + "azure-oss/storage-blob-flysystem": "2.2.2", "google/apiclient": "^2.19", "inertiajs/inertia-laravel": "^3.5.1", "intervention/image": "^4.0", diff --git a/composer.lock b/composer.lock index 24fbca4a3e..d408127cf8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3a23bc95c6ce1228c92160553619e17", + "content-hash": "b0dfca81a5bfe6e5950c11838080de1a", "packages": [ { "name": "aws/aws-crt-php", @@ -157,6 +157,194 @@ }, "time": "2026-06-12T18:10:49+00:00" }, + { + "name": "azure-oss/identity", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/php-oss-for-azure/azure-identity-php.git", + "reference": "ea794d1d766c2c03039479baa013b2ef10533a98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-oss-for-azure/azure-identity-php/zipball/ea794d1d766c2c03039479baa013b2ef10533a98", + "reference": "ea794d1d766c2c03039479baa013b2ef10533a98", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": "^8.1", + "php-http/discovery": "^1.20", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.10 || ^8.0", + "laravel/pint": "^1.27", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Provides a PSR-18 HTTP client and PSR-17 factories (used by default when installed)." + }, + "type": "library", + "autoload": { + "psr-4": { + "AzureOss\\Identity\\": "./" + } + }, + "notification-url": "https://packagist.org/downloads/", + "support": { + "source": "https://github.com/php-oss-for-azure/azure-identity-php/tree/1.0.4" + }, + "time": "2026-09-04T05:57:32+00:00" + }, + { + "name": "azure-oss/storage-blob", + "version": "2.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-oss-for-azure/azure-storage-blob-php.git", + "reference": "2333cad9c507062a8ae5536b02ec1f8099f1d48e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-oss-for-azure/azure-storage-blob-php/zipball/2333cad9c507062a8ae5536b02ec1f8099f1d48e", + "reference": "2333cad9c507062a8ae5536b02ec1f8099f1d48e", + "shasum": "" + }, + "require": { + "azure-oss/storage-common": "^2.0", + "ext-curl": "*", + "ext-json": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8 || ^8.0", + "php": "^8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "AzureOss\\Storage\\Blob\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brecht Vermeersch", + "email": "brechtvermeersch@outlook.be" + }, + { + "name": "Pim Jansen", + "email": "pimjansen@gmail.com" + } + ], + "description": "Azure Blob Storage PHP SDK", + "keywords": [ + "azure", + "php", + "sdk", + "storage" + ], + "support": { + "source": "https://github.com/php-oss-for-azure/azure-storage-blob-php/tree/2.3.1" + }, + "time": "2026-09-04T05:57:32+00:00" + }, + { + "name": "azure-oss/storage-blob-flysystem", + "version": "2.2.2", + "source": { + "type": "git", + "url": "https://github.com/php-oss-for-azure/azure-storage-blob-flysystem-php.git", + "reference": "85ae64006ae9b49dcec432cbfb95591a4b1c199b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-oss-for-azure/azure-storage-blob-flysystem-php/zipball/85ae64006ae9b49dcec432cbfb95591a4b1c199b", + "reference": "85ae64006ae9b49dcec432cbfb95591a4b1c199b", + "shasum": "" + }, + "require": { + "azure-oss/storage-blob": "^2.2", + "league/flysystem": "^3.28", + "php": "^8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "AzureOss\\Storage\\BlobFlysystem\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brecht Vermeersch", + "email": "brechtvermeersch@outlook.be" + }, + { + "name": "Pim Jansen", + "email": "pimjansen@gmail.com" + } + ], + "description": "Flysystem adapter for Azure Storage PHP", + "support": { + "source": "https://github.com/php-oss-for-azure/azure-storage-blob-flysystem-php/tree/2.2.2" + }, + "time": "2026-09-04T05:57:32+00:00" + }, + { + "name": "azure-oss/storage-common", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/php-oss-for-azure/azure-storage-common-php.git", + "reference": "be9f2c36c2b16037aeab2762b40ebf807f1de5c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-oss-for-azure/azure-storage-common-php/zipball/be9f2c36c2b16037aeab2762b40ebf807f1de5c9", + "reference": "be9f2c36c2b16037aeab2762b40ebf807f1de5c9", + "shasum": "" + }, + "require": { + "azure-oss/identity": "^1.0", + "caseyamcl/guzzle_retry_middleware": "^2.13 || ^3.0", + "guzzlehttp/guzzle": "^7.8 || ^8.0", + "php": "^8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "AzureOss\\Storage\\Common\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brecht Vermeersch", + "email": "brechtvermeersch@outlook.be" + } + ], + "description": "Shared primitives for Azure Storage PHP SDK packages", + "support": { + "source": "https://github.com/php-oss-for-azure/azure-storage-common-php/tree/2.2.1" + }, + "time": "2026-09-04T05:57:32+00:00" + }, { "name": "brick/math", "version": "0.18.0", @@ -285,6 +473,79 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "caseyamcl/guzzle_retry_middleware", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/caseyamcl/guzzle_retry_middleware.git", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/caseyamcl/guzzle_retry_middleware/zipball/17c9299cde438b00bbeb099c6480319a81636a60", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "jaschilz/php-coverage-badger": "^2.0", + "nesbot/carbon": "^2.0|^3.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.5|^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0|^6.0|^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleRetry\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Casey McLaughlin", + "email": "caseyamcl@gmail.com", + "homepage": "https://caseymclaughlin.com", + "role": "Developer" + } + ], + "description": "Guzzle v6+ retry middleware that handles 429/503 status codes and connection timeouts", + "homepage": "https://github.com/caseyamcl/guzzle_retry_middleware", + "keywords": [ + "Guzzle", + "back-off", + "caseyamcl", + "guzzle_retry_middleware", + "middleware", + "retry", + "retry-after" + ], + "support": { + "issues": "https://github.com/caseyamcl/guzzle_retry_middleware/issues", + "source": "https://github.com/caseyamcl/guzzle_retry_middleware/tree/v2.13.0" + }, + "funding": [ + { + "url": "https://github.com/caseyamcl", + "type": "github" + } + ], + "time": "2025-07-11T12:33:22+00:00" + }, { "name": "clue/redis-protocol", "version": "v0.3.2", diff --git a/config/filesystems.php b/config/filesystems.php index f81877c94a..a8e4a71fbe 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -17,6 +17,8 @@ 'default' => env('FILESYSTEM_DISK', 'public'), + 'verify_media' => (bool) env('MEDIA_VERIFY_STORAGE', false), + /* |-------------------------------------------------------------------------- | Filesystem Disks @@ -32,6 +34,14 @@ 'disks' => [ + 'azure' => [ + 'driver' => 'azure-flat', + 'account_name' => env('AZURE_STORAGE_ACCOUNT'), + 'container' => env('AZURE_STORAGE_CONTAINER', 'media'), + 'throw' => true, + 'report' => false, + ], + 'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), diff --git a/config/logging.php b/config/logging.php index aa0d9febd1..fff3b8712b 100644 --- a/config/logging.php +++ b/config/logging.php @@ -56,12 +56,14 @@ 'stack' => [ 'driver' => 'stack', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, @@ -69,6 +71,7 @@ 'daily' => [ 'driver' => 'daily', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), @@ -77,6 +80,7 @@ 'slack' => [ 'driver' => 'slack', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), @@ -86,6 +90,7 @@ 'papertrail' => [ 'driver' => 'monolog', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ @@ -98,6 +103,7 @@ 'stderr' => [ 'driver' => 'monolog', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'handler_with' => [ @@ -109,6 +115,7 @@ 'syslog' => [ 'driver' => 'syslog', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true, @@ -116,12 +123,14 @@ 'errorlog' => [ 'driver' => 'errorlog', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', + 'tap' => [\App\Support\Media\RedactStorageCredentials::class], 'handler' => NullHandler::class, ], diff --git a/routes/app.php b/routes/app.php index d9083f9559..eb854f5b04 100644 --- a/routes/app.php +++ b/routes/app.php @@ -266,6 +266,7 @@ Route::post('media/chunked', [MediaUploadController::class, 'storeChunked'])->name('app.media.store-chunked'); Route::get('media/{media}/file', [MediaFileController::class, 'show'])->whereUuid('media')->name('app.media.file'); + Route::get('media/{media}/view', [MediaFileController::class, 'view'])->whereUuid('media')->name('app.media.view'); Route::post('media/from-url', [MediaUploadController::class, 'storeFromUrl']) ->middleware('throttle:media-imports') ->name('app.media.store-from-url'); diff --git a/tests/Browser/MediaEditorTest.php b/tests/Browser/MediaEditorTest.php index c823084d56..9f95644db4 100644 --- a/tests/Browser/MediaEditorTest.php +++ b/tests/Browser/MediaEditorTest.php @@ -956,3 +956,31 @@ function setMediaEditorRange(mixed $page, string $testId, int $value): void ->screenshot(false, "ai-models-alt-error-{$suffix}")->assertNoJavaScriptErrors(); Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'gateway.example.test') || str_contains($request->url(), 'openrouter.ai')); })->with(['desktop' => [1280, 900, 'desktop'], 'compact' => [390, 844, 'compact']]); + + +test('azure preview redirects and canvas edits survive draft save and reload', function (int $width, int $height, string $suffix) { + config(['filesystems.default' => 'azure', 'filesystems.disks.azure.driver' => 'azure-flat']); + Storage::fake('azure'); + // The browser fixture serves bytes locally; protocol signing has separate SDK vectors. + Storage::disk('azure')->buildTemporaryUrlsUsing(function ($path) { + return route('app.media.file', Media::query()->where('path', $path)->firstOrFail()); + }); + [$post] = seedMediaEditorPost(ContentType::BlueskyPost); + $post->user->update(['name' => 'Demo Author', 'email' => 'author@example.test']); + $post->workspace->update(['name' => 'Demo Workspace']); + $post->socialAccount->update(['display_name' => 'Demo Channel', 'username' => 'demo.example.test']); + $media = Media::factory()->ownedByPost($post)->create(['path' => 'medias/blob-preview.png', 'mime_type' => 'image/png']); + Storage::put($media->path, file_get_contents(base_path('tests/fixtures/crop-quadrants.png'))); + $post->update(['media' => [\App\Dto\MediaItem::fromMedia($media)->toArray()]]); + $page = visit(route('app.posts.edit', $post))->resize($width, $height); + openMediaEditor($page, $post->social_account_id); + $page->click('@media-editor-rotate-right')->screenshot(false, "blob-canvas-{$suffix}")->click('@media-editor-apply'); + saveMediaEditorDraft($page); + $saved = $post->fresh(); + expect($saved->ownedMedia()->sole()->id)->not->toBe($media->id) + ->and($saved->getRawOriginal('media'))->not->toContain('sig='); + $page = visit(route('app.posts.edit', $saved))->resize($width, $height); + openMediaEditor($page, $saved->social_account_id); + $page->assertMissing('@media-editor-error')->assertNoJavaScriptErrors() + ->screenshot(false, "blob-reloaded-{$suffix}"); +})->with(['desktop' => [1280, 900, 'desktop'], 'compact' => [390, 844, 'compact']]); diff --git a/tests/Feature/ChunkedCloudUploadTest.php b/tests/Feature/ChunkedCloudUploadTest.php index 5daf093bde..77e0e4b8b2 100644 --- a/tests/Feature/ChunkedCloudUploadTest.php +++ b/tests/Feature/ChunkedCloudUploadTest.php @@ -6,10 +6,22 @@ use App\Models\Media; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\AzureBlockUploader; use App\Services\Media\ChunkedAssetReceiver; use App\Services\Media\ChunkedCloudUploader; +use App\Support\Media\FlatBlobAdapter; use Aws\Result; use Aws\S3\S3Client; +use AzureOss\Storage\Blob\BlobClient; +use AzureOss\Storage\Blob\BlobContainerClient; +use AzureOss\Storage\Blob\Specialized\BlockBlobClient; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Uri; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -72,6 +84,50 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 ); } +function fakeAzureBlockUploader(?Closure $httpClientFactory = null, bool $useDefaultEncoder = false): AzureBlockUploader +{ + $container = new BlobContainerClient(new Uri('https://account.blob.core.windows.net/test-container')); + + return new class($container, $httpClientFactory, $useDefaultEncoder) extends AzureBlockUploader + { + /** @var array */ + public array $stagedBlocks = []; + + /** @var array */ + public array $committedBlockIds = []; + + public ?string $committedContentType = null; + + public function __construct( + BlobContainerClient $container, + ?Closure $httpClientFactory, + bool $useDefaultEncoder, + ) { + parent::__construct( + Cache::store(), + $container, + $useDefaultEncoder ? null : fn (string $path): string => 'flat/'.hash('sha256', $path), + $httpClientFactory, + ); + } + + protected function stageBlock(BlockBlobClient $client, string $blockId, string $chunk): void + { + $this->stagedBlocks[] = [ + 'blob_name' => $client->blobName, + 'block_id' => $blockId, + 'bytes' => $chunk, + ]; + } + + protected function commitBlocks(BlockBlobClient $client, array $blockIds, string $mimeType): void + { + $this->committedBlockIds = $blockIds; + $this->committedContentType = $mimeType; + } + }; +} + // ─── Strategy selection (all disks × file types) ───────────────── test('shouldUseMultipart is only true for object-storage disks with video or pdf', function (string $disk, string $driver, string $fileName, bool $expected) { @@ -83,7 +139,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 $uploader = new ChunkedCloudUploader(Cache::store(), disk: $disk); expect($uploader->shouldUseMultipart($fileName))->toBe($expected); - expect($uploader->isObjectStorageDisk($disk))->toBe($driver === 's3'); + expect($uploader->isObjectStorageDisk($disk))->toBe(in_array($driver, ['s3', 'azure-flat'], true)); })->with([ 'local video' => ['local', 'local', 'clip.mp4', false], 'local pdf' => ['local', 'local', 'deck.pdf', false], @@ -100,6 +156,9 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 'spaces video' => ['spaces', 's3', 'clip.mp4', true], 'spaces pdf' => ['spaces', 's3', 'deck.pdf', true], 'spaces image' => ['spaces', 's3', 'photo.png', false], + 'azure video' => ['azure', 'azure-flat', 'clip.mp4', true], + 'azure pdf' => ['azure', 'azure-flat', 'deck.pdf', true], + 'azure image' => ['azure', 'azure-flat', 'photo.png', false], ]); // ─── Multipart mechanics (object storage) ──────────────────────── @@ -161,6 +220,174 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 expect($uploader->readRange('medias/clip.mp4', 40, 16))->toBe('sixteen bytes!!!'); }); +test('chunked cloud uploader routes azure upload and range operations to the azure block uploader', function () { + config([ + 'filesystems.default' => 'azure', + 'filesystems.disks.azure.driver' => 'azure-flat', + ]); + + $azure = Mockery::mock(AzureBlockUploader::class); + $azure->shouldReceive('receiveChunk') + ->once() + ->with('id-azure', 'clip.mp4', 'chunk', 0, 4, 10) + ->andReturn(['done' => false, 'progress' => 50]); + $azure->shouldReceive('readRange') + ->once() + ->with('medias/clip.mp4', 2, 3) + ->andReturn('cde'); + + $uploader = new ChunkedCloudUploader(Cache::store(), disk: 'azure', azure: $azure); + + expect($uploader->isObjectStorageDisk())->toBeTrue(); + expect($uploader->receiveChunk('id-azure', 'clip.mp4', 'chunk', 0, 4, 10)) + ->toBe(['done' => false, 'progress' => 50]); + expect($uploader->readRange('medias/clip.mp4', 2, 3))->toBe('cde'); +}); + +test('azure block uploader stages equal-width block ids and commits the ordered blocks once', function () { + $uploader = fakeAzureBlockUploader(useDefaultEncoder: true); + $firstChunk = str_pad(fakeMp4Bytes(), ChunkedCloudUploader::MIN_PART_BYTES, "\0"); + $lastChunk = str_repeat('b', 50); + $totalSize = strlen($firstChunk) + strlen($lastChunk); + + $first = $uploader->receiveChunk('azure-blocks', 'clip.mp4', $firstChunk, 0, strlen($firstChunk) - 1, $totalSize); + $replay = $uploader->receiveChunk('azure-blocks', 'clip.mp4', $firstChunk, 0, strlen($firstChunk) - 1, $totalSize); + $done = $uploader->receiveChunk('azure-blocks', 'clip.mp4', $lastChunk, strlen($firstChunk), $totalSize - 1, $totalSize); + + expect($first)->toMatchArray(['done' => false]); + expect($replay)->toBe($first); + expect($uploader->stagedBlocks)->toHaveCount(2); + expect(array_column($uploader->stagedBlocks, 'block_id'))->toBe($uploader->committedBlockIds); + expect(array_map(fn (array $block): int => strlen($block['block_id']), $uploader->stagedBlocks))->each->toBe(12); + expect(base64_decode($uploader->stagedBlocks[0]['block_id'], true))->toBe('00000001'); + expect(base64_decode($uploader->stagedBlocks[1]['block_id'], true))->toBe('00000002'); + expect($done)->toMatchArray([ + 'done' => true, + 'progress' => 100, + 'size' => $totalSize, + 'mime_type' => 'video/mp4', + ]); + expect($done['path'])->toMatch('/^medias\/[0-9a-f-]+\.mp4$/'); + expect($uploader->stagedBlocks[0]['blob_name'])->toBe(FlatBlobAdapter::encodePath($done['path'])); + expect($uploader->committedContentType)->toBe('video/mp4'); + expect($uploader->receiveChunk('azure-blocks', 'clip.mp4', $lastChunk, strlen($firstChunk), $totalSize - 1, $totalSize))->toBe($done); +}); + +test('azure block uploader retries a failed first block against the same blob and block id', function () { + $uploader = new class extends AzureBlockUploader + { + /** @var array */ + public array $blobNames = []; + + /** @var array */ + public array $blockIds = []; + + public function __construct() + { + parent::__construct( + Cache::store(), + new BlobContainerClient(new Uri('https://account.blob.core.windows.net/test-container')), + fn (string $path): string => 'flat/'.hash('sha256', $path), + ); + } + + protected function stageBlock(BlockBlobClient $client, string $blockId, string $chunk): void + { + $this->blobNames[] = $client->blobName; + $this->blockIds[] = $blockId; + + if (count($this->blockIds) === 1) { + throw new RuntimeException('transient stage failure'); + } + } + + protected function commitBlocks(BlockBlobClient $client, array $blockIds, string $mimeType): void {} + }; + $part = str_repeat('a', ChunkedCloudUploader::MIN_PART_BYTES); + $totalSize = ChunkedCloudUploader::MIN_PART_BYTES + 1; + + expect(fn () => $uploader->receiveChunk('azure-retry-failure', 'clip.mp4', $part, 0, strlen($part) - 1, $totalSize)) + ->toThrow(RuntimeException::class, 'transient stage failure'); + + expect($uploader->receiveChunk('azure-retry-failure', 'clip.mp4', $part, 0, strlen($part) - 1, $totalSize)) + ->toMatchArray(['done' => false]); + expect(array_unique($uploader->blobNames))->toHaveCount(1); + expect(array_unique($uploader->blockIds))->toHaveCount(1); +}); + +test('azure block uploader commits a pdf with the sniffed content type', function () { + $uploader = fakeAzureBlockUploader(); + $pdf = "%PDF-1.7\n".str_repeat('a', 256); + + $done = $uploader->receiveChunk('azure-pdf', 'deck.pdf', $pdf, 0, strlen($pdf) - 1, strlen($pdf)); + + expect($done)->toMatchArray([ + 'done' => true, + 'progress' => 100, + 'size' => strlen($pdf), + 'mime_type' => 'application/pdf', + ]); + expect($done['path'])->toEndWith('.pdf'); + expect($uploader->stagedBlocks)->toHaveCount(1); + expect($uploader->committedContentType)->toBe('application/pdf'); +}); + +test('azure block uploader rejects sanitized session, ordering and minimum-size failures', function () { + $uploader = fakeAzureBlockUploader(); + $part = str_repeat('a', ChunkedCloudUploader::MIN_PART_BYTES); + + expect(fn () => $uploader->receiveChunk('azure-gap', 'clip.mp4', $part, ChunkedCloudUploader::MIN_PART_BYTES, ChunkedCloudUploader::MIN_PART_BYTES * 2 - 1, ChunkedCloudUploader::MIN_PART_BYTES * 3)) + ->toThrow(RuntimeException::class, 'Chunked upload session expired or missing.'); + + $uploader->receiveChunk('azure-gap', 'clip.mp4', $part, 0, strlen($part) - 1, ChunkedCloudUploader::MIN_PART_BYTES * 3); + + expect(fn () => $uploader->receiveChunk('azure-gap', 'clip.mp4', $part, ChunkedCloudUploader::MIN_PART_BYTES + 10, ChunkedCloudUploader::MIN_PART_BYTES * 2 + 9, ChunkedCloudUploader::MIN_PART_BYTES * 3)) + ->toThrow(InvalidArgumentException::class, 'Chunk is out of order.'); + + expect(fn () => $uploader->receiveChunk('azure-small', 'clip.mp4', str_repeat('a', 100), 0, 99, ChunkedCloudUploader::MIN_PART_BYTES + 200)) + ->toThrow(InvalidArgumentException::class); +}); + +test('azure block uploader reads an encoded blob with the requested range', function () { + $history = new ArrayObject; + $stack = HandlerStack::create(new MockHandler([ + new Response(206, ['Content-Range' => 'bytes 40-55/1000'], 'sixteen bytes!!!'), + ])); + $stack->push(Middleware::history($history)); + $uploader = fakeAzureBlockUploader( + fn (BlobClient $client): ClientInterface => new Client(['handler' => $stack]), + ); + + expect($uploader->readRange('medias/clip.mp4', 40, 16))->toBe('sixteen bytes!!!'); + expect($history)->toHaveCount(1); + expect($history[0]['request']->getHeaderLine('Range'))->toBe('bytes=40-55'); + expect($history[0]['request']->getUri()->getPath())->toBe( + '/test-container/flat/'.hash('sha256', 'medias/clip.mp4') + ); +}); + +test('azure block uploader rejects a range request when azure ignores or overruns the range', function () { + $stack = HandlerStack::create(new MockHandler([ + new Response(200, ['Content-Length' => '12000000'], 'whole object'), + ])); + $uploader = fakeAzureBlockUploader( + fn (BlobClient $client): ClientInterface => new Client(['handler' => $stack]), + ); + + expect(fn () => $uploader->readRange('medias/clip.mp4', 40, 16)) + ->toThrow(RuntimeException::class, 'Azure stored object returned an invalid byte range.'); + + $overrunStack = HandlerStack::create(new MockHandler([ + new Response(206, ['Content-Range' => 'bytes 40-55/1000'], str_repeat('x', 1000)), + ])); + $overrunUploader = fakeAzureBlockUploader( + fn (BlobClient $client): ClientInterface => new Client(['handler' => $overrunStack]), + ); + + expect(fn () => $overrunUploader->readRange('medias/clip.mp4', 40, 16)) + ->toThrow(RuntimeException::class, 'Azure stored object returned an invalid byte range.'); +}); + test('chunked cloud uploader rejects undersized non-final parts', function () { $uploader = new ChunkedCloudUploader( Cache::store(), @@ -434,10 +661,10 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 // ─── HTTP: object storage ──────────────────────────────────────── -test('chunked upload uses multipart for videos on s3 disks', function (string $disk) { +test('chunked upload uses multipart for videos on object-storage disks', function (string $disk, string $driver) { config([ 'filesystems.default' => $disk, - "filesystems.disks.{$disk}.driver" => 's3', + "filesystems.disks.{$disk}.driver" => $driver, ]); Storage::fake($disk); seedChunkedUploadWorkspace(); @@ -464,7 +691,12 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 'type' => 'video', ]); expect(test()->workspace->getMedia(Media::COLLECTION_UPLOADS)->first()->path)->toBe("medias/{$disk}-clip.mp4"); -})->with(['s3', 'r2', 'spaces']); +})->with([ + 's3' => ['s3', 's3'], + 'r2' => ['r2', 's3'], + 'spaces' => ['spaces', 's3'], + 'azure' => ['azure', 'azure-flat'], +]); test('chunked upload on s3 still assembles images without multipart', function () { config([ diff --git a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php index ee5197459d..3d9263287e 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php @@ -73,7 +73,25 @@ ->and($this->account->platform)->toBe(Platform::Instagram); }); +test('missing owned attachments fail before a publisher can drop them', function (bool $inReply) { + config(['filesystems.verify_media' => true]); + Storage::delete($this->media->path); + if ($inReply) { + $this->post->update(['media' => [], 'meta' => ['thread_replies' => [[ + 'text' => 'fixture', 'media' => [MediaItem::fromMedia($this->media)->toArray()], + ]]]]); + } + $publisher = Mockery::mock(InstagramPublisher::class); + $publisher->shouldNotReceive('publish'); + $this->app->instance(InstagramPublisher::class, $publisher); + (new PublishToSocialPlatform($this->post->fresh()))->handle(); + expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Failed) + ->and(data_get($this->post->fresh()->error_context, 'reason'))->toBe('media_missing'); +})->with([false, true]); + test('a resume of a publish the provider already accepted is not rechecked', function () { + config(['filesystems.verify_media' => true]); + Storage::delete($this->media->path); $this->media->update(['meta' => ['width' => 2000, 'height' => 1000]]); $this->post->update(['error_context' => [ 'category' => 'platform_unavailable', diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php new file mode 100644 index 0000000000..7bd1e041cb --- /dev/null +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -0,0 +1,132 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id, 'account_id' => $this->user->account_id]); + $this->workspace->members()->attach($this->user->id, membershipPivot('admin')); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + subscribeAccount($this->user->account); + config(['filesystems.default' => 'azure', 'filesystems.disks.azure.driver' => 'azure-flat']); + Storage::fake('azure'); + $this->signing = (object) ['requests' => []]; + $signing = $this->signing; + Storage::disk('azure')->buildTemporaryUrlsUsing(function ($path, $expiry) use ($signing) { + $signing->requests[] = [$path, $expiry]; + + return 'https://example.blob.core.windows.net/media/'.rawurlencode($path).'?sp=r&sig=synthetic'; + }); + $this->media = Media::factory()->temporaryUpload($this->workspace)->create(['path' => 'medias/test.jpg', 'mime_type' => 'image/jpeg']); + Storage::put($this->media->path, 'synthetic-image'); +}); + +test('azure browser media uses stable authorized redirects rather than stored bearer links', function () { + expect($this->media->url)->toBe(route('app.media.view', $this->media)); + expect($this->signing->requests)->toBeEmpty(); + $this->actingAs($this->user)->get($this->media->url) + ->assertRedirect('https://example.blob.core.windows.net/media/medias%2Ftest.jpg?sp=r&sig=synthetic') + ->assertHeader('Cache-Control', 'no-store, private') + ->assertHeader('Referrer-Policy', 'no-referrer'); + expect($this->signing->requests)->toHaveCount(1) + ->and($this->signing->requests[0][1]->getTimestamp())->toBe(now()->addMinutes(15)->getTimestamp()); +}); + +test('preview authorization follows membership rather than the selected workspace', function () { + $other = Workspace::factory()->create(['user_id' => $this->user->id, 'account_id' => $this->user->account_id]); + $this->user->update(['current_workspace_id' => $other->id]); + $this->actingAs($this->user)->get($this->media->url)->assertRedirect(); + expect($this->signing->requests)->toHaveCount(1); +}); + +test('another account and guests cannot mint a media link', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id, 'account_id' => $otherUser->account_id]); + $otherUser->update(['current_workspace_id' => $otherWorkspace->id]); + subscribeAccount($otherUser->account); + $this->actingAs($otherUser)->get($this->media->url)->assertNotFound(); + expect($this->signing->requests)->toBeEmpty(); + auth()->logout(); + $this->get($this->media->url)->assertRedirect(); + expect($this->signing->requests)->toBeEmpty(); +}); + +test('missing old staging objects fail before any link is signed', function () { + Storage::delete($this->media->path); + $this->actingAs($this->user)->get($this->media->url)->assertNotFound(); + try { + MediaUrl::apiItems([MediaItem::fromMedia($this->media)->toArray()]); + test()->fail('Missing media must not be signed.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(404); + } + expect($this->signing->requests)->toBeEmpty(); +}); + +test('post and reply persistence discard signed urls while api output obtains fresh links', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $item = MediaItem::fromMedia($this->media)->toArray(); + $item['url'] = 'https://example.blob.core.windows.net/media/a?sig=synthetic'; + $post->update(['media' => [$item], 'meta' => ['thread_replies' => [['text' => 'fixture', 'media' => [$item]]]]]); + $raw = $post->fresh(); + expect($raw->getRawOriginal('media'))->not->toContain('sig=') + ->and($raw->getRawOriginal('meta'))->not->toContain('sig=') + ->and($raw->media[0]['url'])->toBe(route('app.media.view', $this->media)) + ->and($this->signing->requests)->toBeEmpty(); + $output = (new PostResource($raw))->resolve(); + expect(parse_url($output['media'][0]['url'], PHP_URL_HOST))->toBe('example.blob.core.windows.net') + ->and($this->signing->requests)->toHaveCount(2); +}); + +test('every preview fetch renews the access window after time passes', function () { + $url = $this->media->url; + $this->actingAs($this->user)->get($url)->assertRedirect(); + $firstExpiry = $this->signing->requests[0][1]->getTimestamp(); + $this->travel(20)->minutes(); + $this->get($url)->assertRedirect(); + expect($this->signing->requests[1][1]->getTimestamp())->toBeGreaterThan($firstExpiry); +}); + +test('sas data is removed from nested logs and exceptions', function () { + $url = 'https://example.blob.core.windows.net/media/a?sv=2023-11-03&sig=synthetic'; + $record = RedactStorageCredentials::clean(['nested' => ['uri' => $url], 'exception' => new RuntimeException($url)]); + expect(json_encode($record))->not->toContain('sig=', 'synthetic') + ->and($record['nested']['uri'])->toBe('https://example.blob.core.windows.net/media/a?[redacted]'); +}); + + +test('token API responses mint file links without a browser session and enforce media workspace', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); + $token = passportToken($this->user, $this->workspace); + $this->withToken($token)->getJson(route('api.posts.show', $post)) + ->assertOk()->assertJsonPath('media.0.url', 'https://example.blob.core.windows.net/media/medias%2Ftest.jpg?sp=r&sig=synthetic'); + expect($this->signing->requests)->toHaveCount(1); + $foreign = Media::factory()->temporaryUpload(Workspace::factory()->create())->create(['path' => 'medias/foreign.jpg']); + Storage::put($foreign->path, 'fixture'); + $post->update(['media' => [MediaItem::fromMedia($foreign)->toArray()]]); + $this->withToken($token)->getJson(route('api.posts.show', $post))->assertNotFound(); + expect($this->signing->requests)->toHaveCount(1); +}); + +test('config-only recovery checks missing files in API and browser without minting links', function () { + config(['filesystems.default' => 's3', 'filesystems.verify_media' => true]); + Storage::fake('s3'); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); + $this->actingAs($this->user)->get(route('app.media.view', $this->media))->assertNotFound(); + auth()->logout(); + $this->withToken(passportToken($this->user, $this->workspace))->getJson(route('api.posts.show', $post))->assertNotFound(); + expect($this->signing->requests)->toBeEmpty(); +}); diff --git a/tests/Unit/Support/Media/AzureUserDelegationSasTest.php b/tests/Unit/Support/Media/AzureUserDelegationSasTest.php new file mode 100644 index 0000000000..befc2be059 --- /dev/null +++ b/tests/Unit/Support/Media/AzureUserDelegationSasTest.php @@ -0,0 +1,352 @@ +> */ + public array $scopes = []; + + public function __construct(private readonly AccessToken $accessToken) {} + + public function getToken(TokenRequestContext $context): AccessToken + { + $this->calls++; + $this->scopes[] = $context->scopes; + + return $this->accessToken; + } +} + +final class AzureUserDelegationSasTrackingStore extends ArrayStore +{ + public int $lockCalls = 0; + + public function lock($name, $seconds = 0, $owner = null) + { + $this->lockCalls++; + + return parent::lock($name, $seconds, $owner); + } +} + +/** + * @return array + */ +function azureUserDelegationFixture(): array +{ + $contents = file_get_contents(base_path('tests/fixtures/media/user-delegation-sas.json')); + if ($contents === false) { + throw new RuntimeException('Azure user delegation SAS fixture is missing.'); + } + + return json_decode($contents, true, flags: JSON_THROW_ON_ERROR); +} + +/** + * @param array $fixture + * @param array $overrides + */ +function azureUserDelegationKeyXml(array $fixture, array $overrides = []): string +{ + $key = array_replace(data_get($fixture, 'fixed.delegation_key', []), $overrides); + $element = static fn (string $name, string $value): string => sprintf( + '<%s>%s', + $name, + htmlspecialchars($value, ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $name, + ); + + return '' + .$element('SignedOid', data_get($key, 'signed_oid')) + .$element('SignedTid', data_get($key, 'signed_tid')) + .$element('SignedStart', data_get($key, 'signed_start')) + .$element('SignedExpiry', data_get($key, 'signed_expiry')) + .$element('SignedService', data_get($key, 'signed_service')) + .$element('SignedVersion', data_get($key, 'signed_version')) + .$element('Value', data_get($key, 'value')) + .''; +} + +function azureUserDelegationFakeCredential(): AzureUserDelegationSasFakeCredential +{ + return new AzureUserDelegationSasFakeCredential(new AccessToken( + 'entra-access-token-secret', + CarbonImmutable::parse('2026-10-25T11:00:00Z'), + 'Bearer', + )); +} + +/** + * @param array $fixture + */ +function azureUserDelegationSigner( + array $fixture, + TokenCredential $credential, + Repository $cache, +): AzureUserDelegationSas { + return new AzureUserDelegationSas( + $credential, + data_get($fixture, 'fixed.account_name'), + data_get($fixture, 'fixed.container_name'), + $cache, + ); +} + +function azureUserDelegationExpiry(array $fixture): CarbonImmutable +{ + return CarbonImmutable::parse(data_get($fixture, 'fixed.expiry')); +} + +beforeEach(function (): void { + Cache::flush(); + $this->travelTo(CarbonImmutable::parse('2026-10-25T10:00:00Z')); +}); + +test('it matches deterministic official sdk signing vectors', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $store = new AzureUserDelegationSasTrackingStore; + $cache = new Repository($store); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response( + azureUserDelegationKeyXml($fixture), + 200, + ['Content-Type' => 'application/xml'], + ), + ]); + + $signer = azureUserDelegationSigner($fixture, $credential, $cache); + + foreach (data_get($fixture, 'vectors') as $vector) { + expect($signer->url(data_get($vector, 'blob_name'), azureUserDelegationExpiry($fixture))) + ->toBe(data_get($vector, 'url')); + } + + expect($credential->calls)->toBe(1) + ->and($credential->scopes)->toBe([['https://storage.azure.com/.default']]) + ->and($store->lockCalls)->toBe(1); + + Http::assertSentCount(1); + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && $request->url() === 'https://trypostsynthetic.blob.core.windows.net/?restype=service&comp=userdelegationkey' + && $request->hasHeader('Authorization', 'Bearer entra-access-token-secret') + && $request->hasHeader('x-ms-version', '2023-11-03') + && $request->body() === '2026-10-25T09:55:00Z2026-10-27T10:00:00Z'; + }); +}); + +test('it caches only the encrypted delegation key and reuses it across processes', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $store = new AzureUserDelegationSasTrackingStore; + $cache = new Repository($store); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response(azureUserDelegationKeyXml($fixture)), + ]); + + $signer = azureUserDelegationSigner($fixture, $credential, $cache); + $signer->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + + $entries = $store->all(); + expect($entries)->toHaveCount(1); + + $encrypted = data_get(array_values($entries)[0], 'value'); + expect($encrypted)->toBeString() + ->not->toContain(data_get($fixture, 'fixed.delegation_key.value')) + ->not->toContain('entra-access-token-secret') + ->not->toContain('sig='); + + $payload = json_decode(Crypt::decryptString($encrypted), true, flags: JSON_THROW_ON_ERROR); + expect(data_get($payload, 'key.value'))->toBe(data_get($fixture, 'fixed.delegation_key.value')) + ->and(data_get($payload, 'fetched_at'))->toBe(CarbonImmutable::now()->getTimestamp()); + + $signer->url(data_get($fixture, 'vectors.1.blob_name'), azureUserDelegationExpiry($fixture)); + $secondSigner = azureUserDelegationSigner($fixture, $credential, $cache); + $secondSigner->url(data_get($fixture, 'vectors.2.blob_name'), azureUserDelegationExpiry($fixture)); + + expect($credential->calls)->toBe(1) + ->and($store->lockCalls)->toBe(1); + + $this->travel(299)->seconds(); + $cachedSigner = azureUserDelegationSigner($fixture, $credential, $cache); + $cachedSigner->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + expect($credential->calls)->toBe(1); + + $this->travel(1)->seconds(); + $refreshedSigner = azureUserDelegationSigner($fixture, $credential, $cache); + $refreshedSigner->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + + expect($credential->calls)->toBe(2) + ->and($store->lockCalls)->toBe(2); + Http::assertSentCount(2); +}); + +test('it namespaces the delegation key cache by workload identity', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $cache = new Repository(new AzureUserDelegationSasTrackingStore); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response(azureUserDelegationKeyXml($fixture)), + ]); + $tenantId = getenv('AZURE_TENANT_ID'); + $clientId = getenv('AZURE_CLIENT_ID'); + + try { + putenv('AZURE_TENANT_ID=synthetic-tenant-a'); + putenv('AZURE_CLIENT_ID=synthetic-client-a'); + azureUserDelegationSigner($fixture, $credential, $cache) + ->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + + putenv('AZURE_TENANT_ID=synthetic-tenant-b'); + putenv('AZURE_CLIENT_ID=synthetic-client-b'); + azureUserDelegationSigner($fixture, $credential, $cache) + ->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + } finally { + $tenantId === false ? putenv('AZURE_TENANT_ID') : putenv("AZURE_TENANT_ID={$tenantId}"); + $clientId === false ? putenv('AZURE_CLIENT_ID') : putenv("AZURE_CLIENT_ID={$clientId}"); + } + + expect($credential->calls)->toBe(2) + ->and(count($cache->getStore()->all()))->toBe(2); +}); + +test('it rejects expired and overlong urls before resolving a key', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $signer = azureUserDelegationSigner($fixture, $credential, new Repository(new AzureUserDelegationSasTrackingStore)); + + expect(fn () => $signer->url('avatar.webp', CarbonImmutable::now())) + ->toThrow(InvalidArgumentException::class, 'Azure SAS expiry must be in the future and at most 26 hours away.') + ->and(fn () => $signer->url('avatar.webp', CarbonImmutable::now()->addHours(26)->addSecond())) + ->toThrow(InvalidArgumentException::class, 'Azure SAS expiry must be in the future and at most 26 hours away.') + ->and($credential->calls)->toBe(0); +}); + +test('it rejects path-like and traversing physical blob names', function (string $physicalBlobName) { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $signer = azureUserDelegationSigner($fixture, $credential, new Repository(new AzureUserDelegationSasTrackingStore)); + + expect(fn () => $signer->url($physicalBlobName, azureUserDelegationExpiry($fixture))) + ->toThrow(InvalidArgumentException::class, 'Azure blob name must be a flat, non-empty path without traversal.') + ->and($credential->calls)->toBe(0); +})->with([ + 'empty' => [''], + 'blank' => [' '], + 'dot' => ['.'], + 'double dot' => ['..'], + 'slash' => ['folder/avatar.webp'], + 'backslash' => ['folder\\avatar.webp'], + 'control character' => ["avatar\n.webp"], +]); + +test('it pins account and container names to an exact azure host', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $cache = new Repository(new AzureUserDelegationSasTrackingStore); + + foreach (['', 'ab', 'trypost-synthetic', 'trypost.example', 'TryPostSynthetic', str_repeat('a', 25)] as $accountName) { + expect(fn () => new AzureUserDelegationSas($credential, $accountName, 'media', $cache)) + ->toThrow(InvalidArgumentException::class, 'Azure storage account name is invalid.'); + } + + foreach (['', 'ab', 'Media', 'media_name', 'media--one', str_repeat('a', 64)] as $containerName) { + expect(fn () => new AzureUserDelegationSas($credential, 'trypostsynthetic', $containerName, $cache)) + ->toThrow(InvalidArgumentException::class, 'Azure blob container name is invalid.'); + } +}); + +test('it refuses to fetch without a lock-capable cache', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $store = Mockery::mock(Store::class); + $store->shouldReceive('get')->once()->andReturnNull(); + $signer = azureUserDelegationSigner($fixture, $credential, new Repository($store)); + + expect(fn () => $signer->url('avatar.webp', azureUserDelegationExpiry($fixture))) + ->toThrow(RuntimeException::class, 'Azure user delegation SAS caching requires a cache store with lock support.') + ->and($credential->calls)->toBe(0); +}); + +test('it rejects a key that does not cover the requested url bounds', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $cache = new Repository(new AzureUserDelegationSasTrackingStore); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response( + azureUserDelegationKeyXml($fixture, ['signed_expiry' => '2026-10-26T11:59:59Z']), + ), + ]); + + $signer = azureUserDelegationSigner($fixture, $credential, $cache); + + expect(fn () => $signer->url('avatar.webp', azureUserDelegationExpiry($fixture))) + ->toThrow(RuntimeException::class, 'Azure returned a user delegation key outside the requested bounds.'); + + expect(collect($cache->getStore()->all()))->toBeEmpty(); +}); + +test('it produces a different deterministic signature when the delegation key changes', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $cache = new Repository(new AzureUserDelegationSasTrackingStore); + $key = base64_decode(data_get($fixture, 'fixed.delegation_key.value'), true); + $key[0] = chr(ord($key[0]) ^ 1); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response( + azureUserDelegationKeyXml($fixture, ['value' => base64_encode($key)]), + ), + ]); + + $signer = azureUserDelegationSigner($fixture, $credential, $cache); + $tamperedUrl = $signer->url(data_get($fixture, 'vectors.0.blob_name'), azureUserDelegationExpiry($fixture)); + + parse_str((string) parse_url($tamperedUrl, PHP_URL_QUERY), $tamperedQuery); + parse_str((string) parse_url(data_get($fixture, 'vectors.0.url'), PHP_URL_QUERY), $expectedQuery); + + expect(data_get($tamperedQuery, 'sig')) + ->not->toBe(data_get($expectedQuery, 'sig')) + ->and(collect($tamperedQuery)->except('sig')->all()) + ->toBe(collect($expectedQuery)->except('sig')->all()); +}); + +test('it sanitizes failed key responses instead of exposing their bodies', function () { + $fixture = azureUserDelegationFixture(); + $credential = azureUserDelegationFakeCredential(); + $cache = new Repository(new AzureUserDelegationSasTrackingStore); + Http::fake([ + 'https://trypostsynthetic.blob.core.windows.net/*' => Http::response( + 'delegation-key-secret entra-access-token-secret', + 403, + ), + ]); + + $signer = azureUserDelegationSigner($fixture, $credential, $cache); + + try { + $signer->url('avatar.webp', azureUserDelegationExpiry($fixture)); + $this->fail('The signer accepted a failed delegation key response.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toBe('Azure user delegation key request failed with HTTP 403.') + ->not->toContain('delegation-key-secret') + ->not->toContain('entra-access-token-secret') + ->and($exception->getPrevious())->toBeNull(); + } +}); diff --git a/tests/Unit/Support/Media/FlatBlobAdapterTest.php b/tests/Unit/Support/Media/FlatBlobAdapterTest.php new file mode 100644 index 0000000000..f8f507f15e --- /dev/null +++ b/tests/Unit/Support/Media/FlatBlobAdapterTest.php @@ -0,0 +1,221 @@ +blobRoot = sys_get_temp_dir().'/trypost-flat-blob-'.bin2hex(random_bytes(8)); + mkdir($this->blobRoot, 0777, true); + $this->inner = new LocalFilesystemAdapter($this->blobRoot, lazyRootCreation: true); + $this->adapter = new FlatBlobAdapter($this->inner); + $this->config = new Config; +}); + +afterEach(function (): void { + $remove = static function (string $path) use (&$remove): void { + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + if (! is_dir($path)) { + return; + } + + foreach (scandir($path) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + $remove($path.'/'.$entry); + } + } + + rmdir($path); + }; + + $remove($this->blobRoot); +}); + +test('encoding and decoding logical paths is reversible and rejects ambiguous inputs', function (): void { + expect(FlatBlobAdapter::encodePath('/media/2026//photo.jpg'))->toBe('media~2026~photo.jpg') + ->and(FlatBlobAdapter::decodePath('media~2026~photo.jpg'))->toBe('media/2026/photo.jpg') + ->and(FlatBlobAdapter::encodePath('portrait.jpg'))->toBe('portrait.jpg'); +}); + +test('invalid logical and physical paths are refused', function (string $path): void { + expect(fn () => FlatBlobAdapter::encodePath($path))->toThrow(InvalidArgumentException::class); +})->with([ + 'empty' => '', + 'separator only' => '/', + 'reserved separator' => 'media/photo~1.jpg', + 'traversal' => 'media/../photo.jpg', + 'dot segment' => 'media/./photo.jpg', +]); + +test('physical paths with non-inverse separators are refused', function (string $path): void { + expect(fn () => FlatBlobAdapter::decodePath($path))->toThrow(InvalidArgumentException::class); +})->with([ + 'empty' => '', + 'hierarchical' => 'media/photo.jpg', + 'leading separator' => '~media~photo.jpg', + 'trailing separator' => 'media~photo.jpg~', + 'empty segment' => 'media~~photo.jpg', + 'traversal segment' => 'media~..~photo.jpg', +]); + +test('file operations map to flat names and decode metadata paths', function (): void { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', true); + + expect($png)->toBeString(); + + $this->adapter->write('media/source.png', $png, new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC])); + + expect($this->adapter->fileExists('media/source.png'))->toBeTrue() + ->and($this->inner->fileExists('media~source.png'))->toBeTrue() + ->and($this->inner->fileExists('media/source.png'))->toBeFalse() + ->and($this->adapter->read('media/source.png'))->toBe($png); + + $mime = $this->adapter->mimeType('media/source.png'); + $size = $this->adapter->fileSize('media/source.png'); + $modified = $this->adapter->lastModified('media/source.png'); + $visibility = $this->adapter->visibility('media/source.png'); + + expect($mime->path())->toBe('media/source.png') + ->and($mime->mimeType())->toBe('image/png') + ->and($size->path())->toBe('media/source.png') + ->and($size->fileSize())->toBe(strlen($png)) + ->and($modified->path())->toBe('media/source.png') + ->and($modified->lastModified())->toBeInt() + ->and($visibility->path())->toBe('media/source.png') + ->and($visibility->visibility())->toBe(Visibility::PUBLIC); + + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, 'stream payload'); + rewind($stream); + $this->adapter->writeStream('media/stream.bin', $stream, $this->config); + fclose($stream); + + $readStream = $this->adapter->readStream('media/stream.bin'); + $streamContents = stream_get_contents($readStream); + fclose($readStream); + + expect($streamContents)->toBe('stream payload') + ->and($this->inner->fileExists('media~stream.bin'))->toBeTrue(); + + $this->adapter->copy('media/source.png', 'media/copied.png', $this->config); + $this->adapter->move('media/copied.png', 'media/moved.png', $this->config); + + expect($this->adapter->fileExists('media/source.png'))->toBeTrue() + ->and($this->adapter->fileExists('media/copied.png'))->toBeFalse() + ->and($this->adapter->fileExists('media/moved.png'))->toBeTrue() + ->and($this->inner->fileExists('media~moved.png'))->toBeTrue(); + + $this->adapter->setVisibility('media/moved.png', Visibility::PRIVATE); + + expect($this->adapter->visibility('media/moved.png')->visibility())->toBe(Visibility::PRIVATE); + + $this->adapter->delete('media/stream.bin'); + + expect($this->adapter->fileExists('media/stream.bin'))->toBeFalse(); +}); + +test('listings rebuild logical shallow and deep trees with file metadata', function (): void { + $inner = new class($this->blobRoot) extends LocalFilesystemAdapter + { + public function listContents(string $path, bool $deep): iterable + { + foreach (parent::listContents($path, $deep) as $item) { + if ($item instanceof FileAttributes && $item->path() === 'media~cover.png') { + yield new FileAttributes( + $item->path(), + 17, + Visibility::PUBLIC, + 1_700_000_000, + 'image/png', + ['etag' => 'synthetic'], + ); + + continue; + } + + yield $item; + } + } + }; + $adapter = new FlatBlobAdapter($inner); + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', true); + + $inner->write('media~cover.png', $png, $this->config); + $inner->write('media~2026~january~photo.png', $png, $this->config); + $inner->write('media~2026~february~clip.bin', 'clip', $this->config); + $inner->write('library~note.txt', 'note', $this->config); + + $shallow = collect(iterator_to_array($adapter->listContents('media', false), false)); + + expect($shallow->map(fn (FileAttributes|DirectoryAttributes $item): string => $item->path())->all())->toEqualCanonicalizing([ + 'media/cover.png', + 'media/2026', + ])->and($shallow->first(fn (FileAttributes|DirectoryAttributes $item): bool => $item->path() === 'media/2026'))->toBeInstanceOf(DirectoryAttributes::class); + + $cover = $shallow->first(fn (FileAttributes|DirectoryAttributes $item): bool => $item->path() === 'media/cover.png'); + + expect($cover)->toBeInstanceOf(FileAttributes::class) + ->and($cover->fileSize())->toBe(17) + ->and($cover->visibility())->toBe(Visibility::PUBLIC) + ->and($cover->lastModified())->toBe(1_700_000_000) + ->and($cover->mimeType())->toBe('image/png') + ->and($cover->extraMetadata())->toBe(['etag' => 'synthetic']); + + $deep = collect(iterator_to_array($adapter->listContents('media', true), false)); + + expect($deep->map(fn (FileAttributes|DirectoryAttributes $item): string => $item->path())->all())->toEqualCanonicalizing([ + 'media/cover.png', + 'media/2026', + 'media/2026/january', + 'media/2026/january/photo.png', + 'media/2026/february', + 'media/2026/february/clip.bin', + ])->and($deep->contains(fn (FileAttributes|DirectoryAttributes $item): bool => $item->path() === 'library/note.txt'))->toBeFalse(); +}); + +test('directory probes and deletion use an exact flat-name prefix', function (): void { + $this->inner->write('media', 'root file', $this->config); + $this->inner->write('media~photo.jpg', 'photo', $this->config); + $this->inner->write('media~nested~one.jpg', 'one', $this->config); + $this->inner->write('media2~sibling.jpg', 'sibling', $this->config); + $this->inner->write('medi~near.jpg', 'near', $this->config); + + expect($this->adapter->directoryExists('media'))->toBeTrue() + ->and($this->adapter->directoryExists('media/nested'))->toBeTrue() + ->and($this->adapter->directoryExists('media/nested/missing'))->toBeFalse() + ->and($this->adapter->directoryExists('missing'))->toBeFalse(); + + $this->adapter->createDirectory('media/empty', $this->config); + + expect($this->adapter->directoryExists('media/empty'))->toBeFalse() + ->and(is_dir($this->blobRoot.'/media~empty'))->toBeFalse(); + + expect(fn () => $this->adapter->deleteDirectory(''))->toThrow(InvalidArgumentException::class) + ->and(fn () => $this->adapter->deleteDirectory('/'))->toThrow(InvalidArgumentException::class) + ->and($this->inner->fileExists('media~photo.jpg'))->toBeTrue(); + + $this->adapter->deleteDirectory('media'); + + expect($this->adapter->fileExists('media'))->toBeTrue() + ->and($this->inner->fileExists('media~photo.jpg'))->toBeFalse() + ->and($this->inner->fileExists('media~nested~one.jpg'))->toBeFalse() + ->and($this->inner->fileExists('media2~sibling.jpg'))->toBeTrue() + ->and($this->inner->fileExists('medi~near.jpg'))->toBeTrue(); +}); + +test('empty and traversal file operations are refused', function (): void { + expect(fn () => $this->adapter->fileExists(''))->toThrow(InvalidArgumentException::class) + ->and(fn () => $this->adapter->read('../outside.txt'))->toThrow(InvalidArgumentException::class) + ->and(fn () => $this->adapter->delete('media/./photo.jpg'))->toThrow(InvalidArgumentException::class) + ->and(fn () => $this->adapter->createDirectory('../outside', $this->config))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/fixtures/media/generate_user_delegation_sas.py b/tests/fixtures/media/generate_user_delegation_sas.py new file mode 100644 index 0000000000..9e28cd4bfe --- /dev/null +++ b/tests/fixtures/media/generate_user_delegation_sas.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import datetime, timezone +from importlib.metadata import version +from pathlib import Path +from urllib.parse import quote + +from azure.storage.blob import BlobSasPermissions, UserDelegationKey +from azure.storage.blob import _shared_access_signature as sas_module +from azure.storage.blob._shared_access_signature import BlobSharedAccessSignature + + +ACCOUNT = "trypostsynthetic" +CONTAINER = "media" +SAS_VERSION = "2023-11-03" +KEY_VALUE = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + +KEY = UserDelegationKey() +KEY.signed_oid = "11111111-1111-4111-8111-111111111111" +KEY.signed_tid = "22222222-2222-4222-8222-222222222222" +KEY.signed_start = "2026-10-25T09:55:00Z" +KEY.signed_expiry = "2026-10-27T10:00:00Z" +KEY.signed_service = "b" +KEY.signed_version = SAS_VERSION +KEY.value = KEY_VALUE + +START = datetime(2026, 10, 25, 9, 55, tzinfo=timezone.utc) +EXPIRY = datetime(2026, 10, 26, 12, 0, tzinfo=timezone.utc) +BLOB_NAMES = [ + "avatar-01~v1.webp", + "photo #1 + cafe%?.jpg", + "unicode-caf\u00e9.png", +] + +vectors = [] +capture_enabled = False +captured: list[str] = [] +sign_string = sas_module.sign_string + + +def capture_string_to_sign(key: str, statement: str) -> str: + if capture_enabled: + captured.append(statement) + + return sign_string(key, statement) + + +sas_module.sign_string = capture_string_to_sign + +for blob_name in BLOB_NAMES: + signer = BlobSharedAccessSignature(ACCOUNT, user_delegation_key=KEY) + signer.x_ms_version = SAS_VERSION + captured.clear() + capture_enabled = True + + query = signer.generate_blob( + CONTAINER, + blob_name, + permission=BlobSasPermissions(read=True), + expiry=EXPIRY, + start=START, + protocol="https", + ) + capture_enabled = False + encoded_blob_name = quote(blob_name, safe="~") + vectors.append( + { + "blob_name": blob_name, + "encoded_blob_name": encoded_blob_name, + "url": f"https://{ACCOUNT}.blob.core.windows.net/{CONTAINER}/{encoded_blob_name}?{query}", + "sas_query": query, + "string_to_sign": captured[-1], + } + ) + +fixture = { + "generator": { + "package": "azure-storage-blob", + "version": version("azure-storage-blob"), + "command": "uv run --with azure-storage-blob==12.19.0 python tests/fixtures/media/generate_user_delegation_sas.py", + "script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "notes": ( + "Generated offline with the official Python SDK, a synthetic fixed delegation key, " + "fixed timestamps, HTTPS-only read permission, and the SDK's 2023-11-03 SAS version. " + "No Azure credentials or services were used." + ), + }, + "fixed": { + "account_name": ACCOUNT, + "container_name": CONTAINER, + "sas_version": SAS_VERSION, + "start": "2026-10-25T09:55:00Z", + "expiry": "2026-10-26T12:00:00Z", + "delegation_key": { + "signed_oid": KEY.signed_oid, + "signed_tid": KEY.signed_tid, + "signed_start": KEY.signed_start, + "signed_expiry": KEY.signed_expiry, + "signed_service": KEY.signed_service, + "signed_version": KEY.signed_version, + "value": KEY.value, + }, + }, + "vectors": vectors, +} + +print(json.dumps(fixture, indent=2, ensure_ascii=False)) diff --git a/tests/fixtures/media/user-delegation-sas.json b/tests/fixtures/media/user-delegation-sas.json new file mode 100644 index 0000000000..280eea0bf0 --- /dev/null +++ b/tests/fixtures/media/user-delegation-sas.json @@ -0,0 +1,48 @@ +{ + "generator": { + "package": "azure-storage-blob", + "version": "12.19.0", + "command": "uv run --with azure-storage-blob==12.19.0 python tests/fixtures/media/generate_user_delegation_sas.py", + "script_sha256": "918ab8c0ffeaf631d60596cd0e93930d41449839c9f594ba3a832bb555e5e7e0", + "notes": "Generated offline with the official Python SDK, a synthetic fixed delegation key, fixed timestamps, HTTPS-only read permission, and the SDK's 2023-11-03 SAS version. No Azure credentials or services were used." + }, + "fixed": { + "account_name": "trypostsynthetic", + "container_name": "media", + "sas_version": "2023-11-03", + "start": "2026-10-25T09:55:00Z", + "expiry": "2026-10-26T12:00:00Z", + "delegation_key": { + "signed_oid": "11111111-1111-4111-8111-111111111111", + "signed_tid": "22222222-2222-4222-8222-222222222222", + "signed_start": "2026-10-25T09:55:00Z", + "signed_expiry": "2026-10-27T10:00:00Z", + "signed_service": "b", + "signed_version": "2023-11-03", + "value": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + } + }, + "vectors": [ + { + "blob_name": "avatar-01~v1.webp", + "encoded_blob_name": "avatar-01~v1.webp", + "url": "https://trypostsynthetic.blob.core.windows.net/media/avatar-01~v1.webp?st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=k2%2BxwRZAEp%2BgA7K/D0eBkzBSnhgLO3AlfDGKlkY1h%2BY%3D", + "sas_query": "st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=k2%2BxwRZAEp%2BgA7K/D0eBkzBSnhgLO3AlfDGKlkY1h%2BY%3D", + "string_to_sign": "r\n2026-10-25T09:55:00Z\n2026-10-26T12:00:00Z\n/blob/trypostsynthetic/media/avatar-01~v1.webp\n11111111-1111-4111-8111-111111111111\n22222222-2222-4222-8222-222222222222\n2026-10-25T09:55:00Z\n2026-10-27T10:00:00Z\nb\n2023-11-03\n\n\n\n\nhttps\n2023-11-03\nb\n\n\n\n\n\n\n" + }, + { + "blob_name": "photo #1 + cafe%?.jpg", + "encoded_blob_name": "photo%20%231%20%2B%20cafe%25%3F.jpg", + "url": "https://trypostsynthetic.blob.core.windows.net/media/photo%20%231%20%2B%20cafe%25%3F.jpg?st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=OEwQwUj8RzHeQdfRTjWXDw0/0LwBLzgee8r0NIMzzfc%3D", + "sas_query": "st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=OEwQwUj8RzHeQdfRTjWXDw0/0LwBLzgee8r0NIMzzfc%3D", + "string_to_sign": "r\n2026-10-25T09:55:00Z\n2026-10-26T12:00:00Z\n/blob/trypostsynthetic/media/photo #1 + cafe%?.jpg\n11111111-1111-4111-8111-111111111111\n22222222-2222-4222-8222-222222222222\n2026-10-25T09:55:00Z\n2026-10-27T10:00:00Z\nb\n2023-11-03\n\n\n\n\nhttps\n2023-11-03\nb\n\n\n\n\n\n\n" + }, + { + "blob_name": "unicode-café.png", + "encoded_blob_name": "unicode-caf%C3%A9.png", + "url": "https://trypostsynthetic.blob.core.windows.net/media/unicode-caf%C3%A9.png?st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=Qo/2bXiESlAELaALBQcsgw3jFKkA/5Tqn7Z70IoRp1I%3D", + "sas_query": "st=2026-10-25T09%3A55%3A00Z&se=2026-10-26T12%3A00%3A00Z&sp=r&spr=https&sv=2023-11-03&sr=b&skoid=11111111-1111-4111-8111-111111111111&sktid=22222222-2222-4222-8222-222222222222&skt=2026-10-25T09%3A55%3A00Z&ske=2026-10-27T10%3A00%3A00Z&sks=b&skv=2023-11-03&sig=Qo/2bXiESlAELaALBQcsgw3jFKkA/5Tqn7Z70IoRp1I%3D", + "string_to_sign": "r\n2026-10-25T09:55:00Z\n2026-10-26T12:00:00Z\n/blob/trypostsynthetic/media/unicode-café.png\n11111111-1111-4111-8111-111111111111\n22222222-2222-4222-8222-222222222222\n2026-10-25T09:55:00Z\n2026-10-27T10:00:00Z\nb\n2023-11-03\n\n\n\n\nhttps\n2023-11-03\nb\n\n\n\n\n\n\n" + } + ] +} From 46b6656d8a23ffeaed65ddde2e8ee2eb257568a7 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 18:52:14 +0100 Subject: [PATCH 03/10] refactor(media): use the flat encoder directly for block uploads --- app/Services/Media/AzureBlockUploader.php | 17 ++--------------- tests/Feature/ChunkedCloudUploadTest.php | 10 ++++------ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/app/Services/Media/AzureBlockUploader.php b/app/Services/Media/AzureBlockUploader.php index 1a747def87..70cd8c2163 100644 --- a/app/Services/Media/AzureBlockUploader.php +++ b/app/Services/Media/AzureBlockUploader.php @@ -31,23 +31,17 @@ class AzureBlockUploader private const MIME_SNIFF_BYTES = 8192; - /** @var (Closure(string): string)|null */ - private readonly ?Closure $encodePath; - /** @var (Closure(BlobClient): ClientInterface)|null */ private readonly ?Closure $httpClientFactory; /** - * @param (Closure(string): string)|null $encodePath * @param (Closure(BlobClient): ClientInterface)|null $httpClientFactory */ public function __construct( private readonly CacheRepository $cache, private readonly BlobContainerClient $container, - ?Closure $encodePath = null, ?Closure $httpClientFactory = null, ) { - $this->encodePath = $encodePath; $this->httpClientFactory = $httpClientFactory; } @@ -83,7 +77,7 @@ public function readRange(string $key, int $offset, int $length): string throw new InvalidArgumentException('The requested byte range is invalid.'); } - $client = $this->container->getBlobClient($this->encodedBlobName($key)); + $client = $this->container->getBlobClient(FlatBlobAdapter::encodePath($key)); return $this->downloadRange($client, $offset, $length); } @@ -202,7 +196,7 @@ private function startUpload(string $fileName, string $chunk, int $totalSize): a return [ 'path' => $path, - 'blob_name' => $this->encodedBlobName($path), + 'blob_name' => FlatBlobAdapter::encodePath($path), 'mime_type' => $this->detectMimeType($header, $extension), 'header' => $header, 'block_ids' => [], @@ -256,13 +250,6 @@ private function detectMimeType(string $header, string $extension): string return MediaType::mimeTypeFromExtension($extension) ?? 'application/octet-stream'; } - private function encodedBlobName(string $path): string - { - return $this->encodePath === null - ? FlatBlobAdapter::encodePath($path) - : ($this->encodePath)($path); - } - /** * @template T * diff --git a/tests/Feature/ChunkedCloudUploadTest.php b/tests/Feature/ChunkedCloudUploadTest.php index 77e0e4b8b2..20d384265e 100644 --- a/tests/Feature/ChunkedCloudUploadTest.php +++ b/tests/Feature/ChunkedCloudUploadTest.php @@ -84,11 +84,11 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 ); } -function fakeAzureBlockUploader(?Closure $httpClientFactory = null, bool $useDefaultEncoder = false): AzureBlockUploader +function fakeAzureBlockUploader(?Closure $httpClientFactory = null): AzureBlockUploader { $container = new BlobContainerClient(new Uri('https://account.blob.core.windows.net/test-container')); - return new class($container, $httpClientFactory, $useDefaultEncoder) extends AzureBlockUploader + return new class($container, $httpClientFactory) extends AzureBlockUploader { /** @var array */ public array $stagedBlocks = []; @@ -101,12 +101,10 @@ function fakeAzureBlockUploader(?Closure $httpClientFactory = null, bool $useDef public function __construct( BlobContainerClient $container, ?Closure $httpClientFactory, - bool $useDefaultEncoder, ) { parent::__construct( Cache::store(), $container, - $useDefaultEncoder ? null : fn (string $path): string => 'flat/'.hash('sha256', $path), $httpClientFactory, ); } @@ -245,7 +243,7 @@ protected function commitBlocks(BlockBlobClient $client, array $blockIds, string }); test('azure block uploader stages equal-width block ids and commits the ordered blocks once', function () { - $uploader = fakeAzureBlockUploader(useDefaultEncoder: true); + $uploader = fakeAzureBlockUploader(); $firstChunk = str_pad(fakeMp4Bytes(), ChunkedCloudUploader::MIN_PART_BYTES, "\0"); $lastChunk = str_repeat('b', 50); $totalSize = strlen($firstChunk) + strlen($lastChunk); @@ -362,7 +360,7 @@ protected function commitBlocks(BlockBlobClient $client, array $blockIds, string expect($history)->toHaveCount(1); expect($history[0]['request']->getHeaderLine('Range'))->toBe('bytes=40-55'); expect($history[0]['request']->getUri()->getPath())->toBe( - '/test-container/flat/'.hash('sha256', 'medias/clip.mp4') + '/test-container/medias~clip.mp4' ); }); From 9f47832f6c13944122ad49527c4c63bf63928300 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 18:56:54 +0100 Subject: [PATCH 04/10] docs(media): record storage primitive contracts --- docs/project/2026-10-25-azure-blob-media-plan.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/project/2026-10-25-azure-blob-media-plan.md b/docs/project/2026-10-25-azure-blob-media-plan.md index 81ef4550bb..fa2021644e 100644 --- a/docs/project/2026-10-25-azure-blob-media-plan.md +++ b/docs/project/2026-10-25-azure-blob-media-plan.md @@ -5,3 +5,13 @@ Add an optional private Azure Blob disk while preserving the existing S3 and loc Implement and verify flat filesystem behavior, bounded block uploads, URL authorization/freshness, canonical serialization and credential redaction. Missing owned attachments fail publication before any provider call. Reuse existing media ownership and publication checkpoints. No database migration or existing-media transfer is included. The source package ends with reviewed, CI-checked draft delivery. Entra/RBAC/SAS acceptance requires a separate synthetic Azure qualification; emulator/fake tests cannot prove it. Provisioning, release, deployment, publishing and data deletion require separate approval. + +## Primitive impact + +| Product primitive | Disposition | Contract and consumers | Source evidence | +| --- | --- | --- | --- | +| Owned media | Extend delivery | Stable file identity/owner and existing adopt/copy/delete lifecycle. Browser access uses workspace authorization; API/MCP and publishers receive expiring read links. Missing owned attachments fail before a new provider call. | Media, SyncOwnedMedia, DeleteOwnedMedia, MediaUrl | +| Publication | Reuse | Workers resolve access when executing. Accepted provider checkpoints remain authoritative during retries; no duplicate post or new scheduling state. | PublishToSocialPlatform, PublishCheckpoint, ThreadProgress | +| Workspace membership | Reuse | Browser file access follows the file workspace, independently of another tab's selected workspace. Existing token/workspace guards remain the API/MCP boundary. | WorkspacePolicy, MediaFileController, API resources | + +The optional backend changes delivery policy, not media ownership or database identity. Logical paths remain stable over the flat physical layout. No new product primitive is introduced. From bac310eb93a7ab3abf9d3d91288e5a6d44f3413b Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 19:00:42 +0100 Subject: [PATCH 05/10] test(media): cover MCP links and publication-time access --- tests/Feature/Media/AzureBlobStorageTest.php | 23 +++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index 7bd1e041cb..970df5617c 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -4,6 +4,8 @@ use App\Dto\MediaItem; use App\Http\Resources\Api\PostResource; +use App\Mcp\Servers\TryPostServer; +use App\Mcp\Tools\Post\GetPostTool; use App\Models\Media; use App\Models\Post; use App\Models\User; @@ -11,6 +13,7 @@ use App\Support\Media\MediaUrl; use App\Support\Media\RedactStorageCredentials; use Illuminate\Support\Facades\Storage; +use Illuminate\Testing\Fluent\AssertableJson; use Symfony\Component\HttpKernel\Exception\HttpException; beforeEach(function () { @@ -105,7 +108,6 @@ ->and($record['nested']['uri'])->toBe('https://example.blob.core.windows.net/media/a?[redacted]'); }); - test('token API responses mint file links without a browser session and enforce media workspace', function () { $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); @@ -130,3 +132,22 @@ $this->withToken(passportToken($this->user, $this->workspace))->getJson(route('api.posts.show', $post))->assertNotFound(); expect($this->signing->requests)->toBeEmpty(); }); + +test('MCP post output uses the same authorized expiring media contract', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); + TryPostServer::actingAs($this->user) + ->tool(GetPostTool::class, ['post_id' => $post->id]) + ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('media.0.url', 'https://example.blob.core.windows.net/media/medias%2Ftest.jpg?sp=r&sig=synthetic')->etc()); + expect($this->signing->requests)->toHaveCount(1); +}); + +test('the publishing consumer resolves media access when it executes', function () { + $item = MediaItem::fromMedia($this->media); + expect($this->signing->requests)->toBeEmpty(); + $this->travel(2)->days(); + expect($item->publishUrl())->toContain('sig=synthetic'); + expect($this->signing->requests)->toHaveCount(1) + ->and($this->signing->requests[0][1]->getTimestamp())->toBe(now()->addHours(26)->getTimestamp()); +}); From 38de6d3e96f7741cb7b042fdfebe6ef0e3b95bb6 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 19:04:30 +0100 Subject: [PATCH 06/10] fix(media): renew webhook links and retry storage outages --- app/Jobs/DispatchWebhook.php | 11 +++---- app/Jobs/PublishToSocialPlatform.php | 10 ++++--- app/Services/WebhookService.php | 30 ++++++++++++++++++- ...ublishToSocialPlatformMediaRecheckTest.php | 15 ++++++++++ tests/Feature/Media/AzureBlobStorageTest.php | 24 +++++++++++++++ 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/app/Jobs/DispatchWebhook.php b/app/Jobs/DispatchWebhook.php index e05ac8029c..0419b8260c 100644 --- a/app/Jobs/DispatchWebhook.php +++ b/app/Jobs/DispatchWebhook.php @@ -11,6 +11,7 @@ use App\Models\WebhookLog; use App\Services\Http\SafeHttpFetcher; use App\Services\WebhookService; +use App\Support\Media\RedactStorageCredentials; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -66,8 +67,6 @@ public function handle(SafeHttpFetcher $safeHttp, WebhookService $webhooks): voi 'created_at' => now()->toIso8601String(), ]; - $signature = $webhooks->signature($body, $this->webhook->signing_secret); - $log = WebhookLog::query()->find($this->logId); if ($log) { @@ -91,6 +90,8 @@ public function handle(SafeHttpFetcher $safeHttp, WebhookService $webhooks): voi } try { + $deliveryBody = [...$body, 'data' => $webhooks->deliveryPayload($body['data'], $this->webhook->workspace_id)]; + $signature = $webhooks->signature($deliveryBody, $this->webhook->signing_secret); $response = $safeHttp->guardedRequest($this->webhook->endpoint, followRedirects: false) ->timeout(10) ->withUserAgent(config('trypost.user_agent')) @@ -99,11 +100,11 @@ public function handle(SafeHttpFetcher $safeHttp, WebhookService $webhooks): voi 'X-Webhook-Signature' => $signature, ]) ->withOptions(['stream' => true]) - ->post($this->webhook->endpoint, $body); + ->post($this->webhook->endpoint, $deliveryBody); } catch (ConnectionException|RuntimeException $e) { $log->update([ 'failed_at' => now(), - 'response_body' => $e->getMessage(), + 'response_body' => RedactStorageCredentials::clean($e->getMessage()), ]); $log->refresh(); @@ -112,7 +113,7 @@ public function handle(SafeHttpFetcher $safeHttp, WebhookService $webhooks): voi throw $e; } - $responseBody = mb_scrub(str_replace("\0", '', $safeHttp->bodyPrefix($response, self::RESPONSE_BODY_BYTES)), 'UTF-8'); + $responseBody = RedactStorageCredentials::clean(mb_scrub(str_replace("\0", '', $safeHttp->bodyPrefix($response, self::RESPONSE_BODY_BYTES)), 'UTF-8')); if ($response->successful()) { $log->update([ diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index ef9a370912..ade08acc6a 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -284,10 +284,12 @@ private function failForMissingStoredMedia(): bool } } } catch (\Throwable) { - $this->failAndFinalize('Media storage is unavailable.', $this->failureContext([ - 'reason' => 'media_unavailable', - 'category' => ErrorCategory::MediaFormat->value, - ])); + $this->rescheduleForRetry(new PlatformUnavailableException( + 'Media storage is unavailable.', + context: ['reason' => 'media_unavailable'], + )); + $this->updatePostStatus(); + $this->broadcastStatus(); return true; } diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index 13ee2124ed..17d973277c 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -15,6 +15,7 @@ use App\Models\Workspace; use App\Models\WorkspaceLabel; use App\Services\Http\SafeHttpFetcher; +use App\Support\Media\MediaUrl; use Exception; use Illuminate\Support\Str; use RuntimeException; @@ -138,12 +139,39 @@ public function postPayload(Post $post): array 'error_message' => $post->error_message, 'display_name' => $post->display_name, 'display_username' => $post->display_username, - 'display_avatar' => $post->display_avatar, + 'display_avatar' => MediaUrl::isAzure() + ? (($path = $post->socialAccount?->getRawOriginal('avatar_url') ?: $post->platform_avatar) ? MediaUrl::canonical($path) : null) + : $post->display_avatar, 'meta' => $post->meta ?? [], 'social_account' => $this->socialAccountPayload($post->socialAccount), ]; } + /** + * Resolve file access for this delivery attempt without changing queued or logged state. + * + * @param array $payload + * @return array + */ + public function deliveryPayload(array $payload, string $workspaceId): array + { + if (! MediaUrl::isAzure()) { + return $payload; + } + if (isset($payload['media'])) { + $payload['media'] = MediaUrl::apiItems($payload['media'], $workspaceId); + } + if (isset($payload['meta'])) { + $payload['meta'] = MediaUrl::meta($payload['meta'], true, $workspaceId); + } + if (array_key_exists('display_avatar', $payload)) { + $post = Post::query()->whereKey($payload['id'] ?? '')->where('workspace_id', $workspaceId)->first(); + $payload['display_avatar'] = MediaUrl::avatar($post?->socialAccount?->getRawOriginal('avatar_url') ?: $post?->platform_avatar); + } + + return $payload; + } + /** * @return array{id: string, name: string}|null */ diff --git a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php index 3d9263287e..79f2121c66 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php @@ -119,3 +119,18 @@ expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Failed); })->with(['1:1', '4:5', 'original']); + + +test('a transient storage outage schedules a retry before any publisher call', function () { + config(['filesystems.verify_media' => true]); + \Illuminate\Support\Facades\Queue::fake(); + Storage::shouldReceive('url')->andReturn('https://example.test/canonical'); + Storage::shouldReceive('exists')->once()->andThrow(new RuntimeException('temporary storage failure')); + $publisher = Mockery::mock(InstagramPublisher::class); + $publisher->shouldNotReceive('publish'); + $this->app->instance(InstagramPublisher::class, $publisher); + (new PublishToSocialPlatform($this->post->fresh()))->handle(); + expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Retrying) + ->and(data_get($this->post->fresh()->error_context, 'reason'))->toBe('media_unavailable'); + \Illuminate\Support\Facades\Queue::assertPushed(PublishToSocialPlatform::class); +}); diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index 970df5617c..51b570f4fc 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -151,3 +151,27 @@ expect($this->signing->requests)->toHaveCount(1) ->and($this->signing->requests[0][1]->getTimestamp())->toBe(now()->addHours(26)->getTimestamp()); }); + + +test('webhook retries renew file links and sign the wire body while logs keep canonical references', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()], 'platform_avatar' => 'avatars/test.png']); + Storage::put('avatars/test.png', 'synthetic-avatar'); + $webhook = \App\Models\Webhook::factory()->create(['workspace_id' => $this->workspace->id, 'endpoint' => 'https://8.8.8.8/webhook']); + $service = app(\App\Services\WebhookService::class); + $payload = $service->postPayload($post); + expect(json_encode($payload))->not->toContain('sig='); + $job = new \App\Jobs\DispatchWebhook($webhook, 'post.created', $payload); + \Illuminate\Support\Facades\Http::fake(fn () => \Illuminate\Support\Facades\Http::response('https://example.blob.core.windows.net/media/file?sig=synthetic', 200)); + $job->handle(app(\App\Services\Http\SafeHttpFetcher::class), $service); + $this->travel(20)->minutes(); + $job->handle(app(\App\Services\Http\SafeHttpFetcher::class), $service); + \Illuminate\Support\Facades\Http::assertSent(function ($request) use ($service, $webhook) { + return str_contains($request->data()['data']['media'][0]['url'], 'sig=synthetic') + && $request->hasHeader('X-Webhook-Signature', $service->signature($request->data(), $webhook->signing_secret)); + }); + $log = \App\Models\WebhookLog::findOrFail($job->logId); + expect(json_encode([$job->payload, $log->payload, $log->response_body]))->not->toContain('sig=') + ->and($this->signing->requests)->toHaveCount(4) + ->and($this->signing->requests[2][1]->getTimestamp())->toBeGreaterThan($this->signing->requests[0][1]->getTimestamp()); +}); From 5e351f069a4579c6ebfc01460b0bc78c097d992d Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 19:16:58 +0100 Subject: [PATCH 07/10] fix(webhooks): deliver queued events when media disappears --- app/Dto/MediaItem.php | 5 ++- .../Controllers/App/MediaFileController.php | 6 ++- app/Http/Resources/Api/IdeaResource.php | 3 +- app/Http/Resources/Api/PostResource.php | 5 ++- app/Http/Resources/Api/UploadResource.php | 3 +- app/Jobs/PublishToSocialPlatform.php | 14 ++++--- app/Models/Idea.php | 3 +- app/Models/Media.php | 3 +- app/Models/Post.php | 9 +++-- app/Models/SocialAccount.php | 4 +- .../Social/Concerns/FitsImageToCanvas.php | 3 +- .../Social/GoogleBusinessPublisher.php | 3 +- app/Services/Social/TikTokPublisher.php | 3 +- app/Services/WebhookService.php | 30 ++++++++++++++- config/logging.php | 19 +++++----- tests/Browser/MediaEditorTest.php | 4 +- ...ublishToSocialPlatformMediaRecheckTest.php | 6 +-- tests/Feature/Media/AzureBlobStorageTest.php | 37 ++++++++++++++----- 18 files changed, 110 insertions(+), 50 deletions(-) diff --git a/app/Dto/MediaItem.php b/app/Dto/MediaItem.php index 6ad9b5f6f4..40373ff6a0 100644 --- a/app/Dto/MediaItem.php +++ b/app/Dto/MediaItem.php @@ -8,6 +8,7 @@ use App\Enums\Media\Type; use App\Enums\SocialAccount\Platform; use App\Models\Media; +use App\Support\Media\MediaUrl; class MediaItem { @@ -64,7 +65,7 @@ public function toArray(): array return [ 'id' => $this->id, 'path' => $this->path, - 'url' => \App\Support\Media\MediaUrl::verifiesStorage() ? \App\Support\Media\MediaUrl::canonical($this->path, $this->id) : $this->url, + 'url' => MediaUrl::verifiesStorage() ? MediaUrl::canonical($this->path, $this->id) : $this->url, 'type' => $this->type?->value, 'mime_type' => $this->mime_type, 'original_filename' => $this->original_filename, @@ -84,7 +85,7 @@ public function isVideo(): bool public function publishUrl(): string { - return \App\Support\Media\MediaUrl::publishing($this->path, $this->url); + return MediaUrl::publishing($this->path, $this->url); } public function isImage(): bool diff --git a/app/Http/Controllers/App/MediaFileController.php b/app/Http/Controllers/App/MediaFileController.php index dfbfd9dd6d..a29d23cf57 100644 --- a/app/Http/Controllers/App/MediaFileController.php +++ b/app/Http/Controllers/App/MediaFileController.php @@ -7,6 +7,8 @@ use App\Enums\Media\Type as MediaType; use App\Http\Controllers\Controller; use App\Models\Media; +use App\Support\Media\MediaUrl; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -19,12 +21,12 @@ */ class MediaFileController extends Controller { - public function view(Request $request, Media $media): \Illuminate\Http\RedirectResponse + public function view(Request $request, Media $media): RedirectResponse { abort_unless($media->workspace !== null, 404); abort_unless($request->user()->can('view', $media->workspace), 404); - return redirect()->away(\App\Support\Media\MediaUrl::temporary($media->path))->withHeaders([ + return redirect()->away(MediaUrl::temporary($media->path))->withHeaders([ 'Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer', ]); diff --git a/app/Http/Resources/Api/IdeaResource.php b/app/Http/Resources/Api/IdeaResource.php index e7af56ac52..9a72e548c0 100644 --- a/app/Http/Resources/Api/IdeaResource.php +++ b/app/Http/Resources/Api/IdeaResource.php @@ -5,6 +5,7 @@ namespace App\Http\Resources\Api; use App\Models\Idea; +use App\Support\Media\MediaUrl; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -24,7 +25,7 @@ public function toArray(Request $request): array 'title' => $this->title, 'body' => $this->body, 'position' => $this->position, - 'media' => \App\Support\Media\MediaUrl::apiItems($this->media ?? [], $this->workspace_id), + 'media' => MediaUrl::apiItems($this->media ?? [], $this->workspace_id), 'labels' => LabelResource::collection($this->whenLoaded('labels')), 'created_at' => $this->created_at->format('Y-m-d H:i:s'), 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php index 3da50712e7..0e7b9da55f 100644 --- a/app/Http/Resources/Api/PostResource.php +++ b/app/Http/Resources/Api/PostResource.php @@ -6,6 +6,7 @@ use App\Enums\Post\Status; use App\Models\User; +use App\Support\Media\MediaUrl; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,7 +28,7 @@ public function toArray(Request $request): array 'post_group_id' => $this->post_group_id, 'author' => $this->person($this->user), 'content' => $this->content, - 'media' => \App\Support\Media\MediaUrl::apiItems($this->media ?? [], $this->workspace_id), + 'media' => MediaUrl::apiItems($this->media ?? [], $this->workspace_id), 'status' => $this->status?->value, 'schedule_mode' => $this->schedule_mode?->value, 'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'), @@ -45,7 +46,7 @@ public function toArray(Request $request): array 'publish_status' => $this->publish_status->value, 'platform' => $this->platform?->value, 'content_type' => $this->content_type?->value, - 'meta' => $this->meta === null ? null : \App\Support\Media\MediaUrl::meta($this->meta, true, $this->workspace_id), + 'meta' => $this->meta === null ? null : MediaUrl::meta($this->meta, true, $this->workspace_id), 'platform_url' => $this->platform_url, 'error_message' => $this->error_message, 'display_name' => $this->display_name, diff --git a/app/Http/Resources/Api/UploadResource.php b/app/Http/Resources/Api/UploadResource.php index 44097c2a5e..5a952ce258 100644 --- a/app/Http/Resources/Api/UploadResource.php +++ b/app/Http/Resources/Api/UploadResource.php @@ -5,6 +5,7 @@ namespace App\Http\Resources\Api; use App\Models\Media; +use App\Support\Media\MediaUrl; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,7 +28,7 @@ public function toArray(Request $request): array 'mime_type' => $this->mime_type, 'size' => $this->size, 'original_filename' => $this->original_filename, - 'url' => \App\Support\Media\MediaUrl::verifiesStorage() ? \App\Support\Media\MediaUrl::temporary($this->path) : $this->url, + 'url' => MediaUrl::verifiesStorage() ? MediaUrl::temporary($this->path) : $this->url, 'expires_at' => $this->created_at->copy()->addHours((int) config('trypost.media.upload_retention_hours'))->toIso8601String(), ]; } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index ade08acc6a..2f01a17ef9 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -32,6 +32,8 @@ use App\Services\Social\TikTokPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; +use App\Support\Media\MediaUrl; +use App\Support\Media\RedactStorageCredentials; use App\Support\Social\GoogleBusinessDerivativeCleaner; use App\Support\Social\LimitRetryPolicy; use App\Support\Social\PublishCheckpoint; @@ -257,7 +259,7 @@ private function failForMissingScopes(): bool private function failForMissingStoredMedia(): bool { - if (! \App\Support\Media\MediaUrl::verifiesStorage()) { + if (! MediaUrl::verifiesStorage()) { return false; } @@ -274,7 +276,7 @@ private function failForMissingStoredMedia(): bool try { foreach ($items as $item) { $path = data_get($item, 'path'); - if (is_string($path) && $path !== '' && ! \App\Support\Media\MediaUrl::exists($path)) { + if (is_string($path) && $path !== '' && ! MediaUrl::exists($path)) { $this->failAndFinalize('A media attachment is unavailable.', $this->failureContext([ 'reason' => 'media_missing', 'category' => ErrorCategory::MediaFormat->value, @@ -283,7 +285,7 @@ private function failForMissingStoredMedia(): bool return true; } } - } catch (\Throwable) { + } catch (Throwable) { $this->rescheduleForRetry(new PlatformUnavailableException( 'Media storage is unavailable.', context: ['reason' => 'media_unavailable'], @@ -356,7 +358,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void ...($hasOwnPolicy ? ['max_retries' => $maxRetries, 'retry_delay_seconds' => $retryDelaySeconds] : []), 'detail' => $e->getMessage(), ]; - $context = \App\Support\Media\RedactStorageCredentials::clean($context); + $context = RedactStorageCredentials::clean($context); if ($retryCount > $maxRetries) { $this->reportCaughtPublishFailure($e, [ @@ -560,8 +562,8 @@ private function markPlatformAsFailed(string $message, ?array $context = null): $failureContext = [...$previousContext, ...($context ?? [])]; $this->post->markPublicationFailed( - \App\Support\Media\RedactStorageCredentials::clean(ThreadProgress::failureMessage($this->post, $message, $failureContext)), - $failureContext === [] ? null : \App\Support\Media\RedactStorageCredentials::clean($failureContext), + RedactStorageCredentials::clean(ThreadProgress::failureMessage($this->post, $message, $failureContext)), + $failureContext === [] ? null : RedactStorageCredentials::clean($failureContext), ); } diff --git a/app/Models/Idea.php b/app/Models/Idea.php index 86f08315f2..b5122d68d7 100644 --- a/app/Models/Idea.php +++ b/app/Models/Idea.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Casts\StoredMedia; use App\Dto\MediaItem; use Database\Factories\IdeaFactory; use Illuminate\Database\Eloquent\Builder; @@ -40,7 +41,7 @@ class Idea extends Model protected function casts(): array { return [ - 'media' => \App\Casts\StoredMedia::class, + 'media' => StoredMedia::class, 'position' => 'integer', ]; } diff --git a/app/Models/Media.php b/app/Models/Media.php index 4e22e28b39..c545a352aa 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -7,6 +7,7 @@ use App\Enums\Media\Type as MediaType; use App\Exceptions\MediaOwnershipViolation; use App\Jobs\Media\DeleteMediaFiles; +use App\Support\Media\MediaUrl; use Database\Factories\MediaFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -164,7 +165,7 @@ public function issueUploadToken(): void protected function url(): Attribute { return Attribute::make( - get: fn () => \App\Support\Media\MediaUrl::browser($this), + get: fn () => MediaUrl::browser($this), ); } diff --git a/app/Models/Post.php b/app/Models/Post.php index fae4b09b4a..1b0da882b2 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Actions\Media\SyncOwnedMedia; +use App\Casts\StoredMedia; use App\Dto\MediaItem; use App\Enums\Media\Type; use App\Enums\Post\CreatedVia; @@ -19,6 +20,7 @@ use App\Enums\TikTok\PrivacyLevel; use App\Observers\PostObserver; use App\Support\Media\MediaCopyBatch; +use App\Support\Media\MediaUrl; use Carbon\CarbonInterface; use Database\Factories\PostFactory; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -33,7 +35,6 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; #[ObservedBy([PostObserver::class])] class Post extends Model @@ -115,7 +116,7 @@ protected function casts(): array 'recurrence_origin_at' => 'datetime', 'created_via' => CreatedVia::class, 'origin' => Origin::class, - 'media' => \App\Casts\StoredMedia::class, + 'media' => StoredMedia::class, 'scheduled_at' => 'datetime', 'published_at' => 'datetime', 'approval_requested_at' => 'datetime', @@ -124,7 +125,7 @@ protected function casts(): array 'publish_status' => PublishStatus::class, 'platform' => Platform::class, 'content_type' => ContentType::class, - 'meta' => \App\Casts\StoredMedia::class, + 'meta' => StoredMedia::class, 'error_context' => 'array', 'thread_reply_ids' => 'array', 'submitted_at' => 'datetime', @@ -460,7 +461,7 @@ protected function displayAvatar(): Attribute { return Attribute::make( get: fn (): ?string => $this->socialAccount?->avatar_url - ?: \App\Support\Media\MediaUrl::avatar($this->platform_avatar), + ?: MediaUrl::avatar($this->platform_avatar), ); } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 07dcfa3d9d..b70fa42b52 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -19,6 +19,7 @@ use App\Observers\ComposerVersionObserver; use App\Observers\SocialAccountObserver; use App\Support\GoogleBusinessResourceName; +use App\Support\Media\MediaUrl; use Database\Factories\SocialAccountFactory; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -33,7 +34,6 @@ use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; #[ObservedBy([SocialAccountObserver::class, ComposerVersionObserver::class])] #[ScopedBy(SocialAccountOrderScope::class)] @@ -322,7 +322,7 @@ public function needsProactiveTokenRefresh(): bool protected function avatarUrl(): Attribute { return Attribute::make( - get: fn (?string $value) => \App\Support\Media\MediaUrl::avatar($value), + get: fn (?string $value) => MediaUrl::avatar($value), ); } diff --git a/app/Services/Social/Concerns/FitsImageToCanvas.php b/app/Services/Social/Concerns/FitsImageToCanvas.php index 53c05df8de..b550d4114f 100644 --- a/app/Services/Social/Concerns/FitsImageToCanvas.php +++ b/app/Services/Social/Concerns/FitsImageToCanvas.php @@ -6,6 +6,7 @@ use App\Exceptions\Social\SocialPublishException; use App\Services\Media\MediaOptimizer; +use App\Support\Media\MediaUrl; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -40,7 +41,7 @@ protected function fitImageToCanvas(string $imageUrl, int $width, int $height): $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; Storage::put($path, file_get_contents($fitted)); - return \App\Support\Media\MediaUrl::publishing($path); + return MediaUrl::publishing($path); } finally { @unlink($fitted); } diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php index 08a06cb8da..ff2c8f9d5a 100644 --- a/app/Services/Social/GoogleBusinessPublisher.php +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -18,6 +18,7 @@ use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Support\GoogleBusinessResourceName; +use App\Support\Media\MediaUrl; use App\Support\Social\GoogleBusinessDerivativeCleaner; use Carbon\CarbonImmutable; use Illuminate\Http\Client\Response; @@ -189,7 +190,7 @@ private function imageSourceUrl(MediaItem $media, Post $post): string $path = GoogleBusinessDerivativeCleaner::pathFor($post); Storage::put($path, file_get_contents($optimized)); - return \App\Support\Media\MediaUrl::publishing($path); + return MediaUrl::publishing($path); } catch (Throwable $e) { Log::warning('Google Business Profile image derivative failed; sending the original', [ 'path' => $media->path, diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index daf354c7ec..2404a17d41 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -16,6 +16,7 @@ use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Support\Media\MediaUrl; use App\Support\PostPlatformMetaRules; use App\Support\Social\PublishCheckpoint; use App\Support\Social\TikTokPhotoDerivativeCleaner; @@ -385,7 +386,7 @@ private function hostResizedPhoto(string $tempInput): array @unlink($optimized); } - return [\App\Support\Media\MediaUrl::publishing($path), $path]; + return [MediaUrl::publishing($path), $path]; } catch (Throwable $e) { Log::error('TikTok photo resize/host failed', [ 'exception' => $e->getMessage(), diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index 17d973277c..173e13e03d 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -19,6 +19,7 @@ use Exception; use Illuminate\Support\Str; use RuntimeException; +use Symfony\Component\HttpKernel\Exception\HttpException; class WebhookService { @@ -159,10 +160,14 @@ public function deliveryPayload(array $payload, string $workspaceId): array return $payload; } if (isset($payload['media'])) { - $payload['media'] = MediaUrl::apiItems($payload['media'], $workspaceId); + $payload['media'] = $this->deliveryMedia($payload['media'], $workspaceId); } if (isset($payload['meta'])) { - $payload['meta'] = MediaUrl::meta($payload['meta'], true, $workspaceId); + foreach ($payload['meta']['thread_replies'] ?? [] as $index => $reply) { + if (is_array($reply['media'] ?? null)) { + $payload['meta']['thread_replies'][$index]['media'] = $this->deliveryMedia($reply['media'], $workspaceId); + } + } } if (array_key_exists('display_avatar', $payload)) { $post = Post::query()->whereKey($payload['id'] ?? '')->where('workspace_id', $workspaceId)->first(); @@ -172,6 +177,27 @@ public function deliveryPayload(array $payload, string $workspaceId): array return $payload; } + /** + * A queued event can outlive its files; keep its canonical references when they are gone. + * + * @param array> $items + * @return array> + */ + private function deliveryMedia(array $items, string $workspaceId): array + { + return array_map(function (array $item) use ($workspaceId): array { + try { + return MediaUrl::apiItems($item, $workspaceId); + } catch (HttpException $exception) { + if ($exception->getStatusCode() !== 404) { + throw $exception; + } + + return MediaUrl::canonicalItems($item); + } + }, $items); + } + /** * @return array{id: string, name: string}|null */ diff --git a/config/logging.php b/config/logging.php index fff3b8712b..353678f783 100644 --- a/config/logging.php +++ b/config/logging.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Support\Media\RedactStorageCredentials; use Monolog\Handler\NullHandler; use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogUdpHandler; @@ -56,14 +57,14 @@ 'stack' => [ 'driver' => 'stack', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, @@ -71,7 +72,7 @@ 'daily' => [ 'driver' => 'daily', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), @@ -80,7 +81,7 @@ 'slack' => [ 'driver' => 'slack', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), @@ -90,7 +91,7 @@ 'papertrail' => [ 'driver' => 'monolog', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ @@ -103,7 +104,7 @@ 'stderr' => [ 'driver' => 'monolog', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'handler_with' => [ @@ -115,7 +116,7 @@ 'syslog' => [ 'driver' => 'syslog', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true, @@ -123,14 +124,14 @@ 'errorlog' => [ 'driver' => 'errorlog', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', - 'tap' => [\App\Support\Media\RedactStorageCredentials::class], + 'tap' => [RedactStorageCredentials::class], 'handler' => NullHandler::class, ], diff --git a/tests/Browser/MediaEditorTest.php b/tests/Browser/MediaEditorTest.php index 9f95644db4..59dee2520a 100644 --- a/tests/Browser/MediaEditorTest.php +++ b/tests/Browser/MediaEditorTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Ai\Agents\MediaAltTextGenerator; +use App\Dto\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Jobs\Analytics\BootstrapAccountAnalytics; use App\Jobs\Analytics\CollectAccountDailySnapshot; @@ -957,7 +958,6 @@ function setMediaEditorRange(mixed $page, string $testId, int $value): void Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'gateway.example.test') || str_contains($request->url(), 'openrouter.ai')); })->with(['desktop' => [1280, 900, 'desktop'], 'compact' => [390, 844, 'compact']]); - test('azure preview redirects and canvas edits survive draft save and reload', function (int $width, int $height, string $suffix) { config(['filesystems.default' => 'azure', 'filesystems.disks.azure.driver' => 'azure-flat']); Storage::fake('azure'); @@ -971,7 +971,7 @@ function setMediaEditorRange(mixed $page, string $testId, int $value): void $post->socialAccount->update(['display_name' => 'Demo Channel', 'username' => 'demo.example.test']); $media = Media::factory()->ownedByPost($post)->create(['path' => 'medias/blob-preview.png', 'mime_type' => 'image/png']); Storage::put($media->path, file_get_contents(base_path('tests/fixtures/crop-quadrants.png'))); - $post->update(['media' => [\App\Dto\MediaItem::fromMedia($media)->toArray()]]); + $post->update(['media' => [MediaItem::fromMedia($media)->toArray()]]); $page = visit(route('app.posts.edit', $post))->resize($width, $height); openMediaEditor($page, $post->social_account_id); $page->click('@media-editor-rotate-right')->screenshot(false, "blob-canvas-{$suffix}")->click('@media-editor-apply'); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php index 79f2121c66..6d9acd27e5 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php @@ -17,6 +17,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; beforeEach(function () { @@ -120,10 +121,9 @@ expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Failed); })->with(['1:1', '4:5', 'original']); - test('a transient storage outage schedules a retry before any publisher call', function () { config(['filesystems.verify_media' => true]); - \Illuminate\Support\Facades\Queue::fake(); + Queue::fake(); Storage::shouldReceive('url')->andReturn('https://example.test/canonical'); Storage::shouldReceive('exists')->once()->andThrow(new RuntimeException('temporary storage failure')); $publisher = Mockery::mock(InstagramPublisher::class); @@ -132,5 +132,5 @@ (new PublishToSocialPlatform($this->post->fresh()))->handle(); expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Retrying) ->and(data_get($this->post->fresh()->error_context, 'reason'))->toBe('media_unavailable'); - \Illuminate\Support\Facades\Queue::assertPushed(PublishToSocialPlatform::class); + Queue::assertPushed(PublishToSocialPlatform::class); }); diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index 51b570f4fc..da60f32494 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -4,14 +4,20 @@ use App\Dto\MediaItem; use App\Http\Resources\Api\PostResource; +use App\Jobs\DispatchWebhook; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\GetPostTool; use App\Models\Media; use App\Models\Post; use App\Models\User; +use App\Models\Webhook; +use App\Models\WebhookLog; use App\Models\Workspace; +use App\Services\Http\SafeHttpFetcher; +use App\Services\WebhookService; use App\Support\Media\MediaUrl; use App\Support\Media\RedactStorageCredentials; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Testing\Fluent\AssertableJson; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -152,26 +158,39 @@ ->and($this->signing->requests[0][1]->getTimestamp())->toBe(now()->addHours(26)->getTimestamp()); }); - test('webhook retries renew file links and sign the wire body while logs keep canonical references', function () { $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()], 'platform_avatar' => 'avatars/test.png']); Storage::put('avatars/test.png', 'synthetic-avatar'); - $webhook = \App\Models\Webhook::factory()->create(['workspace_id' => $this->workspace->id, 'endpoint' => 'https://8.8.8.8/webhook']); - $service = app(\App\Services\WebhookService::class); + $webhook = Webhook::factory()->create(['workspace_id' => $this->workspace->id, 'endpoint' => 'https://8.8.8.8/webhook']); + $service = app(WebhookService::class); $payload = $service->postPayload($post); expect(json_encode($payload))->not->toContain('sig='); - $job = new \App\Jobs\DispatchWebhook($webhook, 'post.created', $payload); - \Illuminate\Support\Facades\Http::fake(fn () => \Illuminate\Support\Facades\Http::response('https://example.blob.core.windows.net/media/file?sig=synthetic', 200)); - $job->handle(app(\App\Services\Http\SafeHttpFetcher::class), $service); + $job = new DispatchWebhook($webhook, 'post.created', $payload); + Http::fake(fn () => Http::response('https://example.blob.core.windows.net/media/file?sig=synthetic', 200)); + $job->handle(app(SafeHttpFetcher::class), $service); $this->travel(20)->minutes(); - $job->handle(app(\App\Services\Http\SafeHttpFetcher::class), $service); - \Illuminate\Support\Facades\Http::assertSent(function ($request) use ($service, $webhook) { + $job->handle(app(SafeHttpFetcher::class), $service); + Http::assertSent(function ($request) use ($service, $webhook) { return str_contains($request->data()['data']['media'][0]['url'], 'sig=synthetic') && $request->hasHeader('X-Webhook-Signature', $service->signature($request->data(), $webhook->signing_secret)); }); - $log = \App\Models\WebhookLog::findOrFail($job->logId); + $log = WebhookLog::findOrFail($job->logId); expect(json_encode([$job->payload, $log->payload, $log->response_body]))->not->toContain('sig=') ->and($this->signing->requests)->toHaveCount(4) ->and($this->signing->requests[2][1]->getTimestamp())->toBeGreaterThan($this->signing->requests[0][1]->getTimestamp()); }); + +test('a queued webhook still delivers its lifecycle event after an attachment disappears', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); + $webhook = Webhook::factory()->create(['workspace_id' => $this->workspace->id, 'endpoint' => 'https://8.8.8.8/webhook']); + $service = app(WebhookService::class); + $job = new DispatchWebhook($webhook, 'post.created', $service->postPayload($post)); + Storage::delete($this->media->path); + Http::fake(fn () => Http::response('', 200)); + $job->handle(app(SafeHttpFetcher::class), $service); + Http::assertSentCount(1); + expect(WebhookLog::findOrFail($job->logId)->delivered_at)->not->toBeNull() + ->and($this->signing->requests)->toBeEmpty(); +}); From 7953d7b0fec24ec76fd94c504ee5a42e38170a15 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 19:24:25 +0100 Subject: [PATCH 08/10] fix(media): preserve diagnostics and tolerate unavailable attachments --- app/Services/Social/PinterestPublisher.php | 2 +- app/Services/WebhookService.php | 30 +---------- app/Support/Media/MediaUrl.php | 22 ++++++-- .../Media/RedactStorageCredentials.php | 10 +++- .../2026-10-25-azure-blob-media-plan.md | 2 + tests/Feature/Media/AzureBlobStorageTest.php | 51 +++++++++++++++---- .../Social/PinterestPublisherTest.php | 4 +- 7 files changed, 77 insertions(+), 44 deletions(-) diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 1015a67518..2813d9ecbf 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -258,7 +258,7 @@ private function publishVideoPin(Post $post, ?string $content): array $multipart[] = [ 'name' => 'file', 'contents' => $videoStream, - 'filename' => basename($media->publishUrl()), + 'filename' => basename($media->path), ]; $uploadResponse = Http::asMultipart() diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index 173e13e03d..17d973277c 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -19,7 +19,6 @@ use Exception; use Illuminate\Support\Str; use RuntimeException; -use Symfony\Component\HttpKernel\Exception\HttpException; class WebhookService { @@ -160,14 +159,10 @@ public function deliveryPayload(array $payload, string $workspaceId): array return $payload; } if (isset($payload['media'])) { - $payload['media'] = $this->deliveryMedia($payload['media'], $workspaceId); + $payload['media'] = MediaUrl::apiItems($payload['media'], $workspaceId); } if (isset($payload['meta'])) { - foreach ($payload['meta']['thread_replies'] ?? [] as $index => $reply) { - if (is_array($reply['media'] ?? null)) { - $payload['meta']['thread_replies'][$index]['media'] = $this->deliveryMedia($reply['media'], $workspaceId); - } - } + $payload['meta'] = MediaUrl::meta($payload['meta'], true, $workspaceId); } if (array_key_exists('display_avatar', $payload)) { $post = Post::query()->whereKey($payload['id'] ?? '')->where('workspace_id', $workspaceId)->first(); @@ -177,27 +172,6 @@ public function deliveryPayload(array $payload, string $workspaceId): array return $payload; } - /** - * A queued event can outlive its files; keep its canonical references when they are gone. - * - * @param array> $items - * @return array> - */ - private function deliveryMedia(array $items, string $workspaceId): array - { - return array_map(function (array $item) use ($workspaceId): array { - try { - return MediaUrl::apiItems($item, $workspaceId); - } catch (HttpException $exception) { - if ($exception->getStatusCode() !== 404) { - throw $exception; - } - - return MediaUrl::canonicalItems($item); - } - }, $items); - } - /** * @return array{id: string, name: string}|null */ diff --git a/app/Support/Media/MediaUrl.php b/app/Support/Media/MediaUrl.php index e0f457d733..44f1fd4e42 100644 --- a/app/Support/Media/MediaUrl.php +++ b/app/Support/Media/MediaUrl.php @@ -7,6 +7,7 @@ use App\Models\Media; use Illuminate\Support\Facades\Storage; use RuntimeException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; final class MediaUrl @@ -91,7 +92,11 @@ public static function avatar(?string $path): ?string return Storage::url($path); } - return self::exists($path) ? self::temporary($path, 60) : null; + try { + return self::temporary($path, 60); + } catch (Throwable) { + return null; + } } /** Rebuild only owned file references; external URLs and prose are data. */ @@ -122,9 +127,18 @@ public static function apiItems(array $value, ?string $workspaceId = null): arra } if (isset($value['path']) && is_string($value['path']) && $value['path'] !== '' && isset($value['id'])) { - abort_unless(Media::query()->whereKey($value['id'])->where('path', $value['path']) - ->when($workspaceId !== null, fn ($query) => $query->where('workspace_id', $workspaceId))->exists(), 404); - $value['url'] = self::temporary($value['path']); + $owned = Media::query()->whereKey($value['id'])->where('path', $value['path']) + ->when($workspaceId !== null, fn ($query) => $query->where('workspace_id', $workspaceId))->exists(); + $value['url'] = null; + if ($owned) { + try { + $value['url'] = self::temporary($value['path']); + } catch (HttpException $exception) { + if ($exception->getStatusCode() !== 404) { + throw $exception; + } + } + } } foreach ($value as $key => $item) { diff --git a/app/Support/Media/RedactStorageCredentials.php b/app/Support/Media/RedactStorageCredentials.php index bd5b1035b8..cae704bf52 100644 --- a/app/Support/Media/RedactStorageCredentials.php +++ b/app/Support/Media/RedactStorageCredentials.php @@ -22,7 +22,15 @@ public function __invoke(Logger $logger): void public static function clean(mixed $value): mixed { if ($value instanceof Throwable) { - return ['type' => $value::class, 'message' => self::clean($value->getMessage())]; + return [ + 'type' => $value::class, + 'message' => self::clean($value->getMessage()), + 'code' => $value->getCode(), + 'file' => $value->getFile(), + 'line' => $value->getLine(), + 'trace' => array_map(fn (array $frame): array => self::clean(array_intersect_key($frame, array_flip(['file', 'line', 'class', 'type', 'function']))), $value->getTrace()), + 'previous' => self::clean($value->getPrevious()), + ]; } if (is_array($value)) { foreach ($value as $key => $item) { diff --git a/docs/project/2026-10-25-azure-blob-media-plan.md b/docs/project/2026-10-25-azure-blob-media-plan.md index fa2021644e..334dd925b5 100644 --- a/docs/project/2026-10-25-azure-blob-media-plan.md +++ b/docs/project/2026-10-25-azure-blob-media-plan.md @@ -15,3 +15,5 @@ The source package ends with reviewed, CI-checked draft delivery. Entra/RBAC/SAS | Workspace membership | Reuse | Browser file access follows the file workspace, independently of another tab's selected workspace. Existing token/workspace guards remain the API/MCP boundary. | WorkspacePolicy, MediaFileController, API resources | The optional backend changes delivery policy, not media ownership or database identity. Logical paths remain stable over the flat physical layout. No new product primitive is introduced. + +Unavailable media in API/MCP/webhook payloads retains its stable identity with a null delivery URL, so one absent attachment does not break a collection or lifecycle event. Browser file fetches still return not-found; confirmed-missing scheduled attachments fail before a provider call. Avatar storage failures yield no avatar, and redacted exception diagnostics retain file, line, safe stack frames and the previous chain. Real-Azure qualification includes API/MCP and avatar fan-out as well as individual redirects. diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index da60f32494..65f56f97ca 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -7,8 +7,10 @@ use App\Jobs\DispatchWebhook; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\GetPostTool; +use App\Mcp\Tools\Post\ListPostsTool; use App\Models\Media; use App\Models\Post; +use App\Models\SocialAccount; use App\Models\User; use App\Models\Webhook; use App\Models\WebhookLog; @@ -20,7 +22,8 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Testing\Fluent\AssertableJson; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Monolog\Handler\TestHandler; +use Monolog\Logger; beforeEach(function () { $this->user = User::factory()->create(); @@ -74,12 +77,7 @@ test('missing old staging objects fail before any link is signed', function () { Storage::delete($this->media->path); $this->actingAs($this->user)->get($this->media->url)->assertNotFound(); - try { - MediaUrl::apiItems([MediaItem::fromMedia($this->media)->toArray()]); - test()->fail('Missing media must not be signed.'); - } catch (HttpException $exception) { - expect($exception->getStatusCode())->toBe(404); - } + expect(MediaUrl::apiItems([MediaItem::fromMedia($this->media)->toArray()])[0]['url'])->toBeNull(); expect($this->signing->requests)->toBeEmpty(); }); @@ -124,7 +122,7 @@ $foreign = Media::factory()->temporaryUpload(Workspace::factory()->create())->create(['path' => 'medias/foreign.jpg']); Storage::put($foreign->path, 'fixture'); $post->update(['media' => [MediaItem::fromMedia($foreign)->toArray()]]); - $this->withToken($token)->getJson(route('api.posts.show', $post))->assertNotFound(); + $this->withToken($token)->getJson(route('api.posts.show', $post))->assertOk()->assertJsonPath('media.0.url', null); expect($this->signing->requests)->toHaveCount(1); }); @@ -135,7 +133,7 @@ $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray()]]); $this->actingAs($this->user)->get(route('app.media.view', $this->media))->assertNotFound(); auth()->logout(); - $this->withToken(passportToken($this->user, $this->workspace))->getJson(route('api.posts.show', $post))->assertNotFound(); + $this->withToken(passportToken($this->user, $this->workspace))->getJson(route('api.posts.show', $post))->assertOk()->assertJsonPath('media.0.url', null); expect($this->signing->requests)->toBeEmpty(); }); @@ -194,3 +192,38 @@ expect(WebhookLog::findOrFail($job->logId)->delivered_at)->not->toBeNull() ->and($this->signing->requests)->toBeEmpty(); }); + +test('API and MCP lists remain usable when one attachment is missing', function () { + $missing = Media::factory()->temporaryUpload($this->workspace)->create(['path' => 'medias/gone.jpg']); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['media' => [MediaItem::fromMedia($this->media)->toArray(), MediaItem::fromMedia($missing)->toArray()]]); + Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $response = $this->withToken(passportToken($this->user, $this->workspace))->getJson(route('api.posts.index'))->assertOk(); + $output = collect($response->json('data'))->firstWhere('id', $post->id); + expect($output['media'][0]['url'])->toContain('sig=synthetic') + ->and($output['media'][1]['url'])->toBeNull(); + TryPostServer::actingAs($this->user)->tool(ListPostsTool::class, [])->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('posts', fn ($posts) => count($posts) === 2 + && collect($posts)->firstWhere('id', $post->id)['media'][1]['url'] === null)->etc()); +}); + +test('avatar storage failures leave the page accessor usable', function () { + $account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'avatar_url' => 'avatars/test.png']); + Storage::shouldReceive('exists')->once()->andThrow(new RuntimeException('temporary storage failure')); + expect($account->avatar_url)->toBeNull(); +}); + +test('log redaction retains exception diagnostics without arguments or signed links', function () { + $url = 'https://example.blob.core.windows.net/media/a?sig=synthetic'; + $error = new RuntimeException($url, 0, new LogicException($url)); + $handler = new TestHandler; + $logger = new Logger('test', [$handler]); + (new RedactStorageCredentials)(new Illuminate\Log\Logger($logger)); + $logger->error('fixture', ['exception' => $error]); + $record = $handler->getRecords()[0]->context['exception']; + expect($record['file'])->toBe($error->getFile()) + ->and($record['line'])->toBe($error->getLine()) + ->and($record['trace'])->not->toBeEmpty() + ->and($record['previous']['type'])->toBe(LogicException::class) + ->and(json_encode($record))->not->toContain('sig=', 'synthetic', '"args"'); +}); diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index 352f700d23..80e07dc8b9 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -722,7 +722,7 @@ [ 'id' => 'test-media-video', 'path' => 'media/2026-01/video.mp4', - 'url' => 'https://example.com/media/2026-01/video.mp4', + 'url' => 'https://example.com/media/2026-01/video.mp4?sig=synthetic', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4', ], @@ -749,6 +749,8 @@ // Step 2: S3 upload if ($url === $s3UploadUrl) { + expect($request->body())->not->toContain('sig=synthetic'); + return Http::response('', 204); } From 9a8ae44587766c140649f5835724a21eab4a601a Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 19:32:41 +0100 Subject: [PATCH 09/10] test(media): assert each redaction guarantee independently --- tests/Feature/Media/AzureBlobStorageTest.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index 65f56f97ca..08ed059da3 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -108,7 +108,7 @@ test('sas data is removed from nested logs and exceptions', function () { $url = 'https://example.blob.core.windows.net/media/a?sv=2023-11-03&sig=synthetic'; $record = RedactStorageCredentials::clean(['nested' => ['uri' => $url], 'exception' => new RuntimeException($url)]); - expect(json_encode($record))->not->toContain('sig=', 'synthetic') + expect(json_encode($record))->not->toContain('sig=')->not->toContain('synthetic') ->and($record['nested']['uri'])->toBe('https://example.blob.core.windows.net/media/a?[redacted]'); }); @@ -215,7 +215,13 @@ test('log redaction retains exception diagnostics without arguments or signed links', function () { $url = 'https://example.blob.core.windows.net/media/a?sig=synthetic'; - $error = new RuntimeException($url, 0, new LogicException($url)); + $previousSetting = ini_set('zend.exception_ignore_args', '0'); + try { + $error = (fn (string $argument) => new RuntimeException($url, 0, new LogicException($url)))('trace-argument-synthetic'); + } finally { + ini_set('zend.exception_ignore_args', $previousSetting); + } + expect($error->getTrace()[0])->toHaveKey('args'); $handler = new TestHandler; $logger = new Logger('test', [$handler]); (new RedactStorageCredentials)(new Illuminate\Log\Logger($logger)); @@ -225,5 +231,8 @@ ->and($record['line'])->toBe($error->getLine()) ->and($record['trace'])->not->toBeEmpty() ->and($record['previous']['type'])->toBe(LogicException::class) - ->and(json_encode($record))->not->toContain('sig=', 'synthetic', '"args"'); + ->and(json_encode($record))->not->toContain('sig=')->not->toContain('synthetic'); + foreach ($record['trace'] as $frame) { + expect($frame)->not->toHaveKey('args'); + } }); From 64353292e0dc5ba6b1314883549696145588096e Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 20:47:48 +0100 Subject: [PATCH 10/10] fix(media): complete Azure previews and simplify storage handling --- app/Services/Media/AzureBlockUploader.php | 18 +++++------- app/Services/Post/PostPreviewer.php | 3 +- app/Support/Mail/PostPreview.php | 3 +- app/Support/Media/AzureUserDelegationSas.php | 5 ++-- app/Support/Media/FlatBlobAdapter.php | 28 ++++++++++--------- .../2026-10-25-azure-blob-media-plan.md | 2 ++ tests/Feature/ChunkedCloudUploadTest.php | 4 ++- .../Mail/PostPublicationPreviewTest.php | 26 +++++++++++++++++ tests/Feature/Media/AzureBlobStorageTest.php | 25 +++++++++++++++++ 9 files changed, 84 insertions(+), 30 deletions(-) diff --git a/app/Services/Media/AzureBlockUploader.php b/app/Services/Media/AzureBlockUploader.php index 70cd8c2163..a03bf3da95 100644 --- a/app/Services/Media/AzureBlockUploader.php +++ b/app/Services/Media/AzureBlockUploader.php @@ -11,6 +11,7 @@ use AzureOss\Storage\Blob\Models\BlobHttpHeaders; use AzureOss\Storage\Blob\Models\CommitBlockListOptions; use AzureOss\Storage\Blob\Specialized\BlockBlobClient; +use AzureOss\Storage\Common\ApiVersion; use AzureOss\Storage\Common\Middleware\ClientFactory; use Closure; use finfo; @@ -163,13 +164,8 @@ private function receiveChunkLocked( return $this->status($state, completed: false); } - $mimeType = $this->detectMimeType( - (string) data_get($state, 'header'), - MediaType::extensionOf($fileName), - ); - + $mimeType = (string) data_get($state, 'mime_type'); $this->commitBlocks($client, data_get($state, 'block_ids'), $mimeType); - $state['mime_type'] = $mimeType; $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); return $this->status($state, completed: true); @@ -180,7 +176,6 @@ private function receiveChunkLocked( * path: string, * blob_name: string, * mime_type: string, - * header: string, * block_ids: array, * next_offset: int, * bytes_received: int, @@ -192,13 +187,11 @@ private function startUpload(string $fileName, string $chunk, int $totalSize): a $extension = MediaType::extensionOf($fileName); $suffix = $extension === '' ? '' : ".{$extension}"; $path = 'medias/'.Str::uuid().$suffix; - $header = substr($chunk, 0, self::MIME_SNIFF_BYTES); return [ 'path' => $path, 'blob_name' => FlatBlobAdapter::encodePath($path), - 'mime_type' => $this->detectMimeType($header, $extension), - 'header' => $header, + 'mime_type' => $this->detectMimeType(substr($chunk, 0, self::MIME_SNIFF_BYTES), $extension), 'block_ids' => [], 'next_offset' => 0, 'bytes_received' => 0, @@ -292,6 +285,9 @@ private function downloadRange( ], RequestOptions::HTTP_ERRORS => true, RequestOptions::STREAM => true, + RequestOptions::TIMEOUT => 120, + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::READ_TIMEOUT => 120, ]); $contentRange = $response->getHeaderLine('Content-Range'); @@ -330,6 +326,6 @@ private function httpClient(BlobClient $client): ClientInterface } // The installed SDK has no ranged download option, so reuse its authenticated transport. - return (new ClientFactory)->create($client->uri, $client->credential); + return (new ClientFactory)->create($client->uri, $client->credential, apiVersion: ApiVersion::V2024_08_04); } } diff --git a/app/Services/Post/PostPreviewer.php b/app/Services/Post/PostPreviewer.php index 82455a45f1..20c4780105 100644 --- a/app/Services/Post/PostPreviewer.php +++ b/app/Services/Post/PostPreviewer.php @@ -7,6 +7,7 @@ use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Services\Social\ContentSanitizer; +use App\Support\Media\MediaUrl; use App\Support\ThreadReplies; use App\Support\YouTubeDescription; use App\Support\YouTubeMetadata; @@ -86,7 +87,7 @@ private function channelPreview(Post $post, string $original): array if ($replies !== []) { $preview['thread_replies'] = array_map(fn (array $reply): array => [ 'text' => $this->sanitizer->sanitize($reply['text'], $platform), - 'media' => $reply['media'], + 'media' => MediaUrl::apiItems($reply['media'], $post->workspace_id), ], $replies); } diff --git a/app/Support/Mail/PostPreview.php b/app/Support/Mail/PostPreview.php index ee9bc99d43..d50f690739 100644 --- a/app/Support/Mail/PostPreview.php +++ b/app/Support/Mail/PostPreview.php @@ -5,6 +5,7 @@ namespace App\Support\Mail; use App\Models\Post; +use App\Support\Media\MediaUrl; class PostPreview { @@ -16,7 +17,7 @@ public static function from(Post $post): array $media = $post->media_items->first(); $image = null; - if ($media?->isImage() && filled($media->url)) { + if (! MediaUrl::isAzure() && $media?->isImage() && filled($media->url)) { $width = $media->width(); $height = $media->height(); $scale = $width > 0 && $height > 0 ? min(1, 440 / $width, 280 / $height) : null; diff --git a/app/Support/Media/AzureUserDelegationSas.php b/app/Support/Media/AzureUserDelegationSas.php index 989661ff96..7995be0a6e 100644 --- a/app/Support/Media/AzureUserDelegationSas.php +++ b/app/Support/Media/AzureUserDelegationSas.php @@ -212,10 +212,11 @@ private function cachedDelegationKey( return null; } + $fetchedAt = data_get($payload, 'fetched_at'); if ( ! is_array($payload) || data_get($payload, 'schema') !== 1 - || ! is_int(data_get($payload, 'fetched_at')) + || ! is_int($fetchedAt) ) { $cache->forget($cacheKey); @@ -223,10 +224,8 @@ private function cachedDelegationKey( } $delegationKey = $this->normaliseDelegationKey(data_get($payload, 'key')); - $fetchedAt = data_get($payload, 'fetched_at'); if ( $delegationKey === null - || ! is_int($fetchedAt) || $now->getTimestamp() >= $fetchedAt + self::CACHE_TTL_SECONDS || ! $this->keyCovers($delegationKey, $sasStart, $sasExpiry, $now) ) { diff --git a/app/Support/Media/FlatBlobAdapter.php b/app/Support/Media/FlatBlobAdapter.php index c83126f205..d071b1e2b8 100644 --- a/app/Support/Media/FlatBlobAdapter.php +++ b/app/Support/Media/FlatBlobAdapter.php @@ -58,10 +58,8 @@ public function directoryExists(string $path): bool $prefix = self::encodePath($directory).self::SEPARATOR; - foreach ($this->adapter->listContents('', true) as $item) { - if ($item instanceof FileAttributes && str_starts_with($item->path(), $prefix)) { - return true; - } + foreach ($this->filesWithPrefix($prefix) as $item) { + return true; } return false; @@ -103,10 +101,8 @@ public function deleteDirectory(string $path): void $prefix = self::encodePath($directory).self::SEPARATOR; $physicalPaths = []; - foreach ($this->adapter->listContents('', true) as $item) { - if ($item instanceof FileAttributes && str_starts_with($item->path(), $prefix)) { - $physicalPaths[] = $item->path(); - } + foreach ($this->filesWithPrefix($prefix) as $item) { + $physicalPaths[] = $item->path(); } foreach ($physicalPaths as $physicalPath) { @@ -151,11 +147,7 @@ public function listContents(string $path, bool $deep): iterable $prefix = $directory === '' ? '' : self::encodePath($directory).self::SEPARATOR; $directories = []; - foreach ($this->adapter->listContents('', true) as $item) { - if (! $item instanceof FileAttributes || ! str_starts_with($item->path(), $prefix)) { - continue; - } - + foreach ($this->filesWithPrefix($prefix) as $item) { $relativePath = substr($item->path(), strlen($prefix)); $relativeSegments = explode(self::SEPARATOR, $relativePath); $fileSegment = array_pop($relativeSegments); @@ -269,6 +261,16 @@ private static function assertSegment(string $segment, string $path): void } } + /** @return iterable */ + private function filesWithPrefix(string $prefix): iterable + { + foreach ($this->adapter->listContents('', true) as $item) { + if ($item instanceof FileAttributes && str_starts_with($item->path(), $prefix)) { + yield $item; + } + } + } + private static function withLogicalPath(FileAttributes $attributes, string $path): FileAttributes { return new FileAttributes( diff --git a/docs/project/2026-10-25-azure-blob-media-plan.md b/docs/project/2026-10-25-azure-blob-media-plan.md index 334dd925b5..e1a90e183a 100644 --- a/docs/project/2026-10-25-azure-blob-media-plan.md +++ b/docs/project/2026-10-25-azure-blob-media-plan.md @@ -17,3 +17,5 @@ The source package ends with reviewed, CI-checked draft delivery. Entra/RBAC/SAS The optional backend changes delivery policy, not media ownership or database identity. Logical paths remain stable over the flat physical layout. No new product primitive is introduced. Unavailable media in API/MCP/webhook payloads retains its stable identity with a null delivery URL, so one absent attachment does not break a collection or lifecycle event. Browser file fetches still return not-found; confirmed-missing scheduled attachments fail before a provider call. Avatar storage failures yield no avatar, and redacted exception diagnostics retain file, line, safe stack frames and the previous chain. Real-Azure qualification includes API/MCP and avatar fan-out as well as individual redirects. + +Publication emails use their existing text and attachment fallback under Azure instead of sending session-only image URLs or bearer links. API/MCP thread previews use the same authorized expiring-link helper as post resources. Ranged reads use explicit connection, request and streamed-read timeouts. diff --git a/tests/Feature/ChunkedCloudUploadTest.php b/tests/Feature/ChunkedCloudUploadTest.php index 20d384265e..e3e596320b 100644 --- a/tests/Feature/ChunkedCloudUploadTest.php +++ b/tests/Feature/ChunkedCloudUploadTest.php @@ -285,7 +285,6 @@ public function __construct() parent::__construct( Cache::store(), new BlobContainerClient(new Uri('https://account.blob.core.windows.net/test-container')), - fn (string $path): string => 'flat/'.hash('sha256', $path), ); } @@ -359,6 +358,9 @@ protected function commitBlocks(BlockBlobClient $client, array $blockIds, string expect($uploader->readRange('medias/clip.mp4', 40, 16))->toBe('sixteen bytes!!!'); expect($history)->toHaveCount(1); expect($history[0]['request']->getHeaderLine('Range'))->toBe('bytes=40-55'); + expect($history[0]['options']['timeout'])->toBe(120) + ->and($history[0]['options']['connect_timeout'])->toBe(10) + ->and($history[0]['options']['read_timeout'])->toBe(120); expect($history[0]['request']->getUri()->getPath())->toBe( '/test-container/medias~clip.mp4' ); diff --git a/tests/Feature/Mail/PostPublicationPreviewTest.php b/tests/Feature/Mail/PostPublicationPreviewTest.php index 16b26f4222..f20b2e3c62 100644 --- a/tests/Feature/Mail/PostPublicationPreviewTest.php +++ b/tests/Feature/Mail/PostPublicationPreviewTest.php @@ -10,6 +10,8 @@ use App\Models\Post; use App\Models\SocialAccount; use App\Support\Mail\PostPreview; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Symfony\Component\DomCrawler\Crawler; test('the email channel component resolves the platform name and its network logo', function (Platform $platform) { @@ -152,3 +154,27 @@ ->and($image->attr('alt'))->toBe('Studio " onclick="alert(1)') ->and($image->attr('onclick'))->toBeNull(); }); + +test('azure publication emails use the attachment fallback without granting file access', function (Status $status) { + config(['filesystems.default' => 'azure', 'filesystems.disks.azure.driver' => 'azure-flat']); + Storage::fake('azure')->buildTemporaryUrlsUsing(fn () => throw new LogicException('Email must not grant file access.')); + $post = Post::factory()->instagram()->create([ + 'content' => null, + 'publish_status' => $status, + 'media' => [[ + 'id' => (string) Str::uuid(), + 'path' => 'medias/photo.jpg', + 'url' => 'https://example.test/photo.jpg', + 'type' => 'image', + 'original_filename' => 'Preview photo.jpg', + ]], + ]); + + $preview = PostPreview::from($post); + $mail = $status === Status::Published ? new PostPublished($post) : new PostPublishFailed($post); + $document = new Crawler($mail->render()); + + expect($preview['image'])->toBeNull() + ->and($document->filter('body')->text())->toContain($preview['attachment']) + ->and($document->filter('img[src="'.$post->media[0]['url'].'"]')->count())->toBe(0); +})->with([Status::Published, Status::Failed]); diff --git a/tests/Feature/Media/AzureBlobStorageTest.php b/tests/Feature/Media/AzureBlobStorageTest.php index 08ed059da3..aafd313c87 100644 --- a/tests/Feature/Media/AzureBlobStorageTest.php +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -8,6 +8,7 @@ use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\GetPostTool; use App\Mcp\Tools\Post\ListPostsTool; +use App\Mcp\Tools\Post\PreviewPostTool; use App\Models\Media; use App\Models\Post; use App\Models\SocialAccount; @@ -147,6 +148,30 @@ expect($this->signing->requests)->toHaveCount(1); }); +test('API and MCP thread previews grant only available workspace media', function (string $state) { + $media = $state === 'foreign' + ? Media::factory()->temporaryUpload(Workspace::factory()->create())->create(['path' => 'medias/foreign.jpg']) + : $this->media; + Storage::put($media->path, 'synthetic-image'); + if ($state === 'missing') { + Storage::delete($media->path); + } + $post = Post::factory()->bluesky()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $post->update(['meta' => ['thread_replies' => [['text' => 'Reply', 'media' => [MediaItem::fromMedia($media)->toArray()]]]]]); + $url = $state === 'available' + ? 'https://example.blob.core.windows.net/media/medias%2Ftest.jpg?sp=r&sig=synthetic' + : null; + + $this->withToken(passportToken($this->user, $this->workspace)) + ->getJson(route('api.posts.preview', $post)) + ->assertOk()->assertJsonPath('thread_replies.0.media.0.url', $url); + TryPostServer::actingAs($this->user) + ->tool(PreviewPostTool::class, ['post_id' => $post->id]) + ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('thread_replies.0.media.0.url', $url)->etc()); + expect($this->signing->requests)->toHaveCount($state === 'available' ? 2 : 0); +})->with(['available', 'missing', 'foreign']); + test('the publishing consumer resolves media access when it executes', function () { $item = MediaItem::fromMedia($this->media); expect($this->signing->requests)->toBeEmpty();