Commit graph

6 commits

Author SHA1 Message Date
Paulo Castellano
676afb15ba
fix: chunked upload session collision + workspace name i18n (#263)
* 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.
2026-08-09 14:06:47 -03:00
Paulo Castellano
005d3c9dd6 fix: stream chunked video finalize to object storage
Unicode filename encoding was correct, but large videos still died on the last chunk: the whole file was loaded into memory and uploaded to R2 via Guzzle within PHP-FPM's 30s limit. Stream non-images with writeStream and lift the time limit on finalize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:55:57 -03:00
Paulo Castellano
f4513efc70 fix: allow unicode filenames in chunked asset uploads
X-File-Name is an HTTP header, so raw en-dashes/emoji blow up fetch() before the request leaves the browser. Percent-encode on the client and rawurldecode on the server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:46:48 -03:00
Paulo Castellano
7388313f5c feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.

- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip

LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 16:32:27 -03:00
Paulo Castellano
332513e360 refactor: replace regex/preg_match with sscanf + ends_with in chunked upload
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:

1. Parsing 'Content-Range' header — preg_match with capturing groups
   and $matches[1..3] indexing replaced by sscanf, which is
   purpose-built for parsing structured strings with a known format:

       $parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
       $start = $parsed[0] ?? null;

   No regex, no $matches array, no manual int casts (sscanf returns ints
   for %d).

2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
   replaced by Laravel's built-in 'ends_with' rule, with extensions
   derived from the MediaType enum:

       $allowedSuffixes = collect([Image, Video])
           ->flatMap(fn (MediaType $t) => $t->extensions())
           ->map(fn ($ext) => '.'.$ext)
           ->all();

   For the case-insensitive part, normalize the filename to lowercase
   in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.

Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.
2026-05-04 14:59:57 -03:00
Paulo Castellano
770266d36b refactor: centralize media size limits in config + drop Document type
Three things in one move:

1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
   The MediaType enum now reads from there:

       MediaType::Image->maxSizeInMb()    // 10  (env: MEDIA_IMAGE_MAX_SIZE_MB)
       MediaType::Video->maxSizeInMb()    // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)

   Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
   don't have to multiply themselves. StoreAssetRequest now uses
   MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
   from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
   validation moved to the new StoreChunkedAssetRequest FormRequest
   (also reads from the enum). MediaAttacher uses
   MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
   and enforces the per-type cap after MIME resolution.

2. Drop MediaType::Document. We never accepted PDFs anywhere — the
   StoreAssetRequest mimes list excluded them, the storeChunked
   extension regex excluded them, MediaAttacher excluded them. The only
   places that referenced Document were:
     - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
       but unreachable)
     - HasMedia::getMediaType fallback when MIME wasn't image/video/*
   Both now cleaned up. HasMedia::getMediaType throws
   InvalidArgumentException for unsupported MIMEs instead of silently
   returning a fake 'document' type. Platform::LinkedIn now matches
   every other social platform: [Image, Video].

3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
   type loop that MediaAttacher used to roll. Returns null for
   unsupported MIMEs (caller decides how to react).

Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
  sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
  asserts the throw on unsupported MIMEs. The 'add media from path'
  test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
  (FormRequest unprocessable) for malformed Content-Range headers,
  matching the new validation layer.
2026-05-04 14:54:47 -03:00