* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
167 lines
5.4 KiB
PHP
167 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\App;
|
|
|
|
use App\Http\Requests\App\Asset\StoreAssetFromUrlRequest;
|
|
use App\Http\Requests\App\Asset\StoreAssetRequest;
|
|
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
|
|
use App\Http\Resources\App\MediaResource;
|
|
use App\Models\Media;
|
|
use App\Services\Brand\SafeHttpFetcher;
|
|
use App\Services\Media\ChunkedAssetReceiver;
|
|
use App\Services\UnsplashService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
|
|
|
class AssetController extends Controller
|
|
{
|
|
public function index(Request $request): Response|RedirectResponse
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
if (! $workspace) {
|
|
return redirect()->route('app.workspaces.create');
|
|
}
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
return Inertia::render('assets/Index');
|
|
}
|
|
|
|
public function search(Request $request): AnonymousResourceCollection
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
$term = trim((string) $request->input('search', ''));
|
|
$type = $request->input('type');
|
|
|
|
$assets = $workspace->getMedia('assets')
|
|
->when($term !== '', fn ($query) => $query->where('original_filename', 'ilike', '%'.$term.'%'))
|
|
->when(in_array($type, ['image', 'video'], true), fn ($query) => $query->where('type', $type))
|
|
->latest()
|
|
->paginate(config('app.pagination.default'));
|
|
|
|
return MediaResource::collection($assets);
|
|
}
|
|
|
|
public function store(StoreAssetRequest $request): MediaResource
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
$clientMeta = (array) $request->input('meta', []);
|
|
|
|
$media = $workspace->addMedia($request->file('media'), 'assets', $clientMeta);
|
|
|
|
return new MediaResource($media);
|
|
}
|
|
|
|
public function storeChunked(StoreChunkedAssetRequest $request, ChunkedAssetReceiver $receiver): JsonResponse
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
return $receiver->receive(
|
|
$workspace,
|
|
$request->user(),
|
|
$request->validated('file_name'),
|
|
$request->getContent(),
|
|
(int) $request->validated('range_start'),
|
|
(int) $request->validated('range_end'),
|
|
(int) $request->validated('total_size'),
|
|
(string) $request->validated('upload_id'),
|
|
)->toResponse();
|
|
}
|
|
|
|
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
$validated = $request->validated();
|
|
|
|
// Trigger Unsplash download tracking (required by API guidelines)
|
|
if ($downloadLocation = data_get($validated, 'download_location')) {
|
|
$unsplash->trackDownload($downloadLocation);
|
|
}
|
|
|
|
$url = data_get($validated, 'url');
|
|
|
|
try {
|
|
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
|
|
} catch (RuntimeException) {
|
|
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
|
}
|
|
|
|
$mimeType = $response->header('Content-Type', 'image/jpeg');
|
|
$extension = match (true) {
|
|
str_contains($mimeType, 'png') => 'png',
|
|
str_contains($mimeType, 'gif') => 'gif',
|
|
str_contains($mimeType, 'webp') => 'webp',
|
|
default => 'jpg',
|
|
};
|
|
|
|
$filename = Str::uuid().'.'.$extension;
|
|
$path = "medias/{$filename}";
|
|
|
|
Storage::put($path, $response->body());
|
|
|
|
$meta = [];
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'unsplash');
|
|
file_put_contents($tempFile, $response->body());
|
|
$imageInfo = @getimagesize($tempFile);
|
|
if ($imageInfo) {
|
|
$meta['width'] = $imageInfo[0];
|
|
$meta['height'] = $imageInfo[1];
|
|
}
|
|
@unlink($tempFile);
|
|
|
|
$media = $workspace->media()->create([
|
|
'group_id' => Str::uuid()->toString(),
|
|
'collection' => 'assets',
|
|
'type' => 'image',
|
|
'path' => $path,
|
|
'original_filename' => data_get($validated, 'filename'),
|
|
'mime_type' => $mimeType,
|
|
'size' => strlen($response->body()),
|
|
'order' => 0,
|
|
'meta' => $meta,
|
|
]);
|
|
|
|
return new MediaResource($media);
|
|
}
|
|
|
|
public function destroy(Request $request, Media $media): RedirectResponse
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
if ($media->mediable_type !== $workspace->getMorphClass() || $media->mediable_id !== $workspace->id) {
|
|
abort(SymfonyResponse::HTTP_FORBIDDEN);
|
|
}
|
|
|
|
$media->delete();
|
|
|
|
return back();
|
|
}
|
|
}
|