Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions app/Actions/Post/AttachExistingAsset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace App\Actions\Post;

use App\Models\Media;
use App\Models\Post;
use Illuminate\Support\Facades\DB;

class AttachExistingAsset
{
/**
* Attach the asset snapshot to the post exactly once.
*
* Returns true when a new item was appended and false when the same asset
* was already attached. The duplicate check happens inside the row lock so
* concurrent MCP calls remain idempotent.
*/
public function handle(Post $post, Media $media, ?string $alt = null): bool
{
return DB::transaction(function () use ($post, $media, $alt): bool {
$fresh = Post::whereKey($post->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;
});
}
}
95 changes: 95 additions & 0 deletions app/Http/Controllers/Media/AssetPreviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Media;

use App\Http\Controllers\Controller;
use App\Models\Media;
use App\Models\Workspace;
use Illuminate\Filesystem\LocalFilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;

class AssetPreviewController extends Controller
{
public function __invoke(string $workspace, string $media): Response
{
$asset = Media::query()
->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<string, string>
*/
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';
}
}
2 changes: 1 addition & 1 deletion app/Http/Middleware/App/SetLocale.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
);
}
Expand Down
8 changes: 8 additions & 0 deletions app/Mcp/Servers/TryPostServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,

Expand Down
89 changes: 89 additions & 0 deletions app/Mcp/Tools/Asset/AttachExistingAssetTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools\Asset;

use App\Actions\Post\AttachExistingAsset;
use App\Http\Resources\Api\PostResource;
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostMediaRules;
use App\Support\PostStatusRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Attach an existing Asset Library item to a draft or scheduled post in the current workspace. The operation is idempotent: repeating the same post_id and asset_id never duplicates post media.')]
class AttachExistingAssetTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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.'),
];
}
}
65 changes: 65 additions & 0 deletions app/Mcp/Tools/Asset/GetAssetPreviewTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools\Asset;

use App\Models\Media;
use App\Models\Workspace;
use App\Services\Media\AssetPreviewUrlFactory;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use RuntimeException;

#[IsReadOnly]
#[Description('Return a short-lived preview URL for a current-workspace Asset Library media item.')]
class GetAssetPreviewTool extends Tool
{
public function handle(Request $request, AssetPreviewUrlFactory $previewUrls): Response|ResponseFactory
{
$validated = $request->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.'),
];
}
}
Loading