You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Content Drive accepts a multi-file selection, warns the user, and then uploads only the first file. There is no bulk upload on either side of the stack.
Current state, verified
The frontend is an explicit stub. DotContentDriveShellComponent.resolveFilesUpload branches on files.length > 1 into uploadFiles, which shows a warning toast and then calls uploadFile, which uploads files[0] and drops the rest:
content-drive.multiple-files-warning=Multiple files upload is not supported yet.<br/> Only one file will be uploaded
(Language.properties:7224, under a content-drive.work-in-progress summary.)
Everything upstream of that already handles many files. DotUploadDropzoneComponent and DotUploadButtonComponent emit a DotUploadFiles payload carrying a File[], drag-and-drop delivers the whole drop, and the type selector resolves one base type for the batch. The multi-file path exists right up to the request, and then discards the batch.
The backend has no bulk create either. DotUploadFileService.uploadFileByBaseType builds one FormData per file and fires newContentletByBaseType, so one file equals one workflow fire request. WebAssetResource (/v1/assets) exposes a single-asset PUT (:164) and nothing that accepts several. workflow/.../_bulkfire operates on existing contentlets and cannot create them from binaries. ImportContentletsProcessor is CSV import, a different operation.
The current call also passes indexPolicy: 'WAIT_FOR' (dot-content-drive-shell.component.ts:1001). Correct for one file, since the grid reloads immediately afterwards. Fired N times in a loop it serialises every upload behind an index refresh, so a naive "just loop the existing call" fix would be slow enough to look broken.
Two gaps, and they need separating
Several files, one target folder. The batch the UI already collects. This is the actual ask.
Folder upload, meaning a directory tree.webkitdirectory gives relative paths, so the folders have to be created before the assets land in them. Not proposed here; call it out as explicitly out of scope so it does not get assumed in.
Proposed shape: a job, reusing what #37062 introduces
Same reasoning as folder copy and bulk delete. A hundred files is a long operation with per-item outcomes, which is what the job queue is for, and the framework already has the multipart entry point for exactly this: POST /v1/jobs/{queueName}/upload consumes MULTIPART_FORM_DATA and is what ImportContentletsProcessor is fed through.
POST /api/v1/assets/_bulkupload -> 202, queue "assetBulkUpload"
Multipart: the files, plus targetFolder (or site id at the root) and baseType (DOTASSET / FILEASSET). Answers 202 with jobId and statusUrl; status, cancel and progress come from the existing /v1/jobs/{jobId} endpoints.
The processor creates one contentlet per file through the same workflow fire the single-file path uses, so content type resolution, permissions and workflow stay identical. Per-file outcomes land in getResultMetadata with the same successCount / failCount / results shape #37062 defines, keyed by file name. Index policy moves off WAIT_FOR per file: index once at the end of the batch, or let the job's completion drive the single grid reload.
Partial failure is the normal case here
More so than for copy or delete. A batch of thirty files will routinely contain one that is too large, has a blocked extension, or collides with an existing asset name. The batch must not stop at the first failure, and the result has to name the files that did not make it. A single "upload failed" toast over a thirty-file drop is the outcome to avoid.
Acceptance Criteria
Backend
POST /api/v1/assets/_bulkupload accepts a multipart body with several files plus targetFolder and baseType, enqueues an assetBulkUpload job, and returns 202 with jobId and statusUrl
The processor is @Queue-annotated, implements Cancellable, and reports progress per file through Job#progressTracker()
Each file is created through the same workflow fire as the existing single-file upload, so content type resolution, permissions and workflow behavior are identical
Files are not indexed with WAIT_FOR per item; the batch indexes once, or the job's completion drives the reload
A user without add-children permission on the target folder is rejected at submission with 403
Cancelling leaves already-created contentlets in place and records in the result how far the batch got
Per-file size and extension limits are enforced with the same rules as the single-file path, and a rejection is a per-file failure rather than a 400 for the whole batch
openapi.yaml regenerated and committed with the annotation changes
Integration tests cover: many files succeeding, mixed partial failure, a rejected extension, an oversized file, permission denied, cancellation mid-batch, and a batch large enough to exercise progress reporting
Frontend
Selecting or dropping several files uploads all of them
Both upload entry points accept several files. The hidden file input (dot-content-drive-shell.component.html:178) carries no multiple attribute, so the Upload-button path can only ever select one file and only drag-and-drop delivers a batch. onFileChange already reads the whole FileList and hands it to resolveFilesUpload unchanged, so this is the attribute plus its tests. The picker and the drop must behave identically
Upload's copy follows the Action Center's shape rather than its own, so the two surfaces read the same: the in-flight label matches content-drive.action-center.applying, and the outcome toast matches ...toast.executed / -detail / -partial instead of content-drive.add-dotasset-success and -error
Content Drive stops using content-drive.multiple-files-warning and the work-in-progress summary, and the files.length > 1 stub branch in resolveFilesUpload is removed. Leave the definitions in place: the AssetPicker still references them, along with content-drive.file-upload-in-progress and its -detail. It has the same first-file-only defect, tracked separately as AssetPicker: multi-file selection uploads only the first file #37370 and out of scope here
Progress is shown for the batch while the job runs, via a reusable core-web job-progress primitive owned by this ticket (see below); it must not be Content-Drive-specific or upload-specific in its API
The completion toast reports the server's successCount / failCount, never the number of files selected
A partial failure names the files that failed and why, and stays on screen long enough to be read
The grid refreshes once when the job finishes, not once per file
Uploading stays available while the job runs, or is disabled with a reason; either way the UI does not appear frozen
Navigating away does not lose the batch: the job keeps running and its outcome is still discoverable
Outcome copy unified with the Workflow Center
The Workflow Center names what ran and what it ran on (<b>{0}</b> ran on {1} item(s).). Lock, unlock, download and delete folder already do this. The rest do not, and the copy for the missing ones was written and never wired: content-drive.toast.workflow-in-progress, its -detail, and content-drive.toast.workflow-executed-detail (Language.properties:7240-7243) are referenced nowhere.
Every operation's outcome names the action and the item it ran on, matching the Workflow Center's summary/detail split
Folder create's summary: 'Success' is a hardcoded English literal (dot-content-drive-dialog-folder.component.ts:291), not an i18n key. It is replaced, and the outcome names the folder
Edit Folder's outcome (content-drive.dialog.folder.message.save-success, "Folder saved successfully") carries no detail and does not name the folder (:323). The action is the context menu's content-drive.context-menu.edit-folder, offered on EDIT permission, which reopens the create dialog with the folder as payload and branches to saveFolder() on submit
Both folder error paths pass the server's error.message straight through as the toast detail (:309 and :342). Replaced with resolved copy, with the raw message logged rather than shown
Lock and unlock errors name the item, as their success counterparts already do (Locked {0} versus "Something went wrong. The contentlet wasn't locked.")
The orphaned keys at Language.properties:7240-7243 are resolved: workflow-executed-detail is wired up, and the two workflow-in-progress keys are deleted, since the toolbar indicator replaces them
In-progress feedback moves to the toolbar indicator
Content Drive currently reports "this is running" four different ways: the toolbar indicator (Action Center), an info toast (upload, drag-and-drop move), blanking the whole grid with skeleton rows (context-menu workflow action), and nothing at all. This ticket settles the rule and converts every intermediate state except reindex.
The rule: anything asynchronous that outlives the surface that triggered it reports its in-flight state on the toolbar indicator (dot-content-drive-toolbar.component.html:107). Not a toast, not the grid's loading state, not silence. The toast is reserved for the terminal state (success, partial, error, cancelled), and the grid's LOADING status means "the listing is being fetched" and nothing else
Content Drive stops using content-drive.file-upload-in-progress and its -detail (dot-content-drive-shell.component.ts:1057); the batch reports on the indicator
The drag-and-drop move's in-flight toasts are removed (content-drive.move-to-folder-in-progress and -with-folders, shell:1138 and 1150) and the move is routed through withActionExecution instead of calling bulkFire directly from the shell, so it reports on the same indicator. This is also what proves the keyed-runs change below, which upload alone cannot exercise
The context-menu workflow action stops calling setStatus(DotContentDriveStatus.LOADING) (dot-folder-list-context-menu.component.ts:435 and :424), which today replaces the entire table with skeleton rows for an action fired on one row. It reports on the indicator instead, naming the item: "Applying Publish to My Title"
The item title is HTML-escaped before it reaches the indicator label, which is bound with [innerHTML]; actionName already is, and a content title is user-supplied
The context-menu workflow action's outcome toast names what happened, on both success and failure. content-drive.toast.workflow-executed-detail already exists in Language.properties:7243 and is never passed; today the toast reads "Workflow Executed" with no action and no item, and the error path (content-drive.toast.workflow-error) has no detail either
The indicator shows determinate progress once the job reports it, and stays indeterminate when it does not
The indicator offers cancellation for a cancellable runDeferred to [EPIC] Task manager UI to manage large uploads and moves #33331, which already owns it: "From this task manager UI, we can also enable the user to cancel individual file uploads, or cancel the whole upload or move." The backend still builds the capability, so this is a deferral and not a removal. What this ticket must still do is not report a cancelled run as a success: an unrecognised terminal state is reported as an error rather than a green outcome
actionExecution becomes a keyed collection of runs rather than a single slot, so an upload does not block a selection action or the reverse. The existing one-at-a-time guard is scoped per action rather than global
The indicator has a defined rule for concurrent runs (one line per run up to two, then a collapsed count)
Jest/Spectator specs cover: multi-file success, partial failure, a terminal state the client does not recognise reported as an error rather than a success, that a single-file upload still takes the existing path unchanged, that no operation raises an in-flight toast, that the context-menu workflow action leaves the grid rendered, and that two runs can be in flight at once without blocking each other
Priority
Medium
Additional Context
Out of scope: directory upload. Dropping a folder from the desktop needs webkitdirectory relative paths and folder creation ahead of the assets. Worth doing, materially more work, and it should not ride in on this ticket. File a follow-up when this lands.
Out of scope: resumable or chunked single-file upload. Large-single-file behavior is unchanged here.
Now IN scope: per-row busy state.(Reversed 2026-09-03.) Marking the individual rows an operation is acting on, so the grid shows which items are in flight rather than only what is running.
It was excluded on the reasoning that "upload has no rows, so this ticket cannot exercise it". That reasoning no longer holds: this ticket also converts the context menu and the drag-and-drop move to the shared indicator, and both act on rows. The feature has two real consumers inside this PR.
It is also cheaper here than anywhere else. dot-folder-list-view already swaps per row inside its #body template, and $lockedByOthers is an existing keyed per-row input in the same file, already keyed by inode. The store now exposes busyRows from the run registry.
Additional acceptance criteria:
While an operation runs on a row, that row shows it and stays readable and identifiable — not replaced by a skeleton, which loses the row the author wants to watch
The rest of the grid stays usable; rows the operation is not touching behave normally
Busy marks are keyed by inode, not identifier: the language filter is multi-select, so one identifier can occupy several rows and marking by identifier would mark siblings nothing is happening to
The settling reload does not blank the table: it refetches quietly, leaving rows visible, and the busy marks clear once fresh data lands. This keeps the outcome filter-correct — an archived or unpublished row simply is not in the new result — which an optimistic in-place update could never be, since the client cannot know whether the new state still matches an active filter
Busy marks clear on the failure path too, or rows stay busy forever
dot-folder-list-view is given dataKey="inode". It currently takes the 'identifier' default, so selecting one language version of a contentlet selects them all. The codebase already assumes inode — the search service backfills folder inodes with a comment saying the table keys on it — the shell simply never passes it
#37322 was filed for this and is superseded; it should be closed once this lands.
Out of scope: reindex.content-drive.action-center.toast.reindex-started stays as it is. Its opt-out from the indicator is deliberate and documented, and the notification bell already records the run.
This ticket owns the core-web job-progress primitive. Nothing in core-web consumes /v1/jobs today, so the first consumer has to build it, and upload is the right owner: #32356 asks for the richest version of it (per-file progress, per-file status, cancel one file or the whole batch), so a primitive that satisfies upload satisfies the folder actions too, while the reverse is not true.
Requirements for it, so #37063 can consume it without changes:
submit, resolve jobId, follow the job, expose progress, expose the terminal state and the result metadata
follows jobs over GET /v1/jobs/{jobId}/monitor with native EventSource, falling back to status polling. Worth noting: dot-content-drive-action-center.component.ts:126 records that the legacy _bulkfire SSE path was skipped because "native EventSource cannot POST a body". That objection does not apply here, since submission is a normal POST returning a jobId and monitoring is a separate GET
a running job survives component teardown and navigation, and its outcome stays discoverable
generic in its API: no upload-specific or Content-Drive-specific shapes
Depends on #37062 for the job-result contract (successCount / failCount / results), which the folder endpoints define first.
#37063 consumes the primitive for folder copy and bulk delete. Whichever ticket lands first builds it; the specification lives here. Do not build it twice.
Related:#37062 (job-result contract), #37063 (consumes the job-progress primitive for folder copy and delete), #35436 (parent epic), #32356 (the async-upload feature this implements), #36702 (AssetPicker reusing Content Drive, second consumer of the same upload path).
Amendment — 2026-09-01
Recorded during /speckit-specify for the backend half (spec PR #37300, reviewed by @fabrizzio-dotCMS). These supersede the corresponding statements above; everything not listed
here still stands as originally written.
1. The job-result contract: dependency direction reversed
Above: "Depends on #37062 for the job-result contract (successCount / failCount / results), which the folder endpoints define first."
Now: this ticket owns that contract; #37062 and #37063 consume it. Both are open and
unbuilt, so waiting on #37062 would block this work on a ticket nobody has started.
And it is not a new contract. The shape already ships: BulkRefreshContentletsProcessor#getResultMetadata
(#36845 / #37131) emits total / processed / successCount / failedCount / skippedCount / results[], with an immutable per-item record and a SUCCESS / FAILED / SKIPPED status. This
ticket generalizes that shape rather than defining a second one. Three deltas are required:
A machine-readable reason code. The shipped per-item record carries a human-readable message
only. Failures here must distinguish at least: over size limit, disallowed type, name collision,
permission denied, staged content unavailable, and unclassified.
A generic item key. The shipped record is keyed by contentlet identifier and inodes. An
uploading file has neither — it does not exist until the run creates it — and neither does a
folder path. A generic key is the substance of the generalization.
One spelling of the counters. The shipped counter is failedCount; this issue says failCount above. failedCount wins, matching what already ships.
#37062 / #37063: consume this shape. Please do not define a third one.
2. Submission entry point: a domain endpoint, not the generic job upload
Above: proposes reusing POST /v1/jobs/{queueName}/upload because it already consumes
multipart.
Now: a domain endpoint owning its own multipart handling. Two reasons:
JobParams declares a single @FormDataParam("file") InputStream, so the generic entry point
accepts exactly one file today. Carrying a batch through it means changing a shared type
with other consumers.
Submission-time validation. A missing target folder, an upload type that is neither DOTASSET
nor FILEASSET, or a caller without add-children rights must be refused before a job is
created. The generic entry point has no notion of a target folder; it would enqueue first and
fail inside the run.
The client sends one call carrying the files and the batch parameters. Where the bytes wait
for the run is a server concern and stays out of the frontend contract.
3. Per-file size and type limits are inherited, not invented
Above: "Per-file size and extension limits are enforced with the same rules as the
single-file path."
Now: confirmed, and worth stating precisely because it was nearly specified the other way. ESContentletAPIImpl.validateBinary already enforces a size ceiling (maxFileLength) and an
allowed-types rule (accept), both declared per content type on the binary field, during the
contentlet validation the creation path runs. This ticket applies those, not bulk-specific
ones — a batch that rejected a file the single-file upload accepts would not be equivalent.
Consequence, accepted knowingly: where an operator has configured neither, there is no ceiling.
4. New: a batch file-count cap
Nothing caps a selection today. Default 50 files per batch, configurable, enforced as a
submission-time refusal. There is deliberately no batch-total size limit.
5. New: staged content must outlive the queue wait
Not covered by the acceptance criteria above, and it is where this feature can silently lose a
user's files. The submission is answered before the files are created, so the uploaded bytes wait
somewhere in between. The product's staging mechanism expires content on a global timer
(TEMP_RESOURCE_MAX_AGE_SECONDS, default 1800s) with no per-call override, so a large batch queued
behind other work can outlive its own staged files.
Additional acceptance criteria:
Uploaded content stays available to the run until the run reaches a terminal state, however
long it waits in queue
Content that cannot be retrieved is a per-file failure with its own reason — never a silent
loss, and never reported as though the author supplied a bad file
Staged content is reclaimed on any terminal state, including for files the run never reached
Staged content is readable by a node other than the one that accepted the submission
A stated position on total staged bytes per author across concurrent batches ("none,
deliberately" is acceptable; silence is not)
6. Frontend: cancellation is being deferred, and needs its own ticket
The frontend acceptance criterion "the indicator offers cancellation for a cancellable run" is
being deferred to the task-manager work. The backend still builds the capability, so this is a
deferral, not a removal.
Resolved: #33331 covers it explicitly, so no new ticket. The epic's own description already
claims this exact capability: "From this task manager UI, we can also enable the user to cancel
individual file uploads, or cancel the whole upload or move." The frontend spec cites it in six
places and states the reasoning; the acceptance criterion above is now marked deferred rather than
left looking like an undelivered promise.
The one thing kept in this ticket is the half that is not about the control: a run reaching a
cancelled state must not be reported to the author as a success. That is covered by the frontend
spec's FR-024, which reports any terminal state the client does not recognise as an error.
So the backend cancel surface does have a dated consumer (#33331), and until it arrives the client
cannot mistake a cancelled run for a completed one.
Amendment — 2026-09-07
Recorded after /speckit-plan for the backend half, and after the review of spec PR #37358 by @wezell. These supersede the corresponding
statements above and in the 2026-09-01 amendment; everything not listed here still stands.
The backend spec, wire contract and data model are in specs/37166-bulk-file-upload/.
1. Submission shape: one call, confirmed — and it is the shipped precedent
The 2026-09-01 amendment already said "one call". It was reversed to two steps mid-review and then
restored, so this records the outcome rather than leaving the PR history as the only account.
@wezell's objection was that a second upload endpoint duplicates the temp API. Accepted: the
endpoint hands every part to TempFileAPI.createTempFile as it reads, and stores only the
resulting ids in the job parameters. It does not stage anything of its own.
Content import is the precedent, not a departure from it.ContentImportResource:102 consumes MULTIPART_FORM_DATA, and ContentImportHelper:394 calls the staging API internally. An
intermediate draft had described import as though the client staged; it does not.
Two findings decided it against the client-stages-first alternative:
The expiry clock.TempFileAPI.getTempFile gates on lastModified() + TTL, per file. If the
client staged, the earliest files of a large batch could expire before the author reached submit,
and an unresolvable reference refuses the whole submission. Server-side staging starts that clock
immediately before the job is created.
Bounding. Nothing below this endpoint caps file count or request size. With the bytes already
written at submission time, the ceilings would be a policy about what gets processed rather
than a bound on what can be written.
Accepted cost, recorded so it is not rediscovered: no per-file upload progress and no retry of
a single failed file. A dropped connection means resending the batch.
2. Limits — supersedes §3 and §4 of the 2026-09-01 amendment
Per-file ceiling where the content type declares none
"no ceiling, accepted knowingly"
200 MB fallback, configurable
Why a batch total after all. Without one the batch is bounded only by the file count multiplied
by a per-file ceiling that is unset by default — which is to say not bounded at all. TEMP_RESOURCE_MAX_FILE_SIZE
ships as -1, and dotmarketing-config.properties says so in a comment: "authenticated users are
unlimited". There is also no container-level multipart limit configured. This endpoint is the
first and only bound in the path.
Why 100 and not more. The two ceilings are chosen together: at a few megabytes per file, 300
would put a realistic batch past the total, making the count cap decorative and the size cap the
one that surprises people. Under the one-call shape the count also governs how long a single
request stays open.
Why the per-file fallback is a knowing exception. Where a content type declares no maxFileLength, a file over the fallback is rejected in a batch and accepted as a single upload.
That diverges from the equivalence rule this ticket otherwise holds to. It is accepted because the
alternative — no per-file bound at all — was judged worse, and it is documented wherever the limits
are.
Three controls here are new, not one: the file count, the per-file fallback, and the batch total.
3. New: two submission-time controls the endpoint does not inherit
Not covered by any acceptance criterion above.
verifyTempResourceEnabled() and SecurityUtils.validateReferer() live on TempFileResource, not
on the TempFileAPI this endpoint calls. No global filter supplies them for API paths — XSSPreventionWebInterceptor protects a fixed list that does not include /api/.
The endpoint applies a same-origin check on the request
The endpoint honours TEMP_RESOURCE_ENABLED rather than writing staged content behind
a switch an operator turned off
Both are covered by tests
Severity, stated plainly: the classic CSRF path is already closed by sameSiteCookies=lax in context.xml, so this is defense in depth rather than a live exploit. It is specified because the
value is operator-configurable and because content import omits both today — following that
precedent uncritically would reproduce the gap. Closing it there is tracked separately.
4. New: the ceilings are enforced while the request is read
The file count and the accumulated total are checked as each part is staged, aborting the read
the moment either is crossed, rather than after the whole body has arrived.
A submission over either ceiling cannot commit more than the ceiling to disk
Content staged before the abort is reclaimed
Content staged before a submission that dies while being read — the author navigated away,
or the connection dropped — is also reclaimed. This is a different code path from a refusal:
nothing is raised by this side, so a reclaim scoped to the refusal path never runs. Nothing
purges staged content on a schedule, so what leaks here leaks permanently
A declared totalSizeBytes may be sent for a fast refusal before reading. It is a courtesy, never
the enforcement point.
5. Index policy, made concrete
Above: "Files are not indexed with WAIT_FOR per item; the batch indexes once, or the job's
completion drives the reload."
Now: each file is created with IndexPolicy.DEFER, and the run resolves index visibility
once at the end of the batch, before the completion signal is emitted. Both halves are needed:
Per-file WAIT_FOR does not merely block on a refresh — indexContentListWaitFor also flushes
the system-wide query cache on every file, so a full batch charges every other user one flush per
file.
DEFERalone is insufficient: it only enqueues into dist_reindex_journal. A run reporting
"finished" would hand the author files a text search cannot yet find. Per ADR-0018
the plain folder listing is database-resolved, so the grid is fine either way — but free-text and
searchable-field criteria are index-routed, and that ADR names this exact failure as a recurring
complaint.
The frontend depends on the ordering: it refreshes its listing on the completion signal, so
emitting the signal first would surface as a client-side defect for a cause living entirely on the
server.
6. Target fields: folderId / siteId, not targetFolder
Above: "Multipart: the files, plus targetFolder (or site id at the root)".
Now: two explicit, mutually exclusive fields. The workflow API this feature fires already
separates contentHost (site) from hostFolder (folder), and Content Drive currently sends a site
id inhostFolder at the root — the overloading ADR-0020
moves away from. Exactly one of the two must be present.
The multipart shape follows content import: repeated files parts plus a JSON form part carrying baseType, folderId | siteId and the optional totalSizeBytes. A batch over the total size
answers 413; the count, shape and origin refusals answer 400.
7. New scope: a resumable run, and a safe resubmission
Not covered by any acceptance criterion above, and the largest piece of hidden work in this
ticket.
The job framework persists no mid-run per-item state: parameters are immutable after creation, progress is a single float, and result is harvested once at the terminal state. Meanwhile AbandonedJobDetector re-queues a stalled run without consulting the retry policy, so an
interrupted run is retried whether or not the processor is marked no-retry.
A re-run without resume does not duplicate data — the unique index on the lower-cased path rejects
the second create — but it makes the report lie: the files the first attempt created come back
as NAME_COLLISION, so the author is told 30 files failed when all 30 are in the folder.
The run records completed items durably as it goes, in a new additive job_item_result table
A re-queued run continues from that record instead of restarting
Counts reflect the whole batch across all attempts, and the submitter is notified once
Resubmitting after a lost or uncertain response never silently creates a second copy, and a
duplicate resubmission is distinguishable from a batch whose files genuinely all collided
The table is named for jobs, not for uploads, on purpose: #37062, #37063 and #37165 all need the
same thing, and four near-identical tables would be the wrong outcome. Whether durable per-item
state belongs in the job framework itself is being proposed as an ADR in dotCMS/platform-adrs.
8. Where the batch guarantee starts — affects a frontend acceptance criterion
Every guarantee in this ticket — surviving the author leaving, resumability, cancellation, the
durable outcome — is a property of a run, and a run does not exist until the submission is
answered with a handle.
The frontend criterion "Navigating away does not lose the batch" therefore holds after the 202, not during the upload. If the author navigates away while the bytes are still being sent,
that request dies and no job is created: nothing is recorded and nobody is notified, because there
is nothing yet to record.
Agreed with @wezell on #37358 and accepted rather than engineered around. The cheap mitigation,
which the frontend half should take: dotAdmin is a single Angular application — legacy portlets are
hosted inside the same shell — so navigating between portlets is a route change and the document
survives. An upload request owned by a root-level service rather than by the Content Drive
component therefore survives that navigation at no extra cost, which the job-progress primitive
already requires. A beforeunload guard covers a genuine page unload. Only a Service Worker would
survive the document being destroyed, and dotAdmin registers none; that is judged disproportionate.
Description
Content Drive accepts a multi-file selection, warns the user, and then uploads only the first file. There is no bulk upload on either side of the stack.
Current state, verified
The frontend is an explicit stub.
DotContentDriveShellComponent.resolveFilesUploadbranches onfiles.length > 1intouploadFiles, which shows a warning toast and then callsuploadFile, which uploadsfiles[0]and drops the rest:(
Language.properties:7224, under acontent-drive.work-in-progresssummary.)Everything upstream of that already handles many files.
DotUploadDropzoneComponentandDotUploadButtonComponentemit aDotUploadFilespayload carrying aFile[], drag-and-drop delivers the whole drop, and the type selector resolves one base type for the batch. The multi-file path exists right up to the request, and then discards the batch.The backend has no bulk create either.
DotUploadFileService.uploadFileByBaseTypebuilds oneFormDataper file and firesnewContentletByBaseType, so one file equals one workflow fire request.WebAssetResource(/v1/assets) exposes a single-assetPUT(:164) and nothing that accepts several.workflow/.../_bulkfireoperates on existing contentlets and cannot create them from binaries.ImportContentletsProcessoris CSV import, a different operation.The current call also passes
indexPolicy: 'WAIT_FOR'(dot-content-drive-shell.component.ts:1001). Correct for one file, since the grid reloads immediately afterwards. Fired N times in a loop it serialises every upload behind an index refresh, so a naive "just loop the existing call" fix would be slow enough to look broken.Two gaps, and they need separating
webkitdirectorygives relative paths, so the folders have to be created before the assets land in them. Not proposed here; call it out as explicitly out of scope so it does not get assumed in.Proposed shape: a job, reusing what #37062 introduces
Same reasoning as folder copy and bulk delete. A hundred files is a long operation with per-item outcomes, which is what the job queue is for, and the framework already has the multipart entry point for exactly this:
POST /v1/jobs/{queueName}/uploadconsumesMULTIPART_FORM_DATAand is whatImportContentletsProcessoris fed through.Multipart: the files, plus
targetFolder(or site id at the root) andbaseType(DOTASSET/FILEASSET). Answers202withjobIdandstatusUrl; status, cancel and progress come from the existing/v1/jobs/{jobId}endpoints.The processor creates one contentlet per file through the same workflow fire the single-file path uses, so content type resolution, permissions and workflow stay identical. Per-file outcomes land in
getResultMetadatawith the samesuccessCount/failCount/resultsshape #37062 defines, keyed by file name. Index policy moves offWAIT_FORper file: index once at the end of the batch, or let the job's completion drive the single grid reload.Partial failure is the normal case here
More so than for copy or delete. A batch of thirty files will routinely contain one that is too large, has a blocked extension, or collides with an existing asset name. The batch must not stop at the first failure, and the result has to name the files that did not make it. A single "upload failed" toast over a thirty-file drop is the outcome to avoid.
Acceptance Criteria
Backend
POST /api/v1/assets/_bulkuploadaccepts a multipart body with several files plustargetFolderandbaseType, enqueues anassetBulkUploadjob, and returns202withjobIdandstatusUrl@Queue-annotated, implementsCancellable, and reports progress per file throughJob#progressTracker()getResultMetadatareturnssuccessCount,failCountand a per-fileresultsarray with a machine-readable error and a message for each failure, matching Async bulk folder copy and delete via the job queue #37062's shapeWAIT_FORper item; the batch indexes once, or the job's completion drives the reload403400for the whole batchopenapi.yamlregenerated and committed with the annotation changesFrontend
dot-content-drive-shell.component.html:178) carries nomultipleattribute, so the Upload-button path can only ever select one file and only drag-and-drop delivers a batch.onFileChangealready reads the wholeFileListand hands it toresolveFilesUploadunchanged, so this is the attribute plus its tests. The picker and the drop must behave identicallycontent-drive.action-center.applying, and the outcome toast matches...toast.executed/-detail/-partialinstead ofcontent-drive.add-dotasset-successand-errorcontent-drive.multiple-files-warningand thework-in-progresssummary, and thefiles.length > 1stub branch inresolveFilesUploadis removed. Leave the definitions in place: the AssetPicker still references them, along withcontent-drive.file-upload-in-progressand its-detail. It has the same first-file-only defect, tracked separately as AssetPicker: multi-file selection uploads only the first file #37370 and out of scope heresuccessCount/failCount, never the number of files selectedOutcome copy unified with the Workflow Center
The Workflow Center names what ran and what it ran on (
<b>{0}</b> ran on {1} item(s).). Lock, unlock, download and delete folder already do this. The rest do not, and the copy for the missing ones was written and never wired:content-drive.toast.workflow-in-progress, its-detail, andcontent-drive.toast.workflow-executed-detail(Language.properties:7240-7243) are referenced nowhere.summary: 'Success'is a hardcoded English literal (dot-content-drive-dialog-folder.component.ts:291), not an i18n key. It is replaced, and the outcome names the foldercontent-drive.dialog.folder.message.save-success, "Folder saved successfully") carries no detail and does not name the folder (:323). The action is the context menu'scontent-drive.context-menu.edit-folder, offered on EDIT permission, which reopens the create dialog with the folder as payload and branches tosaveFolder()on submiterror.messagestraight through as the toast detail (:309 and :342). Replaced with resolved copy, with the raw message logged rather than shownLocked {0}versus "Something went wrong. The contentlet wasn't locked.")Language.properties:7240-7243are resolved:workflow-executed-detailis wired up, and the twoworkflow-in-progresskeys are deleted, since the toolbar indicator replaces themIn-progress feedback moves to the toolbar indicator
Content Drive currently reports "this is running" four different ways: the toolbar indicator (Action Center), an info toast (upload, drag-and-drop move), blanking the whole grid with skeleton rows (context-menu workflow action), and nothing at all. This ticket settles the rule and converts every intermediate state except reindex.
dot-content-drive-toolbar.component.html:107). Not a toast, not the grid's loading state, not silence. The toast is reserved for the terminal state (success, partial, error, cancelled), and the grid'sLOADINGstatus means "the listing is being fetched" and nothing elsecontent-drive.file-upload-in-progressand its-detail(dot-content-drive-shell.component.ts:1057); the batch reports on the indicatorcontent-drive.move-to-folder-in-progressand-with-folders, shell:1138 and 1150) and the move is routed throughwithActionExecutioninstead of callingbulkFiredirectly from the shell, so it reports on the same indicator. This is also what proves the keyed-runs change below, which upload alone cannot exercisesetStatus(DotContentDriveStatus.LOADING)(dot-folder-list-context-menu.component.ts:435and :424), which today replaces the entire table with skeleton rows for an action fired on one row. It reports on the indicator instead, naming the item: "Applying Publish to My Title"[innerHTML];actionNamealready is, and a content title is user-suppliedcontent-drive.toast.workflow-executed-detailalready exists inLanguage.properties:7243and is never passed; today the toast reads "Workflow Executed" with no action and no item, and the error path (content-drive.toast.workflow-error) has no detail eitherThe indicator offers cancellation for a cancellable runDeferred to [EPIC] Task manager UI to manage large uploads and moves #33331, which already owns it: "From this task manager UI, we can also enable the user to cancel individual file uploads, or cancel the whole upload or move." The backend still builds the capability, so this is a deferral and not a removal. What this ticket must still do is not report a cancelled run as a success: an unrecognised terminal state is reported as an error rather than a green outcomeactionExecutionbecomes a keyed collection of runs rather than a single slot, so an upload does not block a selection action or the reverse. The existing one-at-a-time guard is scoped per action rather than globalPriority
Medium
Additional Context
Out of scope: directory upload. Dropping a folder from the desktop needs
webkitdirectoryrelative paths and folder creation ahead of the assets. Worth doing, materially more work, and it should not ride in on this ticket. File a follow-up when this lands.Out of scope: resumable or chunked single-file upload. Large-single-file behavior is unchanged here.
Now IN scope: per-row busy state. (Reversed 2026-09-03.) Marking the individual rows an operation is acting on, so the grid shows which items are in flight rather than only what is running.
It was excluded on the reasoning that "upload has no rows, so this ticket cannot exercise it". That reasoning no longer holds: this ticket also converts the context menu and the drag-and-drop move to the shared indicator, and both act on rows. The feature has two real consumers inside this PR.
It is also cheaper here than anywhere else.
dot-folder-list-viewalready swaps per row inside its#bodytemplate, and$lockedByOthersis an existing keyed per-row input in the same file, already keyed by inode. The store now exposesbusyRowsfrom the run registry.Additional acceptance criteria:
dot-folder-list-viewis givendataKey="inode". It currently takes the'identifier'default, so selecting one language version of a contentlet selects them all. The codebase already assumes inode — the search service backfills folder inodes with a comment saying the table keys on it — the shell simply never passes it#37322 was filed for this and is superseded; it should be closed once this lands.
Out of scope: reindex.
content-drive.action-center.toast.reindex-startedstays as it is. Its opt-out from the indicator is deliberate and documented, and the notification bell already records the run.This ticket owns the core-web job-progress primitive. Nothing in core-web consumes
/v1/jobstoday, so the first consumer has to build it, and upload is the right owner: #32356 asks for the richest version of it (per-file progress, per-file status, cancel one file or the whole batch), so a primitive that satisfies upload satisfies the folder actions too, while the reverse is not true.Requirements for it, so #37063 can consume it without changes:
jobId, follow the job, expose progress, expose the terminal state and the result metadataGET /v1/jobs/{jobId}/monitorwith nativeEventSource, falling back tostatuspolling. Worth noting:dot-content-drive-action-center.component.ts:126records that the legacy_bulkfireSSE path was skipped because "nativeEventSourcecannot POST a body". That objection does not apply here, since submission is a normal POST returning ajobIdand monitoring is a separate GETPOST /v1/jobs/{jobId}/cancel, but nothing in this ticket surfaces it; the control is [EPIC] Task manager UI to manage large uploads and moves #33331's. The primitive's API should not be shaped to preclude itDepends on #37062 for the job-result contract (
successCount/failCount/results), which the folder endpoints define first.#37063 consumes the primitive for folder copy and bulk delete. Whichever ticket lands first builds it; the specification lives here. Do not build it twice.
Related: #37062 (job-result contract), #37063 (consumes the job-progress primitive for folder copy and delete), #35436 (parent epic), #32356 (the async-upload feature this implements), #36702 (AssetPicker reusing Content Drive, second consumer of the same upload path).
Amendment — 2026-09-01
Recorded during
/speckit-specifyfor the backend half (spec PR #37300, reviewed by@fabrizzio-dotCMS). These supersede the corresponding statements above; everything not listed
here still stands as originally written.
1. The job-result contract: dependency direction reversed
Above: "Depends on #37062 for the job-result contract (
successCount/failCount/results), which the folder endpoints define first."Now: this ticket owns that contract; #37062 and #37063 consume it. Both are open and
unbuilt, so waiting on #37062 would block this work on a ticket nobody has started.
And it is not a new contract. The shape already ships:
BulkRefreshContentletsProcessor#getResultMetadata(#36845 / #37131) emits
total/processed/successCount/failedCount/skippedCount/results[], with an immutable per-item record and aSUCCESS/FAILED/SKIPPEDstatus. Thisticket generalizes that shape rather than defining a second one. Three deltas are required:
only. Failures here must distinguish at least: over size limit, disallowed type, name collision,
permission denied, staged content unavailable, and unclassified.
uploading file has neither — it does not exist until the run creates it — and neither does a
folder path. A generic key is the substance of the generalization.
failedCount; this issue saysfailCountabove.failedCountwins, matching what already ships.2. Submission entry point: a domain endpoint, not the generic job upload
Above: proposes reusing
POST /v1/jobs/{queueName}/uploadbecause it already consumesmultipart.
Now: a domain endpoint owning its own multipart handling. Two reasons:
JobParamsdeclares a single@FormDataParam("file") InputStream, so the generic entry pointaccepts exactly one file today. Carrying a batch through it means changing a shared type
with other consumers.
DOTASSETnor
FILEASSET, or a caller without add-children rights must be refused before a job iscreated. The generic entry point has no notion of a target folder; it would enqueue first and
fail inside the run.
The client sends one call carrying the files and the batch parameters. Where the bytes wait
for the run is a server concern and stays out of the frontend contract.
3. Per-file size and type limits are inherited, not invented
Above: "Per-file size and extension limits are enforced with the same rules as the
single-file path."
Now: confirmed, and worth stating precisely because it was nearly specified the other way.
ESContentletAPIImpl.validateBinaryalready enforces a size ceiling (maxFileLength) and anallowed-types rule (
accept), both declared per content type on the binary field, during thecontentlet validation the creation path runs. This ticket applies those, not bulk-specific
ones — a batch that rejected a file the single-file upload accepts would not be equivalent.
Consequence, accepted knowingly: where an operator has configured neither, there is no ceiling.
4. New: a batch file-count cap
Nothing caps a selection today. Default 50 files per batch, configurable, enforced as a
submission-time refusal. There is deliberately no batch-total size limit.
5. New: staged content must outlive the queue wait
Not covered by the acceptance criteria above, and it is where this feature can silently lose a
user's files. The submission is answered before the files are created, so the uploaded bytes wait
somewhere in between. The product's staging mechanism expires content on a global timer
(
TEMP_RESOURCE_MAX_AGE_SECONDS, default 1800s) with no per-call override, so a large batch queuedbehind other work can outlive its own staged files.
Additional acceptance criteria:
long it waits in queue
loss, and never reported as though the author supplied a bad file
deliberately" is acceptable; silence is not)
6. Frontend: cancellation is being deferred, and needs its own ticket
The frontend acceptance criterion "the indicator offers cancellation for a cancellable run" is
being deferred to the task-manager work. The backend still builds the capability, so this is a
deferral, not a removal.
Resolved: #33331 covers it explicitly, so no new ticket. The epic's own description already
claims this exact capability: "From this task manager UI, we can also enable the user to cancel
individual file uploads, or cancel the whole upload or move." The frontend spec cites it in six
places and states the reasoning; the acceptance criterion above is now marked deferred rather than
left looking like an undelivered promise.
The one thing kept in this ticket is the half that is not about the control: a run reaching a
cancelled state must not be reported to the author as a success. That is covered by the frontend
spec's FR-024, which reports any terminal state the client does not recognise as an error.
So the backend cancel surface does have a dated consumer (#33331), and until it arrives the client
cannot mistake a cancelled run for a completed one.
Amendment — 2026-09-07
Recorded after
/speckit-planfor the backend half, and after the review of spec PR#37358 by @wezell. These supersede the corresponding
statements above and in the 2026-09-01 amendment; everything not listed here still stands.
The backend spec, wire contract and data model are in
specs/37166-bulk-file-upload/.1. Submission shape: one call, confirmed — and it is the shipped precedent
The 2026-09-01 amendment already said "one call". It was reversed to two steps mid-review and then
restored, so this records the outcome rather than leaving the PR history as the only account.
@wezell's objection was that a second upload endpoint duplicates the temp API. Accepted: the
endpoint hands every part to
TempFileAPI.createTempFileas it reads, and stores only theresulting ids in the job parameters. It does not stage anything of its own.
Content import is the precedent, not a departure from it.
ContentImportResource:102consumesMULTIPART_FORM_DATA, andContentImportHelper:394calls the staging API internally. Anintermediate draft had described import as though the client staged; it does not.
Two findings decided it against the client-stages-first alternative:
TempFileAPI.getTempFilegates onlastModified() + TTL, per file. If theclient staged, the earliest files of a large batch could expire before the author reached submit,
and an unresolvable reference refuses the whole submission. Server-side staging starts that clock
immediately before the job is created.
written at submission time, the ceilings would be a policy about what gets processed rather
than a bound on what can be written.
Accepted cost, recorded so it is not rediscovered: no per-file upload progress and no retry of
a single failed file. A dropped connection means resending the batch.
2. Limits — supersedes §3 and §4 of the 2026-09-01 amendment
CONTENT_BULK_UPLOAD_MAX_FILES)CONTENT_BULK_UPLOAD_MAX_TOTAL_BYTES)Why a batch total after all. Without one the batch is bounded only by the file count multiplied
by a per-file ceiling that is unset by default — which is to say not bounded at all.
TEMP_RESOURCE_MAX_FILE_SIZEships as
-1, anddotmarketing-config.propertiessays so in a comment: "authenticated users areunlimited". There is also no container-level multipart limit configured. This endpoint is the
first and only bound in the path.
Why 100 and not more. The two ceilings are chosen together: at a few megabytes per file, 300
would put a realistic batch past the total, making the count cap decorative and the size cap the
one that surprises people. Under the one-call shape the count also governs how long a single
request stays open.
Why the per-file fallback is a knowing exception. Where a content type declares no
maxFileLength, a file over the fallback is rejected in a batch and accepted as a single upload.That diverges from the equivalence rule this ticket otherwise holds to. It is accepted because the
alternative — no per-file bound at all — was judged worse, and it is documented wherever the limits
are.
Three controls here are new, not one: the file count, the per-file fallback, and the batch total.
3. New: two submission-time controls the endpoint does not inherit
Not covered by any acceptance criterion above.
verifyTempResourceEnabled()andSecurityUtils.validateReferer()live onTempFileResource, noton the
TempFileAPIthis endpoint calls. No global filter supplies them for API paths —XSSPreventionWebInterceptorprotects a fixed list that does not include/api/.TEMP_RESOURCE_ENABLEDrather than writing staged content behinda switch an operator turned off
Severity, stated plainly: the classic CSRF path is already closed by
sameSiteCookies=laxincontext.xml, so this is defense in depth rather than a live exploit. It is specified because thevalue is operator-configurable and because content import omits both today — following that
precedent uncritically would reproduce the gap. Closing it there is tracked separately.
4. New: the ceilings are enforced while the request is read
The file count and the accumulated total are checked as each part is staged, aborting the read
the moment either is crossed, rather than after the whole body has arrived.
or the connection dropped — is also reclaimed. This is a different code path from a refusal:
nothing is raised by this side, so a reclaim scoped to the refusal path never runs. Nothing
purges staged content on a schedule, so what leaks here leaks permanently
A declared
totalSizeBytesmay be sent for a fast refusal before reading. It is a courtesy, neverthe enforcement point.
5. Index policy, made concrete
Above: "Files are not indexed with
WAIT_FORper item; the batch indexes once, or the job'scompletion drives the reload."
Now: each file is created with
IndexPolicy.DEFER, and the run resolves index visibilityonce at the end of the batch, before the completion signal is emitted. Both halves are needed:
WAIT_FORdoes not merely block on a refresh —indexContentListWaitForalso flushesthe system-wide query cache on every file, so a full batch charges every other user one flush per
file.
DEFERalone is insufficient: it only enqueues intodist_reindex_journal. A run reporting"finished" would hand the author files a text search cannot yet find. Per
ADR-0018
the plain folder listing is database-resolved, so the grid is fine either way — but free-text and
searchable-field criteria are index-routed, and that ADR names this exact failure as a recurring
complaint.
The frontend depends on the ordering: it refreshes its listing on the completion signal, so
emitting the signal first would surface as a client-side defect for a cause living entirely on the
server.
6. Target fields:
folderId/siteId, nottargetFolderAbove: "Multipart: the files, plus
targetFolder(or site id at the root)".Now: two explicit, mutually exclusive fields. The workflow API this feature fires already
separates
contentHost(site) fromhostFolder(folder), and Content Drive currently sends a siteid in
hostFolderat the root — the overloadingADR-0020
moves away from. Exactly one of the two must be present.
The multipart shape follows content import: repeated
filesparts plus a JSONformpart carryingbaseType,folderId|siteIdand the optionaltotalSizeBytes. A batch over the total sizeanswers
413; the count, shape and origin refusals answer400.7. New scope: a resumable run, and a safe resubmission
Not covered by any acceptance criterion above, and the largest piece of hidden work in this
ticket.
The job framework persists no mid-run per-item state:
parametersare immutable after creation,progressis a single float, andresultis harvested once at the terminal state. MeanwhileAbandonedJobDetectorre-queues a stalled run without consulting the retry policy, so aninterrupted run is retried whether or not the processor is marked no-retry.
A re-run without resume does not duplicate data — the unique index on the lower-cased path rejects
the second create — but it makes the report lie: the files the first attempt created come back
as
NAME_COLLISION, so the author is told 30 files failed when all 30 are in the folder.job_item_resulttableduplicate resubmission is distinguishable from a batch whose files genuinely all collided
The table is named for jobs, not for uploads, on purpose: #37062, #37063 and #37165 all need the
same thing, and four near-identical tables would be the wrong outcome. Whether durable per-item
state belongs in the job framework itself is being proposed as an ADR in
dotCMS/platform-adrs.8. Where the batch guarantee starts — affects a frontend acceptance criterion
Every guarantee in this ticket — surviving the author leaving, resumability, cancellation, the
durable outcome — is a property of a run, and a run does not exist until the submission is
answered with a handle.
The frontend criterion "Navigating away does not lose the batch" therefore holds after the
202, not during the upload. If the author navigates away while the bytes are still being sent,that request dies and no job is created: nothing is recorded and nobody is notified, because there
is nothing yet to record.
Agreed with @wezell on #37358 and accepted rather than engineered around. The cheap mitigation,
which the frontend half should take: dotAdmin is a single Angular application — legacy portlets are
hosted inside the same shell — so navigating between portlets is a route change and the document
survives. An upload request owned by a root-level service rather than by the Content Drive
component therefore survives that navigation at no extra cost, which the job-progress primitive
already requires. A
beforeunloadguard covers a genuine page unload. Only a Service Worker wouldsurvive the document being destroyed, and dotAdmin registers none; that is judged disproportionate.