Folder uploads that keep their structure
Browsers can hand you an entire directory tree - but most upload stacks flatten it into a pile of file names. This guide covers how folder selection actually works, where the structure gets lost, and how to preserve and recreate it safely.
Two ways a browser delivers a folder
- The directory picker. An
<input type="file" webkitdirectory>opens a folder dialog; every file arrives with awebkitRelativePathlikephotos/2026/beach.jpg. - Drag-and-drop traversal. A dropped folder shows up as a
DataTransferItemwhosewebkitGetAsEntry()yields a directory entry. The uploader walks it recursively withcreateReader().readEntries(); each file entry carries afullPathsuch as/photos/2026/beach.jpg.
Both paths exist only at selection time. The moment a plain upload request is built, the standard multipart/form-data body carries a bare filename - and the structure is gone. That is why "folder upload" support in many libraries really means "we enumerate the files"; the tree itself never reaches the server.
Preserving the path end to end
CoreUpload captures the relative path at selection time and carries it the whole way:
// Client - nothing to configure; folder drops and directory picks
// populate task.relativePath automatically ('' for plain files).
CoreUpload.create('#uploader', {
uploadUrl: '/api/upload/upload',
webkitdirectory: true,
onTaskComplete: function (task) {
console.log(task.relativePath); // 'photos/2026/beach.jpg'
}
}); On the wire it travels as the relativePath form field (single uploads, with an X-Upload-Relative-Path header mirror) and inside the chunk-complete body for chunked transfers. Server-side it is sanitized and stored on the upload's metadata as UploadedFileInfo.RelativePath, and raised on FileUploadedEvent.RelativePath.
The part everyone gets wrong: sanitizing
A client-supplied path is attacker-controlled text. Combine it into a destination naively and ../../web.config is a valid "relative path". CoreUpload sanitizes once, at the door, before the value enters the system:
- Separators normalized to
/; empty,.and..segments dropped. - Invalid file-name characters (plus
:, soC:cannot address a drive) replaced with_; trailing dots and spaces stripped per segment. - Oversized input and absurd nesting rejected outright; a result with no real structure (a bare file name) is treated as absent.
Recreating the tree is then one line - still under a fixed root you choose:
public async Task OnFileUploadedAsync(FileUploadedEvent e, CancellationToken ct)
{
var relative = e.RelativePath ?? e.FileName; // sanitized already
var destination = Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
// copy from the temp store to destination...
} See it live
The Folder Upload demo lists each completed file's preserved path as it lands. Drop a nested folder and compare the list against your disk.