|
| 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 | +} |
0 commit comments