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..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' => $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, @@ -82,6 +83,11 @@ public function isVideo(): bool return $this->kind() === Type::Video; } + public function publishUrl(): string + { + return 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..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,6 +21,17 @@ */ class MediaFileController extends Controller { + 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(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..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' => $this->media ?? [], + '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..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' => $this->media, + '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, + '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 c1a9507e25..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' => $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/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 90110b27bc..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; @@ -115,6 +117,10 @@ public function handle(): void return; } + if ($this->failForMissingStoredMedia()) { + return; + } + if ($this->failForInvalidMedia()) { return; } @@ -251,6 +257,48 @@ private function failForMissingScopes(): bool return true; } + private function failForMissingStoredMedia(): bool + { + if (! 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 !== '' && ! MediaUrl::exists($path)) { + $this->failAndFinalize('A media attachment is unavailable.', $this->failureContext([ + 'reason' => 'media_missing', + 'category' => ErrorCategory::MediaFormat->value, + ])); + + return true; + } + } + } catch (Throwable) { + $this->rescheduleForRetry(new PlatformUnavailableException( + 'Media storage is unavailable.', + context: ['reason' => 'media_unavailable'], + )); + $this->updatePostStatus(); + $this->broadcastStatus(); + + 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 +358,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void ...($hasOwnPolicy ? ['max_retries' => $maxRetries, 'retry_delay_seconds' => $retryDelaySeconds] : []), 'detail' => $e->getMessage(), ]; + $context = RedactStorageCredentials::clean($context); if ($retryCount > $maxRetries) { $this->reportCaughtPublishFailure($e, [ @@ -513,8 +562,8 @@ private function markPlatformAsFailed(string $message, ?array $context = null): $failureContext = [...$previousContext, ...($context ?? [])]; $this->post->markPublicationFailed( - ThreadProgress::failureMessage($this->post, $message, $failureContext), - $failureContext === [] ? null : $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 e7bf1172a6..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' => 'array', + 'media' => StoredMedia::class, 'position' => 'integer', ]; } diff --git a/app/Models/Media.php b/app/Models/Media.php index e93c2dfc4a..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 () => Storage::url($this->path), + get: fn () => MediaUrl::browser($this), ); } diff --git a/app/Models/Post.php b/app/Models/Post.php index bee8c9e20e..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' => 'array', + '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' => 'array', + '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 - ?: ($this->platform_avatar ? Storage::url($this->platform_avatar) : null), + ?: MediaUrl::avatar($this->platform_avatar), ); } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index e74c1edfe1..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) => $value ? Storage::url($value) : null, + get: fn (?string $value) => 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..a03bf3da95 --- /dev/null +++ b/app/Services/Media/AzureBlockUploader.php @@ -0,0 +1,331 @@ +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(FlatBlobAdapter::encodePath($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 = (string) data_get($state, 'mime_type'); + $this->commitBlocks($client, data_get($state, 'block_ids'), $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, + * 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; + + return [ + 'path' => $path, + 'blob_name' => FlatBlobAdapter::encodePath($path), + 'mime_type' => $this->detectMimeType(substr($chunk, 0, self::MIME_SNIFF_BYTES), $extension), + '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'; + } + + /** + * @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, + RequestOptions::TIMEOUT => 120, + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::READ_TIMEOUT => 120, + ]); + + $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, apiVersion: ApiVersion::V2024_08_04); + } +} 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/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/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..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 Storage::url($path); + return 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..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; @@ -173,14 +174,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 +190,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 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..2813d9ecbf 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->path), ]; $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..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; @@ -229,7 +230,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 +334,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 +352,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 +386,7 @@ private function hostResizedPhoto(string $tempInput): array @unlink($optimized); } - return [Storage::url($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/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/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/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 new file mode 100644 index 0000000000..7995be0a6e --- /dev/null +++ b/app/Support/Media/AzureUserDelegationSas.php @@ -0,0 +1,529 @@ + 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; + } + + $fetchedAt = data_get($payload, 'fetched_at'); + if ( + ! is_array($payload) + || data_get($payload, 'schema') !== 1 + || ! is_int($fetchedAt) + ) { + $cache->forget($cacheKey); + + return null; + } + + $delegationKey = $this->normaliseDelegationKey(data_get($payload, 'key')); + if ( + $delegationKey === null + || $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..d071b1e2b8 --- /dev/null +++ b/app/Support/Media/FlatBlobAdapter.php @@ -0,0 +1,290 @@ +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->filesWithPrefix($prefix) as $item) { + 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->filesWithPrefix($prefix) as $item) { + $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->filesWithPrefix($prefix) as $item) { + $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'); + } + } + + /** @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( + 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..44f1fd4e42 --- /dev/null +++ b/app/Support/Media/MediaUrl.php @@ -0,0 +1,165 @@ + $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); + } + + try { + return self::temporary($path, 60); + } catch (Throwable) { + return 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'])) { + $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) { + 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..cae704bf52 --- /dev/null +++ b/app/Support/Media/RedactStorageCredentials.php @@ -0,0 +1,56 @@ +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()), + '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) { + $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..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,12 +57,14 @@ 'stack' => [ 'driver' => 'stack', + 'tap' => [RedactStorageCredentials::class], 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', + 'tap' => [RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, @@ -69,6 +72,7 @@ 'daily' => [ 'driver' => 'daily', + 'tap' => [RedactStorageCredentials::class], 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), @@ -77,6 +81,7 @@ 'slack' => [ 'driver' => 'slack', + 'tap' => [RedactStorageCredentials::class], 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), @@ -86,6 +91,7 @@ 'papertrail' => [ 'driver' => 'monolog', + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ @@ -98,6 +104,7 @@ 'stderr' => [ 'driver' => 'monolog', + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'handler_with' => [ @@ -109,6 +116,7 @@ 'syslog' => [ 'driver' => 'syslog', + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true, @@ -116,12 +124,14 @@ 'errorlog' => [ 'driver' => 'errorlog', + 'tap' => [RedactStorageCredentials::class], 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', + 'tap' => [RedactStorageCredentials::class], 'handler' => NullHandler::class, ], 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..e1a90e183a --- /dev/null +++ b/docs/project/2026-10-25-azure-blob-media-plan.md @@ -0,0 +1,21 @@ +# 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. + +## 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. + +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/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..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; @@ -956,3 +957,30 @@ 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' => [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..e3e596320b 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,48 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 ); } +function fakeAzureBlockUploader(?Closure $httpClientFactory = null): AzureBlockUploader +{ + $container = new BlobContainerClient(new Uri('https://account.blob.core.windows.net/test-container')); + + return new class($container, $httpClientFactory) extends AzureBlockUploader + { + /** @var array */ + public array $stagedBlocks = []; + + /** @var array */ + public array $committedBlockIds = []; + + public ?string $committedContentType = null; + + public function __construct( + BlobContainerClient $container, + ?Closure $httpClientFactory, + ) { + parent::__construct( + Cache::store(), + $container, + $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 +137,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 +154,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 +218,176 @@ 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(); + $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')), + ); + } + + 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]['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' + ); +}); + +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..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 () { @@ -73,7 +74,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', @@ -101,3 +120,17 @@ 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]); + 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'); + Queue::assertPushed(PublishToSocialPlatform::class); +}); 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 new file mode 100644 index 0000000000..aafd313c87 --- /dev/null +++ b/tests/Feature/Media/AzureBlobStorageTest.php @@ -0,0 +1,263 @@ +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(); + expect(MediaUrl::apiItems([MediaItem::fromMedia($this->media)->toArray()])[0]['url'])->toBeNull(); + 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=')->not->toContain('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))->assertOk()->assertJsonPath('media.0.url', null); + 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))->assertOk()->assertJsonPath('media.0.url', null); + 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('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(); + $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()); +}); + +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 = 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 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(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 = 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(); +}); + +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'; + $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)); + $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=')->not->toContain('synthetic'); + foreach ($record['trace'] as $frame) { + expect($frame)->not->toHaveKey('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); } 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" + } + ] +}