Skip to content

v0.12.0

Choose a tag to compare

@hegedustibor hegedustibor released this 01 May 20:00
· 37 commits to main since this release

This release is a substantial hardening pass driven by an end-to-end review of the package: race conditions, security-sensitive paths, fault tolerance, and frontend ergonomics. Highlights below; more detail in the per-section entries.

Security (Breaking-ish)

  • Path traversal hardened. file_name validation now rejects /, \, NUL, Windows reserved characters, and . / .. outright (regex on InitiateUploadRequest and InitiateBatchUploadRequest). The DefaultChunkHandler::assemble() additionally applies a defence-in-depth basename() and refuses empty / dot names. Existing valid file names are unaffected; previously-accepted malicious names like ../../public/.htaccess now fail validation with 422.
  • Per-upload / per-batch authorisation. Introduces an Authorizer interface (NETipar\Chunky\Authorization\Authorizer) bound by default to DefaultAuthorizer, which enforces auth()->id() === upload->userId whenever the upload/batch was created with an owner. Anonymous uploads (no user_id) keep the old "anyone with the id can use it" semantics for backward compatibility, but as soon as auth middleware is in place, IDOR is blocked. UploadChunkRequest::authorize(), InitiateBatchUploadRequest::authorize(), and UploadStatusController / BatchStatusController / CancelUploadController all enforce this; non-owners see a 404 (not 403) so upload IDs aren't leaked through error response timing.
  • Broadcast channel auth callbacks shipped. New routes/channels.php registered automatically when broadcasting is enabled; the upload, batch, and user channels delegate to the same Authorizer so HTTP and WebSocket access stay in sync. Disable with chunky.broadcasting.register_channels = false if you prefer to register your own.
  • Status response sanitisation. The public GET /upload/{uploadId} response no longer includes the storage disk, the absolute final_path, or the owner user_id. Use UploadMetadata::toArray() server-side if you need them. New UploadMetadata::toPublicArray() helper exposes the trimmed payload.
  • Mass-assignment hardening. Both ChunkedUpload and ChunkyBatch now define explicit $fillable arrays instead of $guarded = [].

Added

  • BatchUploader.enqueue() in @netipar/chunky-core. Same signature as upload() but doesn't throw Batch upload already in progress. if a batch is already running — the files are held in an internal queue and run as their own batch when the current one finishes (success or failure). When no batch is active, enqueue() immediately delegates to upload(), so callers can use it as a drop-in replacement that "just keeps working" across overlapping calls. The returned promise resolves with that batch's BatchResult, or rejects with Batch upload cancelled before queued upload could start. / BatchUploader destroyed before queued upload could start. if cancel() / destroy() runs before the queued batch starts — so callers don't leak hanging promises. useBatchUpload in both the Vue 3 and React wrappers exposes the matching enqueue method. Strict upload() keeps its existing behaviour for callers that want to detect overlap.
  • onFileProgress in the React useBatchUpload hook and a matching chunky:batch-file-progress DOM event on the Alpine batchUpload data component. Now exposed in all three wrappers — Vue 3, React, Alpine.
  • Authorizer and DefaultAuthorizer services (NETipar\Chunky\Authorization\*). Bind a custom implementation to override ownership rules (admin overrides, shared batches, etc.).
  • AssembleFileJob retry support. tries = 3 with backoff = 30s, plus stale-claim recovery: if a worker crashed mid-assembly, a subsequent retry can re-claim the upload after chunky.assembly_stale_after_minutes (default 10).
  • BatchMetadata::successProgress() alongside progress(), for callers that want the success-only view.
  • Vue 3 composables now expose an explicit destroy() method, so they remain safe to use outside a component scope (Pinia stores, plain modules).
  • chunky.max_files_per_batch config (default 1000) — DOS protection on the batch initiate endpoint.

Fixed

  • AssembleFileJob "stuck in Assembling" recovery. Previously, if a worker crashed between assemble() and updateStatus(Completed), the upload was wedged in Assembling forever: claimForAssembly() only allowed Pending → Assembling, and chunky:cleanup skipped Assembling rows. Now both trackers (DB and FS) treat an Assembling claim older than assembly_stale_after_minutes as recoverable — a queue retry will take it over and re-run the job. The cleanup command applies the same window so abandoned assemblies don't leak storage forever.
  • AssembleFileJob cleanup ordering. The chunk cleanup() call moved from before the save callback to after updateStatus(Completed), so a save-callback failure or worker crash mid-flight no longer leaves us with no chunks AND no completed file — a retry can recover.
  • AssembleFileJob::failed() no longer flips Completed → Failed. Previously, if handle() succeeded but the queue retried it for an unrelated reason (post-Completed dispatch failure, broker hiccup), failed() would overwrite the upload status with Failed, double-increment the batch's failure counter, and broadcast a contradictory UploadFailed event. The early-return now matches any terminal state, not just Failed.
  • Tracker markChunkUploaded() rejects late chunks. Both the DB and FS trackers now refuse chunk POSTs against uploads that aren't in Pending (cancelled, completed, assembling, failed, expired). The HTTP layer surfaces this as 409 Conflict. A pre-flight check in ChunkyManager::uploadChunk() short-circuits before the chunk hits disk, so cancel-races no longer leave orphan chunk files behind.
  • BatchUploader cancel-during-finalise race. If cancel() ran in the very last tick of an upload() (after all worker promises resolved but before the success path emitted complete), both cancel and complete events fired for the same batch, contradicting each other. The success path now checks the cancel/abort flag and returns the partial result silently.
  • BatchUploader cancel suppresses redundant error event. A user-driven cancel() would surface the resulting AbortError as an error event in addition to cancel, so UI components showed both a "cancelled" indicator and an "error" toast for the same action. After cancel(), the catch branch tears down in-flight per-file uploaders and rethrows without re-emitting error.
  • BatchUploader exception path tears down per-file uploaders. A failure in initiateBatch (or any pre-loop step) used to leave the per-file ChunkUploader workers running in the background, continuing to POST chunks against an uploader the caller had already given up on. The catch branch now calls cancel() on each before re-emitting error.
  • ChunkUploader.upload() no longer corrupts a new upload by reusing a stale uploadId. Previously, calling upload(B) after a failed upload(A) (without cancel()) would resume the A upload on the server with B's chunk bytes, producing a hybrid file under A's id. The reuse branch now requires lastFile === file referential equality; otherwise it falls through to a fresh initiate.
  • CompletionWatcher treats 401/403 as fatal. Previously only 404 stopped polling; auth failures looped at 2-second intervals until the wall-clock timeout. They now invoke onError(err, true) and stop.
  • CompletionWatcher skips polling once Echo subscribed. When the Echo subscription confirms, the deferred poll start is cancelled — saves one HTTP request per active wait, which adds up on dashboards. If the subscribe fails, polling kicks off immediately instead of waiting out the start delay.
  • BatchUploader.enqueue() race when cancelling the active batch. drainQueue() used to shift() the next queued batch before the deferring microtask ran. If cancel() (or destroy()) ran in the gap — for example from a catch handler attached to the previous batch's rejection — the queued promise was rejected via rejectPendingQueue(), but the microtask had already captured the shifted entry and started a fresh upload anyway. The shift now happens inside the microtask and re-checks isUploading / queue length.
  • BatchUploader.destroy() rejection reason. destroy() called cancel() first and then rejectPendingQueue('… destroyed …'). Since cancel() already drains the queue under the generic "cancelled" reason, the destroy-specific reject became a silent no-op. Reordered so the destroy reason is applied first.
  • BatchMetadata::progress() now includes failed files. Previously a 4-file batch with 3 successes and 1 failure reported 75% progress while the batch was actually done; now it reports 100%, and the success-only view is available via successProgress().
  • InitiateBatchUploadRequest validation uses the batch's context, not the request's. Closes a validation-bypass where a caller could declare context: documents (max 50MB) in the request while the batch was created with context: photos (max 5MB) — the request validated against documents, but the save callback ran under photos. The request also rejects new uploads against terminal batches (Completed / PartiallyCompleted / Expired) at the validation layer.
  • validateBatchExists rejects terminal batches. A Completed / PartiallyCompleted / Expired batch can no longer accept further initiateInBatch calls — previously it silently grew past total_files, leaving the counters inconsistent.
  • Retry jitter on chunk failures. Exponential backoff now includes up to 250ms of random jitter so N parallel workers don't retry in lockstep and overwhelm a struggling server.

Changed (Breaking)

  • FilesystemTracker refuses to boot on non-local disks. The tracker's flock()-based mutation paths only work when the configured chunky.disk exposes a real local path. Booting against an S3/GCS-style disk previously fell back silently to lock-free writes — every chunk-write/claim was a lost-update race. Boot-time check now throws ChunkyException with a clear message; switch chunky.tracker to database, or set chunky.skip_local_disk_guard = true (for advanced users with external locking) to bypass.
  • UploadStatusController JSON response no longer includes disk, final_path, user_id. See Security above.
  • Models\ChunkedUpload::markChunkUploaded() signature dropped its unused ?string $checksum parameter. The tracker contract UploadTracker::markChunkUploaded() still accepts it for forward compatibility.
  • UploadChunkController maps ChunkyException to 409 Conflict and UploadExpiredException to 410 Gone.
  • AssembleFileJob is now retryable. Custom queue configurations that assumed a single attempt may need to be aware of tries=3, backoff=30. Override at dispatch time if needed.

npm packages

  • All packages bumped to 0.12.0 (core, vue3, react, alpine).
  • Vue 3 wrappers switched from getCurrentInstance() + onBeforeUnmount to getCurrentScope() + onScopeDispose. This preserves component-scoped behaviour but additionally cleans up uploader instances when used inside Pinia stores or effectScope blocks. Composables also expose an explicit destroy() for manual teardown.