WebDAV
The file browser is a JSON API you drive with curl or a client of your
own. This is the protocol every desktop operating system already has a client for: point Finder or
Windows Explorer at the URL and the apps storage appears as a drive, with no client code at all.
dotnet add package Shiny.Net.HttpServer.WebDavapp.MapWebDav("/dav", o =>{ o.RootPath = FileSystem.AppDataDirectory; o.AllowWrite = true; o.AllowDelete = true;}).RequireAuthorization();Mount it from the client side with Finder Go Connect to Server (http://device:8080/dav),
Explorer Map network drive, gio mount dav:// on Linux, or any of the client libraries. On a
phone, behind a tunnel, that is a short path from an app has files to
those files are a drive on my desktop.
The samples
Section titled The samplessamples/Sample.Api mounts one at /dav over a directory beside the binary run it and open
http://localhost:8080/dav in a browser, or mount it, without building anything of your own.
samples/Sample.Maui is the real case: the same mount over FileSystem.AppDataDirectory, behind the
apps Basic password, with the address to paste into Finder shown on the Server tab.
What it implements
Section titled What it implementsCompliance classes 1 and 2: every method RFC 4918 defines, over one directory.
| Method | Notes |
|---|---|
OPTIONS |
Answers DAV: 1, 2, MS-Author-Via: DAV and an Allow built from the options |
PROPFIND |
Depth: 0/1, allprop, propname and named prop requests, answered 207 |
PROPPATCH |
Sets and removes dead properties, atomically |
MKCOL |
Creates a collection |
GET / HEAD |
The files bytes, with byte ranges and conditional GETs |
PUT |
Writes a file |
DELETE |
Removes a file, or a collections whole subtree |
COPY / MOVE |
Destination, Overwrite, and Depth on a COPY |
LOCK / UNLOCK |
Exclusive and shared write locks, including on an unmapped URL |
Alongside them: the If header both lock tokens and entity tags, tagged and untagged lists plus
lockdiscovery, supportedlock and RFC 4331 quota properties.
The file manager
Section titled The file managerGET on a collection is not something WebDAV defines. This serves a file manager, so the first thing
anyone does with a new mount open it in a browser to see whether it works does rather more than
that:
- the listing, with sizes and modification times, a breadcrumb back to the mount root and a link to the parent collection
- upload, by drag and drop or from the file picker, one
PUTper file with a progress bar. Dropping a folder walks it and makes the collections as it goes - new folder (
MKCOL), rename (MOVE, withOverwrite: Fso a rename onto a name that is taken is refused rather than silently replacing it) and delete (DELETE) - download, on a button beside each file, while the name itself opens what it points at an image or a PDF is worth looking at
Every one of those is the mounts own verb, so the page can do exactly what the options allow and no
more: a read-only mount renders the listing with no buttons at all, AllowWrite adds uploading and
new folders, AllowDelete adds deleting, and renaming needs both. Nothing is fetched from anywhere
the page is one self-contained response with no scripts, styles or fonts from outside and the
listing is rendered server-side, so a browser with no scripting still gets every link.
The links are absolute, which means http://device:5000/dav browses just as well as
http://device:5000/dav/. Turn the whole page off with DirectoryBrowsing = false, and a browser
GET on a collection answers 405.
This is the same page shinyhttpserver serves that tool is a WebDAV mount with
a command around it.
Options
Section titled Options| Property | Default | Notes |
|---|---|---|
RootPath |
required | Everything is resolved inside it; nothing outside it is reachable |
AllowWrite |
false |
PUT, MKCOL, PROPPATCH, COPY, LOCK |
AllowDelete |
false |
DELETE. MOVE needs this and AllowWrite |
EnableLocking |
true |
Class 2. See below this one is not really optional |
DefaultLockTimeout |
5 min | When the client asked for no particular duration |
MaxLockTimeout |
1 hour | The longest this will grant, whatever was asked for |
MaxUploadBytes |
64 MB | Counted as the body streams, not taken from Content-Length |
MaxXmlBodyBytes |
1 MB | PROPFIND, PROPPATCH and LOCK bodies |
AllowInfiniteDepth |
false |
See below |
MaxPropFindResults |
50 000 | Past this a PROPFIND answers 507 |
DirectoryBrowsing |
true |
The file manager a browser GET on a collection gets |
ServeHiddenFiles |
false |
A content directory routinely holds a .env or a database journal |
Filter |
null |
Return false to hide an entry and refuse every operation on it |
DefaultContentType |
application/octet-stream |
Downloads only |
DisplayName |
null |
The root collections displayname |
PropertyStore |
in memory | Where dead properties live |
Leave locking on
Section titled Leave locking onEnableLocking defaults to on because class 2 is not really optional in practice. Finder and the
Windows redirector both mount a class 1 server read-only, whatever AllowWrite says the
DAV: 1, 2 header is what they check, and they check it once, at mount time.
Two details that follow from real clients rather than from the specifications happy path:
- A
LOCKon a URL that does not exist yet creates an empty file to hold the lock (RFC 4918 7.3) and answers 201. This is exactly what a Mac does when you save a new document; a server that answers 404 here cannot be written to from Finder at all. - Locks live in memory and belong to the mount, so they do not survive a restart. For an embedded server that is the right lifetime the process being restarted is the app.
Depth: infinity
Section titled Depth: infinityAllowInfiniteDepth is off. A PROPFIND with Depth: infinity walks a whole subtree into one
response, and RFC 4918 anticipates a server declining: the refusal is a 403 carrying
<DAV:propfind-finite-depth/>, which tells the client to ask again with a depth it can bound. Every
client that matters does.
Worth knowing, because the interaction surprises people: RFC 4918 makes a missing Depth on a
PROPFIND mean infinity, so a client that omits the header gets that same refusal. Clients send it.
DELETE, MOVE and COPY are unaffected those are recursive by definition, and are gated by
AllowWrite and AllowDelete instead.
Dead properties
Section titled Dead propertiesA PROPPATCH that sets something outside the DAV: namespace is storing a dead property opaque
XML the server keeps and hands back. There is nowhere on a file system to put those, so they go to an
IWebDavPropertyStore, which is in-memory by default. Windows Explorer sets its own file-attribute
properties on every write and macOS keeps some metadata this way, so losing them across a restart is
untidy rather than fatal. Implement the interface and assign PropertyStore to keep them.
Properties in the DAV: namespace are computed, not stored, and a PROPPATCH naming one answers 403
cannot-modify-protected-property and, because RFC 4918 makes a PROPPATCH atomic, everything
else in the same request answers 424 and nothing is written.
Authorization
Section titled AuthorizationMapWebDav returns the routes it registered twenty-two of them and fans policies across the lot,
so a verb added in a later version cannot quietly arrive unprotected.
// everything behind a policyapp.MapWebDav("/dav", ).RequireAuthorization("files");
// reads open, anything that changes something behind a policyapp.MapWebDav("/dav", ).RequireAuthorizationForChanges("editors");RequireAuthorizationForChanges counts locking as a change: a lock reserves the right to write, and
one taken anonymously is a way to stop everyone else writing.
ReadRoutes, WriteRoutes, DeleteRoutes, LockRoutes and Routes are there for anything else you
want to attach to one group, and RequireCors, RequireRateLimiting, RequireIpFilter and
WithMetadata fan out the same way.
The mount is excluded from the OpenAPI document. It is one protocol behind twenty-two routes, and RFC 4918 already documents it.
The defaults, and why
Section titled The defaults, and whyRead-only until told otherwise. AllowWrite and AllowDelete are both off. Unlike the file
browser, a DELETE here takes a collections whole subtree the protocol requires it, because a
client that drags a folder to the trash expects what is in it to go too which is one more reason
the permission is opt-in.
Uploads are bounded and atomic. MaxUploadBytes is counted as the body streams rather than
trusting Content-Length, and the bytes go to a staging file that is moved into place, so a refused
or interrupted upload leaves the previous file intact.
The XML parser is hardened. No DTD, no resolver, no entity expansion a WebDAV body arrives from the network, and an XML parser that resolves entities is the shortest route from accepts XML to reads /etc/passwd.
Containment reuses the static file handlers path normalization, checked after decoding, again
after resolving links, and again on the Destination of a COPY or MOVE which is decoded segment
by segment, so an encoded %2F stays part of a name instead of becoming a separator.
It is AOT- and trim-clean, like the rest of the repo. The XML is read and written with
XmlReader/XmlWriter; nothing reflects over anything.
This exposes a devices storage over the network, and WebDAV clients send Basic credentials on
every request. Serve it over TLS and put
authentication in front of it, and consider an
IP filter restricting it to the local network. Filter is the way to
narrow it further: an app that only means to share one folder should return false for everything else
rather than trusting the root path to stay tidy.