v0.12.0
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_namevalidation now rejects/,\, NUL, Windows reserved characters, and./..outright (regex onInitiateUploadRequestandInitiateBatchUploadRequest). TheDefaultChunkHandler::assemble()additionally applies a defence-in-depthbasename()and refuses empty / dot names. Existing valid file names are unaffected; previously-accepted malicious names like../../public/.htaccessnow fail validation with 422. - Per-upload / per-batch authorisation. Introduces an
Authorizerinterface (NETipar\Chunky\Authorization\Authorizer) bound by default toDefaultAuthorizer, which enforcesauth()->id() === upload->userIdwhenever the upload/batch was created with an owner. Anonymous uploads (nouser_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(), andUploadStatusController/BatchStatusController/CancelUploadControllerall 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.phpregistered automatically when broadcasting is enabled; the upload, batch, and user channels delegate to the sameAuthorizerso HTTP and WebSocket access stay in sync. Disable withchunky.broadcasting.register_channels = falseif you prefer to register your own. - Status response sanitisation. The public
GET /upload/{uploadId}response no longer includes the storagedisk, the absolutefinal_path, or the owneruser_id. UseUploadMetadata::toArray()server-side if you need them. NewUploadMetadata::toPublicArray()helper exposes the trimmed payload. - Mass-assignment hardening. Both
ChunkedUploadandChunkyBatchnow define explicit$fillablearrays instead of$guarded = [].
Added
BatchUploader.enqueue()in@netipar/chunky-core. Same signature asupload()but doesn't throwBatch 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 toupload(), so callers can use it as a drop-in replacement that "just keeps working" across overlapping calls. The returned promise resolves with that batch'sBatchResult, or rejects withBatch upload cancelled before queued upload could start./BatchUploader destroyed before queued upload could start.ifcancel()/destroy()runs before the queued batch starts — so callers don't leak hanging promises.useBatchUploadin both the Vue 3 and React wrappers exposes the matchingenqueuemethod. Strictupload()keeps its existing behaviour for callers that want to detect overlap.onFileProgressin the ReactuseBatchUploadhook and a matchingchunky:batch-file-progressDOM event on the AlpinebatchUploaddata component. Now exposed in all three wrappers — Vue 3, React, Alpine.AuthorizerandDefaultAuthorizerservices (NETipar\Chunky\Authorization\*). Bind a custom implementation to override ownership rules (admin overrides, shared batches, etc.).AssembleFileJobretry support.tries = 3withbackoff = 30s, plus stale-claim recovery: if a worker crashed mid-assembly, a subsequent retry can re-claim the upload afterchunky.assembly_stale_after_minutes(default 10).BatchMetadata::successProgress()alongsideprogress(), 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_batchconfig (default 1000) — DOS protection on the batch initiate endpoint.
Fixed
AssembleFileJob"stuck in Assembling" recovery. Previously, if a worker crashed betweenassemble()andupdateStatus(Completed), the upload was wedged inAssemblingforever:claimForAssembly()only allowedPending → Assembling, andchunky:cleanupskippedAssemblingrows. Now both trackers (DB and FS) treat anAssemblingclaim older thanassembly_stale_after_minutesas 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.AssembleFileJobcleanup ordering. The chunkcleanup()call moved from before the save callback to afterupdateStatus(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 flipsCompleted → Failed. Previously, ifhandle()succeeded but the queue retried it for an unrelated reason (post-Completeddispatchfailure, broker hiccup),failed()would overwrite the upload status withFailed, double-increment the batch's failure counter, and broadcast a contradictoryUploadFailedevent. The early-return now matches any terminal state, not justFailed.- Tracker
markChunkUploaded()rejects late chunks. Both the DB and FS trackers now refuse chunk POSTs against uploads that aren't inPending(cancelled, completed, assembling, failed, expired). The HTTP layer surfaces this as 409 Conflict. A pre-flight check inChunkyManager::uploadChunk()short-circuits before the chunk hits disk, so cancel-races no longer leave orphan chunk files behind. BatchUploadercancel-during-finalise race. Ifcancel()ran in the very last tick of anupload()(after all worker promises resolved but before the success path emittedcomplete), bothcancelandcompleteevents fired for the same batch, contradicting each other. The success path now checks the cancel/abort flag and returns the partial result silently.BatchUploadercancel suppresses redundanterrorevent. A user-drivencancel()would surface the resultingAbortErroras anerrorevent in addition tocancel, so UI components showed both a "cancelled" indicator and an "error" toast for the same action. Aftercancel(), thecatchbranch tears down in-flight per-file uploaders and rethrows without re-emittingerror.BatchUploaderexception path tears down per-file uploaders. A failure ininitiateBatch(or any pre-loop step) used to leave the per-fileChunkUploaderworkers running in the background, continuing to POST chunks against an uploader the caller had already given up on. Thecatchbranch now callscancel()on each before re-emittingerror.ChunkUploader.upload()no longer corrupts a new upload by reusing a staleuploadId. Previously, callingupload(B)after a failedupload(A)(withoutcancel()) 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 requireslastFile === filereferential equality; otherwise it falls through to a fresh initiate.CompletionWatchertreats 401/403 as fatal. Previously only 404 stopped polling; auth failures looped at 2-second intervals until the wall-clock timeout. They now invokeonError(err, true)and stop.CompletionWatcherskips 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 toshift()the next queued batch before the deferring microtask ran. Ifcancel()(ordestroy()) ran in the gap — for example from acatchhandler attached to the previous batch's rejection — the queued promise was rejected viarejectPendingQueue(), but the microtask had already captured the shifted entry and started a fresh upload anyway. The shift now happens inside the microtask and re-checksisUploading/ queue length.BatchUploader.destroy()rejection reason.destroy()calledcancel()first and thenrejectPendingQueue('… destroyed …'). Sincecancel()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 viasuccessProgress().InitiateBatchUploadRequestvalidation uses the batch's context, not the request's. Closes a validation-bypass where a caller could declarecontext: documents(max 50MB) in the request while the batch was created withcontext: 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.validateBatchExistsrejects terminal batches. ACompleted/PartiallyCompleted/Expiredbatch can no longer accept furtherinitiateInBatchcalls — previously it silently grew pasttotal_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)
FilesystemTrackerrefuses to boot on non-local disks. The tracker'sflock()-based mutation paths only work when the configuredchunky.diskexposes 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 throwsChunkyExceptionwith a clear message; switchchunky.trackertodatabase, or setchunky.skip_local_disk_guard = true(for advanced users with external locking) to bypass.UploadStatusControllerJSON response no longer includesdisk,final_path,user_id. See Security above.Models\ChunkedUpload::markChunkUploaded()signature dropped its unused?string $checksumparameter. The tracker contractUploadTracker::markChunkUploaded()still accepts it for forward compatibility.UploadChunkControllermapsChunkyExceptionto 409 Conflict andUploadExpiredExceptionto 410 Gone.AssembleFileJobis now retryable. Custom queue configurations that assumed a single attempt may need to be aware oftries=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() + onBeforeUnmounttogetCurrentScope() + onScopeDispose. This preserves component-scoped behaviour but additionally cleans up uploader instances when used inside Pinia stores oreffectScopeblocks. Composables also expose an explicitdestroy()for manual teardown.