diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 16a22ec62..0ab200a83 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -63,8 +63,8 @@ jobs: target: production platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - build-args: | - VITE_REVERB_APP_KEY=trypost-reverb-key + secrets: | + vite_reverb_app_key=trypost-reverb-key cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }} cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }} outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true diff --git a/app/Actions/Post/AttachExistingAsset.php b/app/Actions/Post/AttachExistingAsset.php new file mode 100644 index 000000000..d7993dad5 --- /dev/null +++ b/app/Actions/Post/AttachExistingAsset.php @@ -0,0 +1,54 @@ +id)->lockForUpdate()->firstOrFail(); + $items = collect($fresh->media ?? []); + + if ($items->contains(fn (array $item): bool => data_get($item, 'id') === $media->id)) { + $post->setRawAttributes($fresh->getAttributes(), true); + + return false; + } + + $item = [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => $media->type, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + ]; + + if ($alt !== null && $media->isImage()) { + $item['meta'] = ['alt_text' => $alt]; + } + + $fresh->update([ + 'media' => $items->push($item)->all(), + ]); + + $post->setRawAttributes($fresh->getAttributes(), true); + + return true; + }); + } +} diff --git a/app/Http/Controllers/Media/AssetPreviewController.php b/app/Http/Controllers/Media/AssetPreviewController.php new file mode 100644 index 000000000..b6d38179e --- /dev/null +++ b/app/Http/Controllers/Media/AssetPreviewController.php @@ -0,0 +1,95 @@ +whereKey($media) + ->where('mediable_type', (new Workspace)->getMorphClass()) + ->where('mediable_id', $workspace) + ->where('collection', 'assets') + ->firstOrFail(); + + $disk = Storage::disk((string) config('filesystems.default', 'local')); + + abort_unless($disk->exists($asset->path), 404); + + if ($disk instanceof LocalFilesystemAdapter && $path = $this->localPath($disk, $asset->path)) { + return new BinaryFileResponse($path, 200, $this->headers($asset)); + } + + $stream = $disk->readStream($asset->path); + + abort_unless(is_resource($stream), 404); + + return response()->stream(function () use ($stream): void { + try { + fpassthru($stream); + } finally { + fclose($stream); + } + }, 200, $this->headers($asset)); + } + + /** + * Returns an absolute local filesystem path only when the resolved asset path + * remains inside the configured disk root. + */ + private function localPath(LocalFilesystemAdapter $disk, string $path): ?string + { + $root = realpath($disk->path('')); + $resolved = realpath($disk->path($path)); + + if ($root === false || $resolved === false || ! is_file($resolved) || ! is_readable($resolved)) { + return null; + } + + $root = rtrim($root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + return str_starts_with($resolved, $root) ? $resolved : null; + } + + /** + * @return array + */ + private function headers(Media $asset): array + { + return [ + 'Content-Type' => $asset->mime_type, + 'Content-Disposition' => HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_INLINE, + $this->safeFilename($asset->original_filename), + $this->fallbackFilename($asset->original_filename), + ), + ]; + } + + private function safeFilename(string $filename): string + { + return basename(str_replace('\\', '/', $filename)) ?: 'asset'; + } + + private function fallbackFilename(string $filename): string + { + $fallback = Str::ascii($this->safeFilename($filename)); + $fallback = str_replace(['%', '/', '\\'], '-', $fallback); + $fallback = preg_replace('/[^\x20-\x7E]/', '', $fallback) ?: ''; + + return trim($fallback) !== '' ? $fallback : 'asset'; + } +} diff --git a/app/Http/Middleware/App/SetLocale.php b/app/Http/Middleware/App/SetLocale.php index a28cad1eb..7c9f0bf18 100644 --- a/app/Http/Middleware/App/SetLocale.php +++ b/app/Http/Middleware/App/SetLocale.php @@ -30,7 +30,7 @@ public function handle(Request $request, Closure $next): Response $response = $next($request); if (! $isValid) { - $response->withCookie( + $response->headers->setCookie( cookie()->forever('locale', config('languages.default'), '/', config('session.domain')), ); } diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index cd2d18502..0c14fd575 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -7,6 +7,9 @@ use App\Mcp\Tools\ApiKey\CreateApiKeyTool; use App\Mcp\Tools\ApiKey\DeleteApiKeyTool; use App\Mcp\Tools\ApiKey\ListApiKeysTool; +use App\Mcp\Tools\Asset\AttachExistingAssetTool; +use App\Mcp\Tools\Asset\GetAssetPreviewTool; +use App\Mcp\Tools\Asset\ListAssetsTool; use App\Mcp\Tools\Label\CreateLabelTool; use App\Mcp\Tools\Label\DeleteLabelTool; use App\Mcp\Tools\Label\ListLabelsTool; @@ -56,6 +59,11 @@ class TryPostServer extends Server AttachMediaFromUploadTool::class, GetPostMetricsTool::class, + // Assets + ListAssetsTool::class, + GetAssetPreviewTool::class, + AttachExistingAssetTool::class, + // Platforms (read-only metadata) ListContentTypesTool::class, diff --git a/app/Mcp/Tools/Asset/AttachExistingAssetTool.php b/app/Mcp/Tools/Asset/AttachExistingAssetTool.php new file mode 100644 index 000000000..28d5753f3 --- /dev/null +++ b/app/Mcp/Tools/Asset/AttachExistingAssetTool.php @@ -0,0 +1,89 @@ +validate([ + 'post_id' => ['required', 'uuid'], + 'asset_id' => ['required', 'uuid'], + 'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH], + ]); + + $workspaceId = $request->user()->current_workspace_id; + + $post = Post::where('workspace_id', $workspaceId) + ->find(data_get($validated, 'post_id')); + + if (! $post) { + return Response::error('post_not_found'); + } + + if (! Gate::forUser($request->user())->inspect('update', $post)->allowed()) { + return Response::error('forbidden'); + } + + if (PostStatusRules::blocksEditing($post)) { + return Response::error('post_not_editable'); + } + + $media = Media::query() + ->whereKey(data_get($validated, 'asset_id')) + ->where('mediable_type', (new Workspace)->getMorphClass()) + ->where('mediable_id', $workspaceId) + ->where('collection', 'assets') + ->first(); + + if (! $media) { + return Response::error('asset_not_found'); + } + + if (! in_array($media->type, $post->allowedMediaTypes(), true)) { + return Response::error('media_type_not_allowed'); + } + + $attached = app(AttachExistingAsset::class)->handle( + $post, + $media, + data_get($validated, 'alt'), + ); + + $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + + return Response::structured([ + 'asset_id' => $media->id, + 'attached' => $attached, + 'already_attached' => ! $attached, + 'post' => (new PostResource($post))->resolve(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('UUID of the draft or scheduled post to update.'), + 'asset_id' => $schema->string()->required()->description('UUID of an Asset Library media item in the current workspace.'), + 'alt' => $schema->string()->description('Optional accessibility alt text for image assets. Ignored for videos and documents.'), + ]; + } +} diff --git a/app/Mcp/Tools/Asset/GetAssetPreviewTool.php b/app/Mcp/Tools/Asset/GetAssetPreviewTool.php new file mode 100644 index 000000000..ed9b480ce --- /dev/null +++ b/app/Mcp/Tools/Asset/GetAssetPreviewTool.php @@ -0,0 +1,65 @@ +validate([ + 'asset_id' => ['required', 'uuid'], + ]); + + $media = Media::query() + ->whereKey(data_get($validated, 'asset_id')) + ->where('mediable_type', (new Workspace)->getMorphClass()) + ->where('mediable_id', $request->user()->current_workspace_id) + ->where('collection', 'assets') + ->first(); + + if (! $media) { + return Response::error('asset_not_found'); + } + + try { + $previewUrls->ensureAvailable($media); + $expiresAt = CarbonImmutable::now('UTC')->addMinutes(5); + $preview = $previewUrls->temporaryUrl($media, $request->user()->currentWorkspace, $expiresAt); + } catch (RuntimeException) { + return Response::error('preview_unavailable'); + } + + return Response::structured([ + 'asset_id' => $media->id, + 'mime_type' => $media->mime_type, + 'size_bytes' => $media->size, + 'expires_at' => $preview['expires_at'], + 'preview_mode' => $preview['mode'], + 'preview_url' => $preview['url'], + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->string()->required()->description('UUID of the Asset Library media item to preview.'), + ]; + } +} diff --git a/app/Mcp/Tools/Asset/ListAssetsTool.php b/app/Mcp/Tools/Asset/ListAssetsTool.php new file mode 100644 index 000000000..7953dfa8a --- /dev/null +++ b/app/Mcp/Tools/Asset/ListAssetsTool.php @@ -0,0 +1,220 @@ +validate([ + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'search' => ['sometimes', 'string', 'max:255'], + 'mime_type' => ['sometimes', 'string', 'max:255'], + 'category' => ['sometimes', 'string', Rule::in(['image', 'video', 'document'])], + 'usage' => ['sometimes', 'string', Rule::in(['all', 'used', 'unused'])], + 'sort' => ['sometimes', 'string', Rule::in([ + 'created_at', + 'last_used_at', + 'usage_count', + 'publication_usage_count', + 'timestamped_publication_usage_count', + ])], + 'direction' => ['sometimes', 'string', Rule::in(['asc', 'desc'])], + ]); + + $page = (int) data_get($validated, 'page', 1); + $perPage = (int) data_get($validated, 'per_page', 25); + $sort = (string) data_get($validated, 'sort', 'created_at'); + $direction = (string) data_get($validated, 'direction', 'desc'); + $workspaceId = $request->user()->current_workspace_id; + + $query = Media::query() + ->where('mediable_type', (new Workspace)->getMorphClass()) + ->where('mediable_id', $workspaceId) + ->where('collection', 'assets'); + + if ($search = data_get($validated, 'search')) { + $query->where('original_filename', 'like', '%'.trim((string) $search).'%'); + } + + if ($mimeType = data_get($validated, 'mime_type')) { + $mimeType = trim((string) $mimeType); + str_ends_with($mimeType, '/*') + ? $query->where('mime_type', 'like', substr($mimeType, 0, -1).'%') + : $query->where('mime_type', $mimeType); + } + + if ($category = data_get($validated, 'category')) { + $query->where('type', $category); + } + + if (! $this->requiresUsageWideProjection((string) data_get($validated, 'usage', 'all'), $sort)) { + $total = (clone $query)->count(); + $assets = $query + ->orderBy('created_at', $direction) + ->orderBy('id') + ->forPage($page, $perPage) + ->get(); + $usage = $usageQuery->forAssets( + $request->user()->currentWorkspace, + $assets->pluck('id')->all(), + CarbonImmutable::now('UTC'), + ); + + return Response::structured([ + 'assets' => $assets + ->map(fn (Media $media) => $this->assetPayload($media, $usage[$media->id] ?? [])) + ->values() + ->all(), + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'has_more' => $page * $perPage < $total, + ], + ]); + } + + $assets = $query->get(); + $usage = $usageQuery->forAssets( + $request->user()->currentWorkspace, + $assets->pluck('id')->all(), + CarbonImmutable::now('UTC'), + ); + + $items = $assets + ->map(fn (Media $media) => $this->assetPayload($media, $usage[$media->id] ?? [])); + + $items = $this->filterByUsage($items, (string) data_get($validated, 'usage', 'all')); + $items = $this->sortItems($items, $sort, $direction); + + $total = $items->count(); + $pageItems = $items->forPage($page, $perPage)->values(); + + return Response::structured([ + 'assets' => $pageItems->all(), + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'has_more' => $page * $perPage < $total, + ], + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number, minimum 1. Default: 1.'), + 'per_page' => $schema->integer()->description('Items per page, 1-100. Default: 25.'), + 'search' => $schema->string()->description('Filename substring search, maximum 255 characters.'), + 'mime_type' => $schema->string()->description('Exact MIME type or safe prefix such as image/*.'), + 'category' => $schema->string()->enum(['image', 'video', 'document'])->description('Media category.'), + 'usage' => $schema->string()->enum(['all', 'used', 'unused'])->description('Filter by current content usage.'), + 'sort' => $schema->string()->enum([ + 'created_at', + 'last_used_at', + 'usage_count', + 'publication_usage_count', + 'timestamped_publication_usage_count', + ])->description('Sort field.'), + 'direction' => $schema->string()->enum(['asc', 'desc'])->description('Sort direction.'), + ]; + } + + private function requiresUsageWideProjection(string $usage, string $sort): bool + { + return $usage !== 'all' || in_array($sort, [ + 'last_used_at', + 'usage_count', + 'publication_usage_count', + 'timestamped_publication_usage_count', + ], true); + } + + /** + * @param array $usage + * @return array + */ + private function assetPayload(Media $media, array $usage): array + { + return array_merge([ + 'asset_id' => $media->id, + 'filename' => $media->original_filename, + 'mime_type' => $media->mime_type, + 'category' => $media->type instanceof MediaType ? $media->type->value : (string) $media->type, + 'size_bytes' => $media->size, + 'width' => data_get($media->meta, 'width'), + 'height' => data_get($media->meta, 'height'), + 'duration_seconds' => is_numeric(data_get($media->meta, 'duration')) ? (int) data_get($media->meta, 'duration') : null, + 'created_at' => $media->created_at?->utc()->toAtomString(), + 'preview_available' => filled($media->path), + ], $usage); + } + + /** + * @param Collection> $items + * @return Collection> + */ + private function filterByUsage(Collection $items, string $usage): Collection + { + return match ($usage) { + 'used' => $items->filter(fn (array $item) => $item['is_used']), + 'unused' => $items->filter(fn (array $item) => ! $item['is_used']), + default => $items, + }; + } + + /** + * @param Collection> $items + * @return Collection> + */ + private function sortItems(Collection $items, string $sort, string $direction): Collection + { + return $items + ->sortBy([ + fn (array $left, array $right): int => $this->compareNullable($left[$sort] ?? null, $right[$sort] ?? null, $direction), + fn (array $left, array $right): int => strcmp((string) $left['asset_id'], (string) $right['asset_id']), + ]) + ->values(); + } + + private function compareNullable(mixed $left, mixed $right, string $direction): int + { + if ($left === null && $right === null) { + return 0; + } + + if ($left === null) { + return 1; + } + + if ($right === null) { + return -1; + } + + $result = $left <=> $right; + + return $direction === 'desc' ? -$result : $result; + } +} diff --git a/app/Services/Media/AssetPreviewUrlFactory.php b/app/Services/Media/AssetPreviewUrlFactory.php new file mode 100644 index 000000000..6976ed0dd --- /dev/null +++ b/app/Services/Media/AssetPreviewUrlFactory.php @@ -0,0 +1,49 @@ + Storage::disk($disk)->temporaryUrl($media->path, $expiresAt), + 'expires_at' => $expiresAt->utc()->toAtomString(), + 'mode' => 'temporary_url', + ]; + } + + return [ + 'url' => URL::temporarySignedRoute('media.asset-preview.show', $expiresAt, [ + 'workspace' => $workspace->id, + 'media' => $media->id, + ]), + 'expires_at' => $expiresAt->utc()->toAtomString(), + 'mode' => 'signed_route', + ]; + } + + public function ensureAvailable(Media $media): void + { + if (! Storage::disk((string) Config::get('filesystems.default', 'local'))->exists($media->path)) { + throw new RuntimeException('preview_unavailable'); + } + } +} diff --git a/app/Services/Media/AssetUsageQuery.php b/app/Services/Media/AssetUsageQuery.php new file mode 100644 index 000000000..7a9884068 --- /dev/null +++ b/app/Services/Media/AssetUsageQuery.php @@ -0,0 +1,275 @@ + $assetIds + * @return array> + */ + public function forAssets(Workspace $workspace, array $assetIds, CarbonImmutable $nowUtc): array + { + $assetIds = array_values(array_unique(array_filter($assetIds))); + $usage = []; + + foreach ($assetIds as $assetId) { + $usage[$assetId] = $this->emptyUsage(); + } + + if ($assetIds === []) { + return $usage; + } + + $posts = $this->postsReferencingAssets($workspace, $assetIds) + ->with('postPlatforms') + ->get(); + + $associated = []; + + foreach ($posts as $post) { + $postAssetIds = collect($post->media ?? []) + ->pluck('id') + ->filter() + ->unique() + ->intersect($assetIds) + ->values(); + + foreach ($postAssetIds as $assetId) { + $associated[$assetId] ??= []; + $associated[$assetId][$post->id] = $post; + } + } + + foreach ($associated as $assetId => $postsById) { + $usage[$assetId] = $this->usageForPosts(collect($postsById), $nowUtc); + } + + return $usage; + } + + /** + * @param array $assetIds + */ + private function postsReferencingAssets(Workspace $workspace, array $assetIds): Builder + { + $query = Post::query() + ->where('workspace_id', $workspace->id) + ->whereNotNull('media'); + + $driver = DB::connection()->getDriverName(); + + if ($driver === 'pgsql') { + return $query->where(function ($query) use ($assetIds): void { + foreach ($assetIds as $assetId) { + $query->orWhereRaw($this->postgresAssetContainsExpression(), [json_encode([['id' => $assetId]])]); + } + }); + } + + if ($driver === 'sqlite') { + return $query->where(function ($query) use ($assetIds): void { + foreach ($assetIds as $assetId) { + $query->orWhereRaw( + "exists (select 1 from json_each(posts.media) where json_extract(json_each.value, '$.id') = ?)", + [$assetId], + ); + } + }); + } + + return $query->where(function ($query) use ($assetIds): void { + foreach ($assetIds as $assetId) { + $query->orWhereJsonContains('media', ['id' => $assetId]); + } + }); + } + + /** + * @return array + */ + private function emptyUsage(): array + { + return [ + 'is_used' => false, + 'usage_count' => 0, + 'content_usage_count' => 0, + 'publication_usage_count' => 0, + 'timestamped_publication_usage_count' => 0, + 'configured_platforms' => [], + 'configured_content_types' => [], + 'published_platforms' => [], + 'published_content_types' => [], + 'content_statuses' => [], + 'publication_statuses' => [], + 'latest_content_id' => null, + 'latest_content_ids' => [], + 'latest_content_basis' => null, + 'last_used_at' => null, + 'last_use_basis' => null, + 'last_use_contexts' => [], + 'days_since_last_use' => null, + ]; + } + + /** + * @param Collection $posts + * @return array + */ + private function usageForPosts(Collection $posts, CarbonImmutable $nowUtc): array + { + $enabledPlatforms = $posts + ->flatMap(fn (Post $post) => $post->postPlatforms->filter(fn (PostPlatform $platform) => $platform->enabled)); + + $publishedPlatforms = $enabledPlatforms + ->filter(fn (PostPlatform $platform) => $platform->status === PostPlatformStatus::Published); + + $contexts = $this->timestampContexts($posts); + $lastUsedAt = $contexts->max('used_at'); + $lastContexts = $lastUsedAt === null + ? collect() + : $contexts + ->filter(fn (array $context) => $context['used_at'] === $lastUsedAt) + ->sortBy([ + ['content_id', 'asc'], + ['platform', 'asc'], + ['content_type', 'asc'], + ['use_basis', 'asc'], + ]) + ->values(); + + $latestContentIds = $lastContexts + ->pluck('content_id') + ->unique() + ->sort() + ->values() + ->all(); + + $latestContentId = $latestContentIds[0] ?? $posts->sortByDesc('created_at')->first()?->id; + $useBases = $lastContexts->pluck('use_basis')->unique()->values(); + $lastUseBasis = $useBases->count() > 1 ? 'mixed' : $useBases->first(); + + return [ + 'is_used' => $posts->isNotEmpty(), + 'usage_count' => $posts->count(), + 'content_usage_count' => $posts->count(), + 'publication_usage_count' => $publishedPlatforms->count(), + 'timestamped_publication_usage_count' => $publishedPlatforms->filter(fn (PostPlatform $platform) => $platform->published_at !== null)->count(), + 'configured_platforms' => $this->sortedEnumValues($enabledPlatforms->pluck('platform')), + 'configured_content_types' => $this->sortedEnumValues($enabledPlatforms->pluck('content_type')), + 'published_platforms' => $this->sortedEnumValues($publishedPlatforms->pluck('platform')), + 'published_content_types' => $this->sortedEnumValues($publishedPlatforms->pluck('content_type')), + 'content_statuses' => $this->sortedEnumValues($posts->pluck('status')), + 'publication_statuses' => $this->sortedEnumValues($enabledPlatforms->pluck('status')), + 'latest_content_id' => $latestContentId, + 'latest_content_ids' => $latestContentIds, + 'latest_content_basis' => $latestContentId === null + ? null + : ($lastUsedAt === null ? 'content_created_at_fallback' : $lastUseBasis), + 'last_used_at' => $lastUsedAt, + 'last_use_basis' => $lastUseBasis, + 'last_use_contexts' => $lastContexts->all(), + 'days_since_last_use' => $lastUsedAt === null ? null : $this->daysSince($lastUsedAt, $nowUtc), + ]; + } + + /** + * @param Collection $posts + * @return Collection> + */ + private function timestampContexts(Collection $posts): Collection + { + return $posts->flatMap(function (Post $post): array { + $published = $post->postPlatforms + ->filter(fn (PostPlatform $platform) => $platform->enabled && $platform->status === PostPlatformStatus::Published); + + $platformContexts = $published + ->filter(fn (PostPlatform $platform) => $platform->published_at !== null) + ->map(fn (PostPlatform $platform) => $this->context($post, $platform, $platform->published_at, 'platform_published_at')) + ->all(); + + if ($platformContexts !== []) { + return $platformContexts; + } + + if ($post->published_at !== null && in_array($post->status, [PostStatus::Published, PostStatus::PartiallyPublished], true)) { + $rows = $published->isNotEmpty() ? $published : collect([null]); + + return $rows + ->map(fn (?PostPlatform $platform) => $this->context($post, $platform, $post->published_at, 'post_published_at')) + ->all(); + } + + if ($post->scheduled_at !== null && $post->status === PostStatus::Scheduled) { + $rows = $post->postPlatforms + ->filter(fn (PostPlatform $platform) => $platform->enabled); + + if ($rows->isEmpty()) { + $rows = collect([null]); + } + + return $rows + ->map(fn (?PostPlatform $platform) => $this->context($post, $platform, $post->scheduled_at, 'scheduled_at')) + ->all(); + } + + return []; + })->values(); + } + + /** + * @return array + */ + private function context(Post $post, ?PostPlatform $platform, mixed $usedAt, string $basis): array + { + return [ + 'content_id' => $post->id, + 'platform' => $platform?->platform?->value, + 'content_type' => $platform?->content_type?->value, + 'content_status' => $post->status->value, + 'publication_status' => $platform?->status?->value, + 'used_at' => CarbonImmutable::parse($usedAt)->utc()->toAtomString(), + 'use_basis' => $basis, + ]; + } + + /** + * @param Collection $values + * @return array + */ + private function sortedEnumValues(Collection $values): array + { + return $values + ->map(fn (mixed $value) => $value?->value ?? $value) + ->filter() + ->unique() + ->sort() + ->values() + ->all(); + } + + private function daysSince(string $lastUsedAt, CarbonImmutable $nowUtc): int + { + $lastDate = CarbonImmutable::parse($lastUsedAt)->utc()->startOfDay(); + $nowDate = $nowUtc->utc()->startOfDay(); + + return max(0, (int) $lastDate->diffInDays($nowDate, false)); + } + + private function postgresAssetContainsExpression(): string + { + return 'media::jsonb @> ?::jsonb'; + } +} diff --git a/compose.prod.yaml b/compose.prod.yaml index d5bafaab9..24669d61b 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -40,9 +40,10 @@ services: BROADCAST_CONNECTION: reverb # ===== WebSockets (Reverb) ===== - # The published image bakes the client at localhost:8080. For a custom + # Vite bakes these client values at image build time. For a custom # domain, rebuild the image with --build-arg VITE_REVERB_HOST= - # VITE_REVERB_PORT=443 VITE_REVERB_SCHEME=https. + # VITE_REVERB_PORT=443 VITE_REVERB_SCHEME=https and pass the public + # app key as a BuildKit secret: id=vite_reverb_app_key. REVERB_APP_ID: "1001" REVERB_APP_KEY: trypost-reverb-key # must match the key baked into the published image REVERB_APP_SECRET: change-me-reverb-secret # <- change me diff --git a/docker/Dockerfile b/docker/Dockerfile index 3844c9d7c..a2ea95e9e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -89,10 +89,12 @@ RUN composer install \ # invokes `php artisan wayfinder:generate` during vite build — splitting # the stages would mean the vite phase has no PHP available. # -# Vite inlines VITE_* values into the JS bundle at build time. Pass them -# as build args (--build-arg VITE_REVERB_APP_KEY=...) so the bundle reaches -# the browser with the correct Reverb/PostHog config. Runtime env vars on -# the container have no effect on the already-compiled bundle. +# Vite inlines VITE_* values into the JS bundle at build time. Runtime env vars +# on the container have no effect on the already-compiled bundle. +# +# Pass the public Reverb app key with a BuildKit secret: +# --secret id=vite_reverb_app_key,env=VITE_REVERB_APP_KEY +# Non-sensitive Vite values can be passed as build args. # ---------------------------------------------------------------------------- FROM composer-deps AS asset-build @@ -100,7 +102,6 @@ FROM composer-deps AS asset-build RUN apk add --no-cache nodejs npm ARG VITE_APP_NAME=TryPost -ARG VITE_REVERB_APP_KEY= ARG VITE_REVERB_HOST=localhost ARG VITE_REVERB_PORT=8080 ARG VITE_REVERB_SCHEME=http @@ -114,7 +115,6 @@ ENV APP_KEY=base64:c3R1Yi13YXlmaW5kZXItZ2VuLWtleS1mb3ItYnVpbGRpbmctYXNzZXRzMA== APP_DEBUG=false \ APP_URL=http://localhost \ VITE_APP_NAME=${VITE_APP_NAME} \ - VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY} \ VITE_REVERB_HOST=${VITE_REVERB_HOST} \ VITE_REVERB_PORT=${VITE_REVERB_PORT} \ VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME} \ @@ -133,7 +133,9 @@ RUN mkdir -p storage/framework/cache/data \ storage/logs \ bootstrap/cache -RUN composer dump-autoload --no-scripts --optimize \ +RUN --mount=type=secret,id=vite_reverb_app_key,required=true \ + export VITE_REVERB_APP_KEY="$(cat /run/secrets/vite_reverb_app_key)" \ + && composer dump-autoload --no-scripts --optimize \ && php artisan wayfinder:generate --with-form \ && npm ci --no-audit --no-fund \ && npm run build \ @@ -200,6 +202,7 @@ RUN rm -rf /var/www/html/vendor /var/www/html/bootstrap/cache/*.php COPY --from=composer-deps-prod /var/www/html/vendor /var/www/html/vendor RUN composer dump-autoload --optimize --classmap-authoritative --no-scripts \ + && chmod -R a+rX /var/www/html \ && chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache ENV TRYPOST_TARGET=production diff --git a/docs/mcp-asset-library.md b/docs/mcp-asset-library.md new file mode 100644 index 000000000..eaf020712 --- /dev/null +++ b/docs/mcp-asset-library.md @@ -0,0 +1,165 @@ +# MCP Asset Library tools + +This document describes the Asset Library MCP extension implemented on top of the +existing TryPost workspace media model. The extension is schema-compatible with +existing installations: it does not add database tables, columns, or indexes. + +## Tools + +### `list_assets` + +Lists reusable workspace asset-library media from the authenticated user's +current workspace. + +Input: + +- `page`: optional positive integer, default `1`. +- `per_page`: optional positive integer, default `25`, max `100`. +- `search`: optional filename substring. +- `mime_type`: optional exact MIME type or family filter such as `image/*`. +- `category`: optional media category: `image`, `video`, or `document`. +- `usage`: optional usage filter: `all`, `unused`, or `used`. +- `sort`: optional sort key: `created_at`, `last_used_at`, or + `publication_usage_count`, `timestamped_publication_usage_count`, or + `usage_count`. +- `direction`: optional `asc` or `desc`. + +Output: + +- `assets[]` contains safe media metadata: + - `asset_id` + - `filename` + - `mime_type` + - `category` + - `size_bytes` + - optional dimensions/duration from existing media metadata + - `created_at` + - `preview_available` + - usage projection fields described below +- `pagination` contains pagination metadata. + +The response never includes storage paths, storage bucket names, workspace IDs, +or permanent public media URLs. + +### `get_asset_preview` + +Returns a short-lived preview URL for a single asset in the current workspace. + +Input: + +- `asset_id`: required UUID for an asset-library media item. + +Output: + +- `asset_id` +- `mime_type` +- `size_bytes` +- `expires_at` +- `preview_mode`: `temporary_url` for temporary object-store URLs or + `signed_route` for the local signed preview route. +- `preview_url` + +The preview route is signed, expires after five minutes, re-checks workspace +ownership, and returns `404` when the storage object is missing or the media does +not belong to the requested workspace. For Laravel local filesystem disks, +including NFS mounts exposed through the local driver, the route returns a +`BinaryFileResponse`; Symfony handles `Range` and `If-Range`, enabling `206` +partial responses, `416` invalid-range responses, `Content-Length`, +`Accept-Ranges`, seek, and resumable downloads without loading the whole asset +into PHP memory. If a reliable local filesystem path cannot be resolved inside +the disk root, the route falls back to `readStream()` and `fpassthru()`: memory +usage remains constant, but byte-range seeking is not guaranteed. S3-compatible +object storage continues to use the disk temporary URL mode instead of this local +signed route. + +### `attach_existing_asset` + +Attaches an existing workspace asset-library item to a draft or scheduled post. +The operation is idempotent for the same `post_id` and `asset_id`; duplicate +checks run inside a row lock on the post. + +Input: + +- `post_id`: required post UUID in the current workspace. +- `asset_id`: required asset UUID in the current workspace. +- `alt`: optional image alt text, max length follows existing post media rules. + +Output: + +- `asset_id` +- `attached`: `true` only when a new media snapshot was added. +- `already_attached`: `true` when the asset was already present. +- `post`: refreshed post resource. + +The tool rejects cross-workspace posts/assets, unauthorized users, non-editable +post states, and media types that the enabled post platforms cannot publish. + +## Usage projection contract + +Usage metadata is computed from existing post media snapshots and +`post_platforms`; no denormalized asset usage table is required. + +Each asset usage object includes: + +- `content_usage_count`: number of workspace posts that reference the asset. +- `publication_usage_count`: number of enabled post-platform rows with status + `published`, including rows where `published_at` is missing. +- `timestamped_publication_usage_count`: number of enabled published + post-platform rows with a reliable publication timestamp. +- `configured_platforms`: platforms configured on enabled post-platform rows. +- `configured_content_types`: content types configured on enabled + post-platform rows. +- `published_platforms`: platforms from enabled post-platform rows whose status + is `published`. +- `published_content_types`: content types from enabled post-platform rows whose + status is `published`. +- `latest_content_id`: best available associated post ID. +- `latest_content_basis`: basis used for `latest_content_id`. +- `last_used_at`: latest reliable use timestamp, or `null`. +- `last_use_basis`: timestamp basis for `last_used_at`; `mixed` when multiple + contexts at the same timestamp use different bases. +- `last_use_contexts`: every context tied exactly to `last_used_at`. It is an + empty list for draft-only usage. +- `days_since_last_use`: difference between UTC calendar dates, not complete + 24-hour periods. + +Each `last_use_contexts[]` item contains: + +- `content_id` +- `platform` +- `content_type` +- `content_status` +- `publication_status` +- `used_at` +- `use_basis` + +Configured platforms/content types are never presented as published usage unless +the corresponding enabled post-platform row is actually `published`. Missing +timestamps prevent temporal calculations, but they do not remove a published row +from `publication_usage_count`. + +## Query and performance notes + +`AssetUsageQuery` first scopes posts by workspace and then applies JSON +containment against the requested asset IDs. The implementation eager-loads +`postPlatforms` for the bounded post set and does not issue per asset or per +post-platform follow-up queries. + +For metadata-only listing (`usage=all` and `sort=created_at`), pagination is +performed in SQL and usage is projected only for the current page. Usage-derived +filtering and sorting (`used`, `unused`, `last_used_at`, `usage_count`, +`publication_usage_count`, and `timestamped_publication_usage_count`) require the +matching workspace asset set to be loaded before pagination because the usage +projection is calculated from post media snapshots. Deployments with very large +asset libraries should evaluate a dedicated index or denormalized usage model +separately; that would require an explicit database schema change. + +For PostgreSQL, the asset-reference predicate casts `posts.media` to `jsonb` +before applying containment: + +```sql +media::jsonb @> ?::jsonb +``` + +This keeps the predicate valid when the Laravel `json` column maps to either +`json` or `jsonb` in a target PostgreSQL database. diff --git a/routes/web.php b/routes/web.php index 81265863a..b23a9aaa7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,13 @@ declare(strict_types=1); +use App\Http\Controllers\Media\AssetPreviewController; +use Illuminate\Support\Facades\Route; + +Route::get('media/asset-preview/{workspace}/{media}', AssetPreviewController::class) + ->middleware('signed') + ->name('media.asset-preview.show'); + require __DIR__.'/webhook.php'; require __DIR__.'/auth.php'; require __DIR__.'/app.php'; diff --git a/tests/Feature/Mcp/AttachExistingAssetToolTest.php b/tests/Feature/Mcp/AttachExistingAssetToolTest.php new file mode 100644 index 000000000..34274755c --- /dev/null +++ b/tests/Feature/Mcp/AttachExistingAssetToolTest.php @@ -0,0 +1,174 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); +}); + +function assetForAttach(Workspace $workspace, array $attributes = []): Media +{ + return Media::factory()->create(array_merge([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + ], $attributes)); +} + +test('attaches an existing workspace asset once and reports idempotent repeats', function () { + $asset = assetForAttach($this->workspace, [ + 'original_filename' => 'apex.jpg', + 'mime_type' => 'image/jpeg', + ]); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $asset->id, + 'alt' => 'Car clipping the apex', + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($asset) { + $json->where('asset_id', $asset->id) + ->where('attached', true) + ->where('already_attached', false) + ->has('post') + ->etc(); + }); + + expect($this->post->fresh()->media) + ->toHaveCount(1) + ->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id) + ->and(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('Car clipping the apex'); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $asset->id, + 'alt' => 'Updated alt should not duplicate the item', + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('attached', false) + ->where('already_attached', true) + ->etc()); + + expect($this->post->fresh()->media)->toHaveCount(1) + ->and(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('Car clipping the apex'); +}); + +test('does not store alt text for existing non-image assets', function () { + $asset = assetForAttach($this->workspace, [ + 'type' => Type::Video, + 'path' => 'media/video.mp4', + 'original_filename' => 'video.mp4', + 'mime_type' => 'video/mp4', + ]); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $asset->id, + 'alt' => 'ignored for videos', + ]) + ->assertOk(); + + expect(data_get($this->post->fresh()->media, '0.type'))->toBe('video') + ->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull(); +}); + +test('rejects cross-workspace assets and posts without mutating the post', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $foreignAsset = assetForAttach($otherWorkspace); + $localAsset = assetForAttach($this->workspace); + $foreignPost = Post::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + 'user_id' => $otherUser->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $foreignAsset->id, + ]) + ->assertHasErrors(); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $foreignPost->id, + 'asset_id' => $localAsset->id, + ]) + ->assertHasErrors(); + + expect($this->post->fresh()->media)->toHaveCount(0) + ->and($foreignPost->fresh()->media)->toHaveCount(0); +}); + +test('rejects read-only users and posts in non-editable states', function () { + $viewer = User::factory()->create(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); + $asset = assetForAttach($this->workspace); + + TryPostServer::actingAs($viewer) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $asset->id, + ]) + ->assertHasErrors(); + + $publishedPost = Post::factory()->published()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $publishedPost->id, + 'asset_id' => $asset->id, + ]) + ->assertHasErrors(); + + expect($this->post->fresh()->media)->toHaveCount(0) + ->and($publishedPost->fresh()->media)->toHaveCount(0); +}); + +test('rejects assets that enabled post platforms cannot publish', function () { + $asset = assetForAttach($this->workspace); + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]); + PostPlatform::factory()->tiktok()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(AttachExistingAssetTool::class, [ + 'post_id' => $this->post->id, + 'asset_id' => $asset->id, + ]) + ->assertHasErrors(); + + expect($this->post->fresh()->media)->toHaveCount(0); +}); diff --git a/tests/Feature/Mcp/GetAssetPreviewToolTest.php b/tests/Feature/Mcp/GetAssetPreviewToolTest.php new file mode 100644 index 000000000..e2745d339 --- /dev/null +++ b/tests/Feature/Mcp/GetAssetPreviewToolTest.php @@ -0,0 +1,70 @@ +put('media/preview.jpg', 'preview-bytes'); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +function mcpPreviewAsset(Workspace $workspace, array $attributes = []): Media +{ + return Media::factory()->create(array_merge([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + 'path' => 'media/preview.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12, + ], $attributes)); +} + +test('returns a temporary preview for a workspace asset', function () { + $media = mcpPreviewAsset($this->workspace); + + TryPostServer::actingAs($this->user) + ->tool(GetAssetPreviewTool::class, ['asset_id' => $media->id]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($media) { + $json->where('asset_id', $media->id) + ->where('mime_type', 'image/jpeg') + ->where('size_bytes', 12) + ->where('preview_mode', 'signed_route') + ->has('preview_url') + ->has('expires_at') + ->missing('path') + ->missing('url') + ->etc(); + }); +}); + +test('missing and cross workspace assets do not reveal metadata', function () { + $otherWorkspace = Workspace::factory()->create(); + $foreignMedia = mcpPreviewAsset($otherWorkspace); + + TryPostServer::actingAs($this->user) + ->tool(GetAssetPreviewTool::class, ['asset_id' => $foreignMedia->id]) + ->assertHasErrors(); +}); + +test('missing storage object returns preview unavailable', function () { + $media = mcpPreviewAsset($this->workspace, ['path' => 'media/missing.jpg']); + + TryPostServer::actingAs($this->user) + ->tool(GetAssetPreviewTool::class, ['asset_id' => $media->id]) + ->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/ListAssetsToolTest.php b/tests/Feature/Mcp/ListAssetsToolTest.php new file mode 100644 index 000000000..3edaeb816 --- /dev/null +++ b/tests/Feature/Mcp/ListAssetsToolTest.php @@ -0,0 +1,201 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +afterEach(function () { + CarbonImmutable::setTestNow(); +}); + +function assetForList(Workspace $workspace, array $attributes = []): Media +{ + return Media::factory()->create(array_merge([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + ], $attributes)); +} + +test('lists current workspace asset library media with usage metadata and safe payload shape', function () { + $asset = assetForList($this->workspace, [ + 'original_filename' => 'finish-line.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'media' => [['id' => $asset->id, 'path' => $asset->path]], + 'status' => PostStatus::Published, + ]); + PostPlatform::factory()->published()->create([ + 'post_id' => $post->id, + 'published_at' => '2026-07-21 10:00:00', + ]); + + $otherWorkspace = Workspace::factory()->create(); + assetForList($otherWorkspace, ['original_filename' => 'foreign.jpg']); + Media::factory()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'collection' => 'avatar', + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListAssetsTool::class, ['per_page' => 25]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($asset) { + $json->has('assets', 1, function (AssertableJson $json) use ($asset) { + $json->where('asset_id', $asset->id) + ->where('filename', 'finish-line.jpg') + ->where('category', 'image') + ->where('mime_type', 'image/jpeg') + ->where('size_bytes', 12345) + ->where('width', 1920) + ->where('height', 1080) + ->where('is_used', true) + ->where('content_usage_count', 1) + ->where('usage_count', 1) + ->where('publication_usage_count', 1) + ->where('timestamped_publication_usage_count', 1) + ->where('last_used_at', '2026-07-21T10:00:00+00:00') + ->where('days_since_last_use', 1) + ->has('last_use_contexts', 1) + ->missing('path') + ->missing('url') + ->missing('workspace_id') + ->etc(); + })->where('pagination.per_page', 25) + ->where('pagination.total', 1) + ->etc(); + }); +}); + +test('filters and sorts assets by metadata and usage without conflating configured and published rows', function () { + $used = assetForList($this->workspace, [ + 'original_filename' => 'used-race.jpg', + 'mime_type' => 'image/jpeg', + ]); + $unused = assetForList($this->workspace, [ + 'original_filename' => 'unused-race.mp4', + 'mime_type' => 'video/mp4', + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'media' => [['id' => $used->id, 'path' => $used->path]], + 'status' => PostStatus::PartiallyPublished, + ]); + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'enabled' => true, + 'status' => PostPlatformStatus::Pending, + 'published_at' => null, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListAssetsTool::class, [ + 'usage' => 'used', + 'category' => 'image', + 'sort' => 'publication_usage_count', + 'direction' => 'desc', + ]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($used) { + $json->has('assets', 1) + ->where('assets.0.asset_id', $used->id) + ->where('assets.0.configured_platforms.0', 'linkedin') + ->where('assets.0.published_platforms', []) + ->where('assets.0.publication_usage_count', 0) + ->etc(); + }); + + TryPostServer::actingAs($this->user) + ->tool(ListAssetsTool::class, ['usage' => 'unused']) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('assets.0.asset_id', $unused->id)->etc()); +}); + +test('metadata sorted listing computes usage only for the current page', function () { + $firstPage = assetForList($this->workspace, [ + 'original_filename' => 'first-page.jpg', + 'created_at' => CarbonImmutable::parse('2026-07-22 12:03:00', 'UTC'), + ]); + $offPage = assetForList($this->workspace, [ + 'original_filename' => 'off-page.jpg', + 'created_at' => CarbonImmutable::parse('2026-07-22 12:02:00', 'UTC'), + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'media' => [['id' => $offPage->id, 'path' => $offPage->path]], + 'status' => PostStatus::Published, + ]); + PostPlatform::factory()->published()->create([ + 'post_id' => $post->id, + 'published_at' => '2026-07-21 10:00:00', + ]); + + $postQueryBindings = []; + DB::listen(function ($query) use (&$postQueryBindings) { + if (str_contains($query->sql, 'from "posts"')) { + $postQueryBindings[] = $query->bindings; + } + }); + + TryPostServer::actingAs($this->user) + ->tool(ListAssetsTool::class, [ + 'per_page' => 1, + 'sort' => 'created_at', + 'direction' => 'desc', + ]) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use ($firstPage) { + $json->where('assets.0.asset_id', $firstPage->id) + ->where('assets.0.is_used', false) + ->where('pagination.total', 2) + ->where('pagination.has_more', true) + ->etc(); + }); + + $encodedBindings = json_encode($postQueryBindings, JSON_THROW_ON_ERROR); + + expect($encodedBindings)->toContain($firstPage->id) + ->and($encodedBindings)->not->toContain($offPage->id); +}); + +test('schema does not accept workspace_id', function () { + $schema = (new ListAssetsTool)->schema(new JsonSchemaTypeFactory); + + expect($schema)->toHaveKeys(['page', 'per_page', 'search', 'mime_type', 'category', 'usage', 'sort', 'direction']) + ->and($schema)->not->toHaveKey('workspace_id'); +}); diff --git a/tests/Feature/Media/AssetPreviewControllerTest.php b/tests/Feature/Media/AssetPreviewControllerTest.php new file mode 100644 index 000000000..648461cb6 --- /dev/null +++ b/tests/Feature/Media/AssetPreviewControllerTest.php @@ -0,0 +1,216 @@ +create(array_merge([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + 'path' => 'media/preview.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12, + ], $attributes)); +} + +test('signed preview route streams a current workspace asset without exposing paths', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview.jpg', 'preview-bytes'); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $response = $this->get($preview['url']) + ->assertOk() + ->assertStreamedContent('preview-bytes') + ->assertHeader('content-type', 'image/jpeg'); + + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); +}); + +test('signed preview route serves local files through binary responses with content length', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview-video.mp4', PREVIEW_VIDEO_BYTES); + Log::spy(); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => 'media/preview-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'preview-video.mp4', + 'size' => strlen(PREVIEW_VIDEO_BYTES), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $response = $this->get($preview['url']) + ->assertOk() + ->assertHeader('content-type', 'video/mp4') + ->assertHeader('content-length', (string) strlen(PREVIEW_VIDEO_BYTES)) + ->assertHeader('accept-ranges', 'bytes'); + + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); + expect($response->streamedContent())->toBe(PREVIEW_VIDEO_BYTES); + expect(json_encode($response->headers->allPreserveCaseWithoutCookies()))->not->toContain(Storage::disk('local')->path('media/preview-video.mp4')); + expect($response->streamedContent())->not->toContain(Storage::disk('local')->path('media/preview-video.mp4')); + Log::shouldNotHaveReceived('error'); + Log::shouldNotHaveReceived('warning'); +}); + +test('signed preview route supports open byte ranges for local videos', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview-video.mp4', PREVIEW_VIDEO_BYTES); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => 'media/preview-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'preview-video.mp4', + 'size' => strlen(PREVIEW_VIDEO_BYTES), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $response = $this->get($preview['url'], ['Range' => 'bytes=0-9']) + ->assertStatus(206) + ->assertHeader('content-range', 'bytes 0-9/'.strlen(PREVIEW_VIDEO_BYTES)) + ->assertHeader('content-length', '10') + ->assertHeader('accept-ranges', 'bytes'); + + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); + expect($response->streamedContent())->toBe('0123456789'); +}); + +test('signed preview route supports intermediate byte ranges for local videos', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview-video.mp4', PREVIEW_VIDEO_BYTES); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => 'media/preview-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'preview-video.mp4', + 'size' => strlen(PREVIEW_VIDEO_BYTES), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $response = $this->get($preview['url'], ['Range' => 'bytes=10-19']) + ->assertStatus(206) + ->assertHeader('content-range', 'bytes 10-19/'.strlen(PREVIEW_VIDEO_BYTES)) + ->assertHeader('content-length', '10'); + + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); + expect($response->streamedContent())->toBe('abcdefghij'); +}); + +test('signed preview route rejects invalid byte ranges for local videos', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview-video.mp4', PREVIEW_VIDEO_BYTES); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => 'media/preview-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'preview-video.mp4', + 'size' => strlen(PREVIEW_VIDEO_BYTES), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $this->get($preview['url'], ['Range' => 'bytes=999-1000']) + ->assertStatus(416) + ->assertHeader('content-range', 'bytes */'.strlen(PREVIEW_VIDEO_BYTES)); +}); + +test('signed preview route streams supported local asset categories', function (string $path, string $mimeType, string $bytes) { + Storage::fake('local'); + Storage::disk('local')->put($path, $bytes); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => $path, + 'mime_type' => $mimeType, + 'original_filename' => basename($path), + 'size' => strlen($bytes), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $this->get($preview['url']) + ->assertOk() + ->assertStreamedContent($bytes) + ->assertHeader('content-type', $mimeType); +})->with([ + 'image' => ['media/preview-image.jpg', 'image/jpeg', 'image-bytes'], + 'video' => ['media/preview-video.mp4', 'video/mp4', 'video-bytes'], + 'pdf' => ['media/preview-document.pdf', 'application/pdf', '%PDF-bytes'], +]); + +test('signed preview route supports unicode filenames in content disposition', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview-video.mp4', 'video-bytes'); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace, [ + 'path' => 'media/preview-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'città onboard 100%.mp4', + 'size' => strlen('video-bytes'), + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $response = $this->get($preview['url']) + ->assertOk() + ->assertHeader('content-disposition', "inline; filename=\"citta onboard 100-.mp4\"; filename*=utf-8''citt%C3%A0%20onboard%20100%25.mp4"); + + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); +}); + +test('signed preview route rejects tampered signatures and cross workspace assets', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview.jpg', 'preview-bytes'); + $workspace = Workspace::factory()->create(); + $otherWorkspace = Workspace::factory()->create(); + $media = previewAsset($workspace); + $foreignMedia = previewAsset($otherWorkspace); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $this->get($preview['url'].'tampered=1')->assertForbidden(); + + $foreignPreview = (new AssetPreviewUrlFactory)->temporaryUrl($foreignMedia, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $this->get($foreignPreview['url'])->assertNotFound(); +}); + +test('signed preview route rejects expired signatures', function () { + Storage::fake('local'); + Storage::disk('local')->put('media/preview.jpg', 'preview-bytes'); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->subMinute()); + + $this->get($preview['url'])->assertForbidden(); +}); + +test('signed preview route returns not found when storage object is missing', function () { + Storage::fake('local'); + $workspace = Workspace::factory()->create(); + $media = previewAsset($workspace); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl($media, $workspace, CarbonImmutable::now('UTC')->addMinutes(5)); + + $this->get($preview['url'])->assertNotFound(); +}); diff --git a/tests/Feature/Middleware/SetLocaleTest.php b/tests/Feature/Middleware/SetLocaleTest.php index 1da03f036..07588e4e3 100644 --- a/tests/Feature/Middleware/SetLocaleTest.php +++ b/tests/Feature/Middleware/SetLocaleTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\View; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * Run the middleware with the given `locale` cookie (null = no cookie) and @@ -64,3 +65,16 @@ function runSetLocale(?string $locale): string expect($cookie)->toBeNull(); }); + +test('persists the default locale cookie on streamed responses', function () { + $request = Request::create('/', 'GET'); + + $response = (new SetLocale)->handle($request, fn () => new StreamedResponse(fn () => print 'ok')); + + $cookie = collect($response->headers->getCookies()) + ->first(fn ($cookie) => $cookie->getName() === 'locale'); + + expect($response)->toBeInstanceOf(StreamedResponse::class); + expect($cookie)->not->toBeNull(); + expect($cookie->getValue())->toBe(config('languages.default')); +}); diff --git a/tests/Unit/Services/Media/AssetPreviewUrlFactoryTest.php b/tests/Unit/Services/Media/AssetPreviewUrlFactoryTest.php new file mode 100644 index 000000000..67f1178f6 --- /dev/null +++ b/tests/Unit/Services/Media/AssetPreviewUrlFactoryTest.php @@ -0,0 +1,37 @@ +create(); + $media = Media::factory()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + 'path' => 'media/private-preview.jpg', + ]); + + $preview = (new AssetPreviewUrlFactory)->temporaryUrl( + $media, + $workspace, + CarbonImmutable::parse('2026-07-22 12:05:00', 'UTC'), + ); + + expect($preview['mode'])->toBe('signed_route') + ->and($preview['expires_at'])->toBe('2026-07-22T12:05:00+00:00') + ->and($preview['url'])->not->toContain('media/private-preview.jpg') + ->and(URL::hasValidSignature(request()->create($preview['url'], 'GET')))->toBeTrue(); + }); +}); diff --git a/tests/Unit/Services/Media/AssetUsageQueryTest.php b/tests/Unit/Services/Media/AssetUsageQueryTest.php new file mode 100644 index 000000000..7807d3a21 --- /dev/null +++ b/tests/Unit/Services/Media/AssetUsageQueryTest.php @@ -0,0 +1,246 @@ +create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $workspace->id, + 'collection' => 'assets', + ]); +} + +function postUsing(Workspace $workspace, Media $media, array $attributes = []): Post +{ + return Post::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'media' => [ + ['id' => $media->id, 'path' => $media->path], + ['id' => $media->id, 'path' => $media->path], + ], + ], $attributes)); +} + +function platformFor(Post $post, array $attributes = []): PostPlatform +{ + return PostPlatform::factory()->create(array_merge([ + 'post_id' => $post->id, + 'enabled' => true, + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'status' => PostPlatformStatus::Pending, + ], $attributes)); +} + +test('usage contract counts content and published rows without inventing draft timestamps', function () { + $workspace = Workspace::factory()->create(); + $unused = assetIn($workspace); + $draftOnly = assetIn($workspace); + $publishedWithoutTimestamp = assetIn($workspace); + $now = CarbonImmutable::parse('2026-07-22 12:00:00', 'UTC'); + + postUsing($workspace, $draftOnly, [ + 'status' => PostStatus::Draft, + 'created_at' => '2026-07-01 00:00:00', + 'updated_at' => '2026-07-20 00:00:00', + ]); + + $post = postUsing($workspace, $publishedWithoutTimestamp, [ + 'status' => PostStatus::Published, + 'published_at' => null, + ]); + platformFor($post, [ + 'status' => PostPlatformStatus::Published, + 'published_at' => null, + ]); + + $usage = (new AssetUsageQuery)->forAssets($workspace, [ + $unused->id, + $draftOnly->id, + $publishedWithoutTimestamp->id, + ], $now); + + expect($usage[$unused->id]['is_used'])->toBeFalse() + ->and($usage[$unused->id]['content_usage_count'])->toBe(0) + ->and($usage[$unused->id]['last_use_contexts'])->toBe([]) + ->and($usage[$draftOnly->id]['is_used'])->toBeTrue() + ->and($usage[$draftOnly->id]['content_usage_count'])->toBe(1) + ->and($usage[$draftOnly->id]['usage_count'])->toBe(1) + ->and($usage[$draftOnly->id]['last_used_at'])->toBeNull() + ->and($usage[$draftOnly->id]['last_use_basis'])->toBeNull() + ->and($usage[$draftOnly->id]['last_use_contexts'])->toBe([]) + ->and($usage[$draftOnly->id]['days_since_last_use'])->toBeNull() + ->and($usage[$publishedWithoutTimestamp->id]['publication_usage_count'])->toBe(1) + ->and($usage[$publishedWithoutTimestamp->id]['timestamped_publication_usage_count'])->toBe(0) + ->and($usage[$publishedWithoutTimestamp->id]['published_platforms'])->toBe(['linkedin']) + ->and($usage[$publishedWithoutTimestamp->id]['published_content_types'])->toBe(['linkedin_post']) + ->and($usage[$publishedWithoutTimestamp->id]['last_used_at'])->toBeNull(); +}); + +test('configured and published aggregates stay separate', function () { + $workspace = Workspace::factory()->create(); + $asset = assetIn($workspace); + $post = postUsing($workspace, $asset, ['status' => PostStatus::PartiallyPublished]); + + platformFor($post, [ + 'platform' => Platform::Instagram, + 'content_type' => ContentType::InstagramFeed, + 'status' => PostPlatformStatus::Pending, + ]); + platformFor($post, [ + 'platform' => Platform::Facebook, + 'content_type' => ContentType::FacebookPost, + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-20 09:00:00', + ]); + + $usage = (new AssetUsageQuery)->forAssets($workspace, [$asset->id], CarbonImmutable::parse('2026-07-22', 'UTC')); + + expect($usage[$asset->id]['configured_platforms'])->toBe(['facebook', 'instagram']) + ->and($usage[$asset->id]['configured_content_types'])->toBe(['facebook_post', 'instagram_feed']) + ->and($usage[$asset->id]['published_platforms'])->toBe(['facebook']) + ->and($usage[$asset->id]['published_content_types'])->toBe(['facebook_post']) + ->and($usage[$asset->id]['latest_content_basis'])->toBe('platform_published_at'); +}); + +test('last use contexts include every context tied exactly to last used timestamp', function () { + $workspace = Workspace::factory()->create(); + $asset = assetIn($workspace); + $timestamp = '2026-07-21 10:30:00'; + + $platformPost = postUsing($workspace, $asset, ['status' => PostStatus::Published]); + platformFor($platformPost, [ + 'platform' => Platform::Facebook, + 'content_type' => ContentType::FacebookPost, + 'status' => PostPlatformStatus::Published, + 'published_at' => $timestamp, + ]); + + $postFallback = postUsing($workspace, $asset, [ + 'status' => PostStatus::Published, + 'published_at' => $timestamp, + ]); + platformFor($postFallback, [ + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'status' => PostPlatformStatus::Published, + 'published_at' => null, + ]); + + $older = postUsing($workspace, $asset, ['status' => PostStatus::Published]); + platformFor($older, [ + 'platform' => Platform::X, + 'content_type' => ContentType::XPost, + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-01 10:30:00', + ]); + + $usage = (new AssetUsageQuery)->forAssets($workspace, [$asset->id], CarbonImmutable::parse('2026-07-22 00:00:00', 'UTC')); + + expect($usage[$asset->id]['last_used_at'])->toBe('2026-07-21T10:30:00+00:00') + ->and($usage[$asset->id]['last_use_basis'])->toBe('mixed') + ->and($usage[$asset->id]['last_use_contexts'])->toHaveCount(2) + ->and(collect($usage[$asset->id]['last_use_contexts'])->pluck('used_at')->unique()->values()->all())->toBe(['2026-07-21T10:30:00+00:00']) + ->and($usage[$asset->id]['latest_content_ids'])->toEqualCanonicalizing([$platformPost->id, $postFallback->id]) + ->and($usage[$asset->id]['latest_content_id'])->toBe(collect([$platformPost->id, $postFallback->id])->sort()->first()); +}); + +test('days since last use uses utc calendar dates instead of complete hours', function () { + $workspace = Workspace::factory()->create(); + $sameDate = assetIn($workspace); + $adjacentDate = assetIn($workspace); + $future = assetIn($workspace); + + $sameDatePost = postUsing($workspace, $sameDate, ['status' => PostStatus::Published]); + platformFor($sameDatePost, [ + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-22 00:30:00', + ]); + + $adjacentDatePost = postUsing($workspace, $adjacentDate, ['status' => PostStatus::Published]); + platformFor($adjacentDatePost, [ + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-21 23:30:00', + ]); + + $futurePost = postUsing($workspace, $future, [ + 'status' => PostStatus::Scheduled, + 'scheduled_at' => '2026-07-23 00:30:00', + ]); + platformFor($futurePost); + + $usage = (new AssetUsageQuery)->forAssets($workspace, [ + $sameDate->id, + $adjacentDate->id, + $future->id, + ], CarbonImmutable::parse('2026-07-22 00:15:00', 'UTC')); + + expect($usage[$sameDate->id]['days_since_last_use'])->toBe(0) + ->and($usage[$adjacentDate->id]['days_since_last_use'])->toBe(1) + ->and($usage[$future->id]['days_since_last_use'])->toBe(0); +}); + +test('usage aggregation is scoped to the requested workspace', function () { + $workspace = Workspace::factory()->create(); + $otherWorkspace = Workspace::factory()->create(); + $asset = assetIn($workspace); + $otherPost = postUsing($otherWorkspace, $asset, ['status' => PostStatus::Published]); + + platformFor($otherPost, [ + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-21 10:00:00', + ]); + + $usage = (new AssetUsageQuery)->forAssets($workspace, [$asset->id], CarbonImmutable::parse('2026-07-22', 'UTC')); + + expect($usage[$asset->id]['is_used'])->toBeFalse() + ->and($usage[$asset->id]['publication_usage_count'])->toBe(0) + ->and($usage[$asset->id]['last_use_contexts'])->toBe([]); +}); + +test('usage aggregation uses a bounded query set for requested assets', function () { + $workspace = Workspace::factory()->create(); + $assets = collect(range(1, 5))->map(fn () => assetIn($workspace)); + + $assets->each(function (Media $asset) use ($workspace): void { + $post = postUsing($workspace, $asset, ['status' => PostStatus::Published]); + platformFor($post, [ + 'status' => PostPlatformStatus::Published, + 'published_at' => '2026-07-21 10:00:00', + ]); + }); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + (new AssetUsageQuery)->forAssets($workspace, $assets->pluck('id')->all(), CarbonImmutable::parse('2026-07-22', 'UTC')); + + $queries = collect(DB::getQueryLog())->pluck('query')->join("\n"); + + expect($queries)->toContain('media') + ->and($queries)->toContain('post_platforms'); +}); + +test('postgres asset reference expression casts media json to jsonb containment', function () { + $query = new AssetUsageQuery; + $method = new ReflectionMethod($query, 'postgresAssetContainsExpression'); + $method->setAccessible(true); + + expect($method->invoke($query))->toBe('media::jsonb @> ?::jsonb'); +});