Skip to content

Commit e10468f

Browse files
committed
1.3 webdav abstraction
1 parent 9365e2a commit e10468f

13 files changed

Lines changed: 1005 additions & 299 deletions

File tree

‎readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ is built on the one below and they compose in the same app.
146146
| **Mediator** | Shiny.Mediator handlers published as endpoints — requests as JSON, commands as a status code, stream requests as Server-Sent Events, all bound at compile time |
147147
| **DocumentDb** | A document type as a complete HTTP resource, with filtering, cursor paging, sparse fieldsets, ETag/If-Match, RFC 7396 merge-patch, a live SSE tail, and server-side scopes enforced on both sides of a write |
148148
| **gRPC** | Unary, client-streaming, server-streaming and bidirectional methods, deadlines, per-message compression and status in trailers — plus gRPC-Web for browsers and anything on HTTP/1.1. Marshalling is yours, so nothing reflects over your messages |
149-
| **WebDAV** | RFC 4918 classes 1 and 2 over a directory — `PROPFIND`, `PROPPATCH`, `MKCOL`, `COPY`, `MOVE`, `LOCK`/`UNLOCK`, the `If` header and dead properties — so an app's storage mounts as a drive with no client to write |
149+
| **WebDAV** | RFC 4918 classes 1 and 2 over a directory — or over anything an `IWebDavFileSystem` describes — `PROPFIND`, `PROPPATCH`, `MKCOL`, `COPY`, `MOVE`, `LOCK`/`UNLOCK`, the `If` header and dead properties — so an app's storage mounts as a drive with no client to write |
150150
| **Lifecycle** | Start, stop and restart at runtime, serialized and idempotent, with an observable state — an embedded server gets toggled, not just booted. It also follows the device: rebinding when the addresses change, and following the app between foreground and background |
151151
| **Resilience** | Every state change says *why* it happened and carries the exception behind it, and a server that stops serving never does it quietly. A listener that dies underneath a running server is logged, reported and rebound; a transient accept failure is retried with backoff; a restart or a rebind whose bind is refused keeps trying and, if it never succeeds, says so unmistakably rather than leaving a server that claims to be running with nothing behind it. On by default — an app that has to opt in to not-silently-dying will not have opted in |
152152
| **Discovery** | The other half of hosting on a phone. mDNS advertises the server as the device moves and withdraws it when the server stops — telling a restart apart from a stop, so peers are not made to re-resolve a service that never went away — and a publication the responder refuses is retried rather than leaving an app that runs perfectly and is found by nobody; the locator turns what is on the link into a base address a client can call |

‎skills/shiny-httpserver/SKILL.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ triggers:
222222
- WebDavMethods
223223
- WebDavHeaderNames
224224
- IWebDavPropertyStore
225+
- IWebDavFileSystem
226+
- PhysicalWebDavFileSystem
227+
- WebDavEntry
228+
- WebDavException
225229
- InMemoryWebDavPropertyStore
226230
- WebDavLock
227231
- WebDavLockScope
@@ -852,6 +856,14 @@ app.MapWebDav("/dav", o =>
852856
is the same page `shinyhttpserver` serves — say "open the mount URL in a browser" when a user asks
853857
for a UI over a directory; there is no other file-manager UI in this library.
854858
- The mount is excluded from the OpenAPI document — do not try to describe it.
859+
- **Not one directory?** Implement `IWebDavFileSystem` and set `o.FileSystem` instead of `RootPath`
860+
— several roots side by side, platform-API folders, a photo library. Paths arrive relative,
861+
`/`-separated, root `""`, already stripped of `..`/dotfiles/`Filter` misses. `WebDavEntry.Name` is
862+
the URL segment and `DisplayName` what the client shows. Refuse with
863+
`throw new WebDavException(403)`; `UnauthorizedAccessException`→403,
864+
`FileNotFound`/`DirectoryNotFound`→404, other `IOException`→409. Let the 413 the write stream throws
865+
propagate. `OpenReadAsync` returning a seekable stream wins over the entry's length — buffer
866+
anything transcoded on the fly. Do not copy the data into a temp directory to use `RootPath`.
855867

856868
## Realtime
857869

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
namespace Shiny.Net.HttpServer.WebDav;
2+
3+
/// <summary>
4+
/// What a WebDAV mount is serving: a tree of collections and files, addressed by path.
5+
/// <para>
6+
/// A mount answers the protocol - hrefs, depths, locks, <c>If</c> headers, the multistatus
7+
/// XML - and this answers what is actually there. <see cref="PhysicalWebDavFileSystem"/> is a
8+
/// directory on disk and is what <see cref="WebDavOptions.RootPath"/> builds. Implement this when
9+
/// what a mount should show is not one directory: an app whose storage is a view over several
10+
/// roots, a folder a platform only lets it reach through its own API, or something that is not
11+
/// files at all.
12+
/// </para>
13+
/// <para>
14+
/// <b>Paths</b> are relative to the mount root, separated by <c>/</c>, with no leading slash, and
15+
/// the root itself is the empty string. By the time one gets here it has been percent-decoded,
16+
/// checked for <c>.</c> and <c>..</c> segments, for dotfiles (unless
17+
/// <see cref="WebDavOptions.ServeHiddenFiles"/>) and against <see cref="WebDavOptions.Filter"/> -
18+
/// so an implementation never sees a path that climbs. What it still owns is anything only it can
19+
/// know, such as a link that leads out of whatever it treats as its root.
20+
/// </para>
21+
/// <para>
22+
/// <b>Refusing.</b> Throw <see cref="WebDavException"/> with the status the client should get.
23+
/// The mount also answers <see cref="UnauthorizedAccessException"/> with 403,
24+
/// <see cref="FileNotFoundException"/> and <see cref="DirectoryNotFoundException"/> with 404, and
25+
/// any other <see cref="IOException"/> with 409 - the ones <c>System.IO</c> throws, so an
26+
/// implementation over the file system rarely needs to translate anything.
27+
/// </para>
28+
/// <para>
29+
/// <b>Why the reads are synchronous.</b> <see cref="GetEntry"/> and <see cref="GetChildren"/> are
30+
/// asked from inside the evaluation of an <c>If</c> header, which is synchronous, and a
31+
/// <c>PROPFIND</c> asks them once per resource it describes. They are metadata, which every file
32+
/// system answers without waiting on the bytes; the members that move bytes are asynchronous.
33+
/// </para>
34+
/// </summary>
35+
public interface IWebDavFileSystem
36+
{
37+
/// <summary>
38+
/// What is at <paramref name="path"/>, or null when nothing is. Must answer for the root
39+
/// (<c>""</c>), which must be a collection.
40+
/// </summary>
41+
WebDavEntry? GetEntry(string path);
42+
43+
/// <summary>
44+
/// The members of the collection at <paramref name="path"/>, in any order - the mount sorts
45+
/// them. Each entry's <see cref="WebDavEntry.Name"/> is the segment that, appended to
46+
/// <paramref name="path"/>, addresses it.
47+
/// </summary>
48+
IEnumerable<WebDavEntry> GetChildren(string path);
49+
50+
/// <summary>
51+
/// Opens a file for reading. A seekable stream is what makes range requests work, and its
52+
/// <see cref="Stream.Length"/> is served as the <c>Content-Length</c> in preference to
53+
/// <see cref="WebDavEntry.Length"/> - so a file whose bytes are produced on demand, and whose
54+
/// size is only known once they have been, is served correctly by handing back a buffered one.
55+
/// </summary>
56+
ValueTask<Stream> OpenReadAsync(string path, CancellationToken cancellationToken);
57+
58+
/// <summary>
59+
/// Creates or replaces the file at <paramref name="path"/> with <paramref name="content"/>. The
60+
/// parent collection is known to exist. Write somewhere else first and move into place where
61+
/// that is possible: the stream is a request body, and a client that stops sending halfway
62+
/// should not leave half a file where a whole one used to be.
63+
/// <para>
64+
/// The stream enforces <see cref="WebDavOptions.MaxUploadBytes"/> itself, by throwing a
65+
/// <see cref="WebDavException"/> once it is passed - let that propagate.
66+
/// </para>
67+
/// </summary>
68+
ValueTask WriteAsync(string path, Stream content, CancellationToken cancellationToken);
69+
70+
/// <summary>Creates a collection. The parent is known to exist and the path to be free.</summary>
71+
ValueTask CreateDirectoryAsync(string path, CancellationToken cancellationToken);
72+
73+
/// <summary>Removes a file, or a collection and everything in it.</summary>
74+
ValueTask DeleteAsync(string path, CancellationToken cancellationToken);
75+
76+
/// <summary>
77+
/// Moves a file or collection. The destination's parent exists and the destination itself does
78+
/// not - an overwriting <c>MOVE</c> has already deleted it.
79+
/// </summary>
80+
ValueTask MoveAsync(string source, string destination, CancellationToken cancellationToken);
81+
82+
/// <summary>
83+
/// Copies a file or collection, on the same terms as <see cref="MoveAsync"/>.
84+
/// </summary>
85+
/// <param name="recursive">
86+
/// False only for a collection copied with <c>Depth: 0</c>, which RFC 4918 §9.8.3 defines as
87+
/// the collection without its members.
88+
/// </param>
89+
ValueTask CopyAsync(string source, string destination, bool recursive, CancellationToken cancellationToken);
90+
91+
/// <summary>
92+
/// Room left and room used where <paramref name="path"/> lives, for RFC 4331 quota - or null
93+
/// when there is no answer, and the properties are then left out.
94+
/// </summary>
95+
WebDavQuota? GetQuota(string path);
96+
}
97+
98+
/// <summary>One file or collection, as a <see cref="IWebDavFileSystem"/> describes it.</summary>
99+
/// <param name="Name">
100+
/// The path segment this entry is addressed by. Also what a client is shown, unless
101+
/// <see cref="DisplayName"/> says otherwise.
102+
/// </param>
103+
/// <param name="IsCollection">A folder, rather than a file.</param>
104+
/// <param name="Length">Bytes, for a file. Ignored for a collection.</param>
105+
/// <param name="CreatedUtc">Reported as <c>creationdate</c>.</param>
106+
/// <param name="LastModifiedUtc">Reported as <c>getlastmodified</c>, and half of the default ETag.</param>
107+
public sealed record WebDavEntry(
108+
string Name,
109+
bool IsCollection,
110+
long Length,
111+
DateTimeOffset CreatedUtc,
112+
DateTimeOffset LastModifiedUtc
113+
)
114+
{
115+
/// <summary>
116+
/// What to call it where that differs from the segment - the <c>displayname</c> property and
117+
/// the browser listing. Null uses <see cref="Name"/>.
118+
/// </summary>
119+
public string? DisplayName { get; init; }
120+
121+
/// <summary>The content type, when the file system knows better than the extension does.</summary>
122+
public string? ContentType { get; init; }
123+
124+
/// <summary>
125+
/// A quoted entity tag. Null builds one from <see cref="LastModifiedUtc"/> and
126+
/// <see cref="Length"/>, which is the shape the static file handler uses, so a file fetched over
127+
/// one and written back over the other is recognised as the same entity.
128+
/// </summary>
129+
public string? ETag { get; init; }
130+
131+
/// <summary>
132+
/// Hidden by the platform's own rules, beyond a leading dot. Treated as a dotfile is:
133+
/// left out unless <see cref="WebDavOptions.ServeHiddenFiles"/> is on.
134+
/// </summary>
135+
public bool IsHidden { get; init; }
136+
137+
/// <summary>
138+
/// A link to somewhere else in the tree. Listed like anything else, but an infinite-depth
139+
/// <c>PROPFIND</c> does not walk into it - a link to one of its own ancestors would otherwise
140+
/// be a walk that only the result cap ends.
141+
/// </summary>
142+
public bool IsLink { get; init; }
143+
}
144+
145+
/// <summary>RFC 4331 quota: what is left, and what is used, in bytes.</summary>
146+
public readonly record struct WebDavQuota(long AvailableBytes, long UsedBytes);
147+
148+
/// <summary>
149+
/// A refusal with the status the client should see, thrown from a <see cref="IWebDavFileSystem"/>.
150+
/// <para>
151+
/// The message is not sent: a WebDAV client shows the status as an error of its own, and a
152+
/// sentence written for a file system's log is not written for whoever is looking at Finder.
153+
/// </para>
154+
/// </summary>
155+
public sealed class WebDavException : Exception
156+
{
157+
public WebDavException(int statusCode, string? message = null, Exception? innerException = null)
158+
: base(message ?? $"WebDAV request refused with {statusCode}.", innerException)
159+
{
160+
this.StatusCode = statusCode;
161+
}
162+
163+
/// <summary>The HTTP status to answer with.</summary>
164+
public int StatusCode { get; }
165+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
namespace Shiny.Net.HttpServer.WebDav.Internal;
2+
3+
/// <summary>
4+
/// A request body that refuses to be read past <see cref="WebDavOptions.MaxUploadBytes"/>.
5+
/// <para>
6+
/// The limit used to be enforced by the loop that copied the body to disk. With the copy now the
7+
/// file system's, the body is what has to enforce it, and throwing is the only way a read can say
8+
/// "stop" to a copy it does not own: a short read would be taken for the end of the file, and the
9+
/// truncated upload would be moved into place as though it were whole.
10+
/// </para>
11+
/// </summary>
12+
sealed class UploadLimitStream(Stream inner, long limit) : Stream
13+
{
14+
long total;
15+
16+
public override bool CanRead => true;
17+
public override bool CanSeek => false;
18+
public override bool CanWrite => false;
19+
public override long Length => throw new NotSupportedException();
20+
21+
public override long Position
22+
{
23+
get => this.total;
24+
set => throw new NotSupportedException();
25+
}
26+
27+
public override int Read(byte[] buffer, int offset, int count)
28+
=> this.Count(inner.Read(buffer, offset, count));
29+
30+
public override int Read(Span<byte> buffer)
31+
=> this.Count(inner.Read(buffer));
32+
33+
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
34+
=> this.Count(await inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false));
35+
36+
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
37+
=> this.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
38+
39+
int Count(int read)
40+
{
41+
this.total += read;
42+
43+
if (this.total > limit)
44+
throw new WebDavException(StatusCodes.Status413PayloadTooLarge, "The upload is larger than this mount accepts.");
45+
46+
return read;
47+
}
48+
49+
public override void Flush()
50+
{
51+
}
52+
53+
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
54+
public override void SetLength(long value) => throw new NotSupportedException();
55+
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
56+
}

‎src/Shiny.Net.HttpServer.WebDav/Internal/WebDavHandler.CopyMove.cs‎

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ async ValueTask CopyOrMoveAsync(HttpContext context, bool move)
3737
return;
3838
}
3939

40-
var isCollection = Directory.Exists(source.Full);
41-
42-
if (!isCollection && !File.Exists(source.Full))
40+
if (this.Stat(source) is not { } sourceEntry)
4341
{
4442
await StatusAsync(context, StatusCodes.Status404NotFound).ConfigureAwait(false);
4543
return;
4644
}
4745

46+
var isCollection = sourceEntry.IsCollection;
47+
4848
// Moving the root would take the directory the mount is defined by with it.
4949
if (move && source.IsRoot)
5050
{
@@ -122,9 +122,7 @@ async ValueTask CopyOrMoveAsync(HttpContext context, bool move)
122122
return;
123123
}
124124

125-
var destinationParent = Path.GetDirectoryName(destination.Full);
126-
127-
if (destinationParent is null || !Directory.Exists(destinationParent))
125+
if (!this.IsCollection(destination.Parent))
128126
{
129127
await StatusAsync(context, StatusCodes.Status409Conflict).ConfigureAwait(false);
130128
return;
@@ -139,7 +137,7 @@ async ValueTask CopyOrMoveAsync(HttpContext context, bool move)
139137
if (move && !await this.CheckLockAsync(context, source, tokens, subtree: isCollection).ConfigureAwait(false))
140138
return;
141139

142-
var destinationExisted = File.Exists(destination.Full) || Directory.Exists(destination.Full);
140+
var destinationExisted = this.Stat(destination) is not null;
143141

144142
if (!await this.CheckLockAsync(context, destination, tokens, subtree: destinationExisted).ConfigureAwait(false))
145143
return;
@@ -154,10 +152,7 @@ async ValueTask CopyOrMoveAsync(HttpContext context, bool move)
154152

155153
// RFC 4918 §9.8.4: an overwriting COPY behaves as if the destination had been DELETEd
156154
// first. Doing it literally is also the only way a collection's stale members go.
157-
if (Directory.Exists(destination.Full))
158-
Directory.Delete(destination.Full, recursive: true);
159-
else
160-
File.Delete(destination.Full);
155+
await this.fileSystem.DeleteAsync(destination.Relative, context.RequestAborted).ConfigureAwait(false);
161156

162157
this.locks.ReleaseTree(destination.Relative);
163158

@@ -168,21 +163,16 @@ await this.properties
168163

169164
if (move)
170165
{
171-
if (isCollection)
172-
Directory.Move(source.Full, destination.Full);
173-
else
174-
File.Move(source.Full, destination.Full);
166+
await this.fileSystem.MoveAsync(source.Relative, destination.Relative, context.RequestAborted).ConfigureAwait(false);
175167

176168
// A lock does not travel with the resource — RFC 4918 §9.9.1.
177169
this.locks.ReleaseTree(source.Relative);
178170
}
179-
else if (isCollection)
180-
{
181-
CopyTree(source.Full, destination.Full, shallow);
182-
}
183171
else
184172
{
185-
File.Copy(source.Full, destination.Full, overwrite: false);
173+
await this.fileSystem
174+
.CopyAsync(source.Relative, destination.Relative, recursive: !(isCollection && shallow), context.RequestAborted)
175+
.ConfigureAwait(false);
186176
}
187177

188178
await this.properties
@@ -197,27 +187,6 @@ await StatusAsync(
197187
).ConfigureAwait(false);
198188
}
199189

200-
static void CopyTree(string source, string destination, bool shallow)
201-
{
202-
Directory.CreateDirectory(destination);
203-
204-
if (shallow)
205-
return;
206-
207-
foreach (var file in Directory.EnumerateFiles(source))
208-
File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true);
209-
210-
foreach (var directory in Directory.EnumerateDirectories(source))
211-
{
212-
// Not descending into a link keeps a cycle inside the root from turning a copy into an
213-
// unbounded one.
214-
if (new DirectoryInfo(directory).Attributes.HasFlag(FileAttributes.ReparsePoint))
215-
continue;
216-
217-
CopyTree(directory, Path.Combine(destination, Path.GetFileName(directory)), shallow: false);
218-
}
219-
}
220-
221190
/// <summary>Maps a <c>Destination</c> header onto a path in this mount.</summary>
222191
DestinationKind ResolveDestination(HttpContext context, string header, out DavPath path)
223192
{

0 commit comments

Comments
 (0)