2026-05-15 19:10:21 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
|
|
use App\Http\Requests\Api\StoreUploadRequest;
|
2026-05-15 19:48:09 +00:00
|
|
|
use App\Http\Resources\Api\MediaUploadResource;
|
2026-05-15 19:10:21 +00:00
|
|
|
use App\Models\Media;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
2026-05-15 19:16:42 +00:00
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-05-15 19:10:21 +00:00
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
2026-07-25 01:37:57 +00:00
|
|
|
use Throwable;
|
2026-05-15 19:10:21 +00:00
|
|
|
|
|
|
|
|
class UploadController extends Controller
|
|
|
|
|
{
|
2026-05-15 19:48:09 +00:00
|
|
|
private const CACHE_TTL_BUFFER_SECONDS = 60;
|
|
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
/**
|
|
|
|
|
* One-shot claim for a signed upload token (api.uploads.store).
|
|
|
|
|
* Survives across MCP and any other client that POSTs the signed URL.
|
|
|
|
|
*/
|
|
|
|
|
private const CLAIM_CACHE_PREFIX = 'media:signed-upload:';
|
|
|
|
|
|
2026-05-15 19:10:21 +00:00
|
|
|
public function store(StoreUploadRequest $request, string $token): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$expiresAt = (int) $request->query('expires');
|
2026-05-15 19:48:09 +00:00
|
|
|
$ttl = max(
|
|
|
|
|
self::CACHE_TTL_BUFFER_SECONDS,
|
|
|
|
|
$expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS,
|
|
|
|
|
);
|
2026-05-15 19:10:21 +00:00
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
$cacheKey = self::CLAIM_CACHE_PREFIX.$token;
|
2026-07-25 01:37:57 +00:00
|
|
|
|
|
|
|
|
if (! Cache::add($cacheKey, true, $ttl)) {
|
2026-05-15 19:48:09 +00:00
|
|
|
abort(Response::HTTP_CONFLICT);
|
2026-05-15 19:10:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Media::where('upload_token', $token)->exists()) {
|
2026-05-15 19:48:09 +00:00
|
|
|
abort(Response::HTTP_CONFLICT);
|
2026-05-15 19:10:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:37:57 +00:00
|
|
|
try {
|
|
|
|
|
$workspace = Workspace::findOrFail((string) $request->query('workspace_id'));
|
|
|
|
|
$file = $request->file('media');
|
|
|
|
|
$path = $file->getRealPath();
|
2026-05-15 19:10:21 +00:00
|
|
|
|
2026-07-25 01:37:57 +00:00
|
|
|
// Stream from PHP's temp upload path — do not load the whole file into
|
|
|
|
|
// memory (addMedia() uses file_get_contents; videos can be up to 1GB).
|
|
|
|
|
if ($path === false) {
|
|
|
|
|
abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.');
|
|
|
|
|
}
|
2026-07-25 01:00:07 +00:00
|
|
|
|
2026-07-25 01:37:57 +00:00
|
|
|
$media = DB::transaction(function () use ($workspace, $file, $path, $token): Media {
|
|
|
|
|
$media = $workspace->addMediaFromPath(
|
|
|
|
|
$path,
|
|
|
|
|
$file->getClientOriginalName(),
|
|
|
|
|
'assets',
|
|
|
|
|
mimeType: (string) $file->getMimeType(),
|
|
|
|
|
);
|
|
|
|
|
$media->upload_token = $token;
|
|
|
|
|
$media->save();
|
2026-05-15 19:16:42 +00:00
|
|
|
|
2026-07-25 01:37:57 +00:00
|
|
|
return $media;
|
|
|
|
|
});
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// Claim is only permanent after Media is stored — release so the
|
|
|
|
|
// signed URL can be retried after a transient disk/storage failure.
|
|
|
|
|
Cache::forget($cacheKey);
|
|
|
|
|
|
|
|
|
|
throw $e;
|
|
|
|
|
}
|
2026-05-15 19:10:21 +00:00
|
|
|
|
2026-05-15 19:48:09 +00:00
|
|
|
return MediaUploadResource::make($media)
|
|
|
|
|
->response()
|
|
|
|
|
->setStatusCode(Response::HTTP_CREATED);
|
2026-05-15 19:10:21 +00:00
|
|
|
}
|
|
|
|
|
}
|