Skip to content

Commit 3c0485c

Browse files
committed
Updates
1 parent 58dda83 commit 3c0485c

14 files changed

Lines changed: 909 additions & 79 deletions

File tree

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
2121
| [Shiny.Net.HttpServer.Mcp](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mcp) | Model Context Protocol (Streamable HTTP) transport — host an MCP server without ASP.NET Core, including inside a MAUI app |
2222
| [Shiny.Net.HttpServer.Mediator](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mediator) | Publishes Shiny.Mediator requests, commands and streams as endpoints generated at compile time. Generator included |
2323
| [Shiny.Net.HttpServer.DocumentDb](https://www.nuget.org/packages/Shiny.Net.HttpServer.DocumentDb) | Publishes a Shiny.DocumentDb type as a REST resource — list, by-id, count, CRUD, merge-patch and a live SSE tail |
24-
| [Shiny.Net.HttpServer.WebDav](https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav) | A WebDAV (RFC 4918) class 1 & 2 server over a directory — mount an app's storage in Finder, Windows Explorer or any WebDAV client |
24+
| [Shiny.Net.HttpServer.WebDav](https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav) | A WebDAV (RFC 4918) class 1 & 2 server over a directory — mount an app's storage in Finder, Windows Explorer or any WebDAV client, and open the same URL in a browser for a file manager with upload, rename and delete |
2525
| [Shiny.Net.HttpServer.Grpc](https://www.nuget.org/packages/Shiny.Net.HttpServer.Grpc) | gRPC and gRPC-Web — unary, streaming and bidirectional methods over the same HTTP/2 stack, with serialization you supply |
26-
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over HTTP with the file browser, with basic auth, per-operation permissions, and a QR code in the banner so a phone can scan its way in — `--tunnel` swaps the LAN address for a public pinggy.io tunnel so the phone need not be on the same network |
26+
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over WebDAV, so one address is both a browser file manager (browse, upload, rename, delete) and a drive Finder or Explorer can mount, with basic auth, per-operation permissions, and a QR code in the banner so a phone can scan its way in — `--tunnel` swaps the LAN address for a public pinggy.io tunnel so the phone need not be on the same network |
2727

2828
## Getting Started
2929

samples/Sample.Api/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@
216216
// Connect to Server) or Explorer (Map network drive) at http://localhost:8080/dav and the folder
217217
// opens as a drive — no client to write, and no client to install.
218218
//
219-
// A browser GET of the same URL shows a plain HTML index, which is the quickest way to see it
219+
// A browser GET of the same URL shows a file manager - listing, upload, new folder, rename, delete,
220+
// each offered only when the options above allow it - which is the quickest way to see the mount
220221
// working without mounting anything.
221222
app.MapWebDav("/dav", o =>
222223
{

samples/Sample.Maui/MauiProgram.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ public static MauiApp CreateMauiApp()
131131
// /files is a JSON API for a script; this is the protocol a desktop already speaks,
132132
// so the phone's storage opens as a drive in Finder or Windows Explorer with nothing
133133
// installed on either end. Point them at http://<device>:<port>/dav and sign in with
134-
// the same account as everything else.
134+
// the same account as everything else - or open that URL in a browser, where the
135+
// same mount answers with a file manager: the phone's storage, uploaded to and
136+
// deleted from, with no app on the other machine at all.
135137
//
136138
// Locking is left on, and that is not a detail: Finder and the Windows redirector
137139
// both check for DAV class 2 at mount time and mount read-only without it, however

skills/shiny-httpserver/SKILL.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ triggers:
9797
- Shiny.Net.HttpServer.CommandLine
9898
- shinyhttpserver
9999
- serve a directory from the command line
100+
- file manager in a browser
101+
- mount a folder as a drive
100102
- QR code in the terminal
101103
- open a served directory on a phone
102104
- share a folder over the internet
@@ -208,6 +210,13 @@ dotnet tool install -g Shiny.Net.HttpServer.CommandLine # `shinyhttpserver`, no
208210
`Shiny.Net.HttpServer.CommandLine` is a .NET tool, not something an app references: it serves a
209211
directory over HTTP from a terminal (`shinyhttpserver [path] -m read|create|update|delete|all
210212
-u user:password`). Reach for it when the ask is "serve this folder", not "add a server to my app".
213+
It mounts the directory over **WebDAV**, so one address is both a browser file manager (browse,
214+
upload, rename, delete — whatever `-m` allows) and a drive Finder, Explorer or a Linux file manager
215+
can mount. Scripting it is WebDAV: `GET`/`PUT`/`DELETE` as usual, `MKCOL` for a directory, `MOVE`
216+
for a rename, and `PROPFIND` with `Depth: 1` for a machine-readable listing — a `GET` on a directory
217+
returns the manager's HTML, not JSON. `-m` is enforced before the handler runs, across `MKCOL`,
218+
`COPY` and `MOVE` as well as `PUT`; a `MOVE` is judged by where it lands, and renaming needs
219+
`update` **and** `delete`.
211220
It listens on every interface by default and ends its banner with a scannable QR code of the LAN
212221
address plus the URL in full, so "get this folder onto my phone" is the tool answer, not code —
213222
`-a localhost` keeps it to the machine, `--no-qr` drops the code. Basic auth (`-u`) over plain HTTP
@@ -547,8 +556,9 @@ app.MapFileBrowser("/files", o => o.RootPath = FileSystem.AppDataDirectory).Requ
547556

548557
`MapFileBrowser` is a JSON API you drive with curl or your own client. `MapWebDav` speaks the
549558
protocol the operating system already has a client for, so the folder mounts as a drive with no
550-
client code at all. Reach for it whenever the user says *mount*, *Finder*, *Explorer*, *map a
551-
network drive*, or *WebDAV*; reach for the file browser when they want an API.
559+
client code at all — and its browser `GET` is a working file manager. Reach for it whenever the user
560+
says *mount*, *Finder*, *Explorer*, *map a network drive*, *WebDAV*, or wants a **UI** over a
561+
directory; reach for the file browser when they want a JSON API to call from their own code.
552562

553563
Tier 1: a mounted module of raw routes, twenty-two of them, mapped in one call.
554564

@@ -571,9 +581,15 @@ app.MapWebDav("/dav", o =>
571581
small tree.
572582
- Dead properties (`PROPPATCH`) are held in memory. Assign `PropertyStore` to keep them across
573583
restarts.
574-
- A browser `GET` on a collection returns an HTML listing whose entries — and the link back to the
575-
parent — are absolute, so the tree is walkable from a plain browser with or without a trailing
576-
slash on the mount URL. `DirectoryBrowsing = false` turns it off.
584+
- A browser `GET` on a collection returns a **file manager**: listing with sizes and times,
585+
breadcrumbs, drag-and-drop upload (`PUT`, folders walked and recreated), new folder (`MKCOL`),
586+
rename (`MOVE`, `Overwrite: F`) and delete (`DELETE`), plus a download button per file. It offers
587+
only what the options allow — read-only renders a listing with no buttons — and it is one
588+
self-contained response with nothing fetched from outside. Entries and the link to the parent are
589+
absolute, so the tree is walkable with or without a trailing slash on the mount URL.
590+
`DirectoryBrowsing = false` turns the page off entirely (a browser `GET` then answers 405). This
591+
is the same page `shinyhttpserver` serves — say "open the mount URL in a browser" when a user asks
592+
for a UI over a directory; there is no other file-manager UI in this library.
577593
- The mount is excluded from the OpenAPI document — do not try to describe it.
578594

579595
## Realtime

src/Shiny.Net.HttpServer.CommandLine/Cli.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>
3838

3939
var prefixOpt = new Option<string>("--prefix")
4040
{
41-
Description = "URL prefix the browser is mounted at.",
41+
Description = "URL prefix the directory is mounted at.",
4242
DefaultValueFactory = _ => "/"
4343
};
4444

@@ -117,7 +117,7 @@ public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>
117117
Description = "Logs every request."
118118
};
119119

120-
var root = new RootCommand("Serves a directory over HTTP with the Shiny.Net.HttpServer file browser.")
120+
var root = new RootCommand("Serves a directory over HTTP: a file manager in a browser, and a WebDAV drive in Finder or Explorer.")
121121
{
122122
pathArg,
123123
portOpt,

src/Shiny.Net.HttpServer.CommandLine/Permissions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace Shiny.Net.HttpServer.CommandLine;
22

33
/// <summary>
4-
/// What the file browser is allowed to do. Read is always on - a server that cannot
4+
/// What the mount is allowed to do. Read is always on - a server that cannot
55
/// be read from is not one worth starting.
66
/// </summary>
77
[Flags]

src/Shiny.Net.HttpServer.CommandLine/Runner.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
using System.Security.Cryptography.X509Certificates;
66
using Microsoft.Extensions.DependencyInjection;
77
using Microsoft.Extensions.Logging;
8-
using Shiny.Net.HttpServer.FileBrowser;
98
using Shiny.Net.HttpServer.Security;
109
using Shiny.Net.HttpServer.Ssh;
10+
using Shiny.Net.HttpServer.WebDav;
1111

1212
namespace Shiny.Net.HttpServer.CommandLine;
1313

@@ -78,26 +78,34 @@ public static async Task<int> RunAsync(ServeSettings settings, CancellationToken
7878
if (settings.Verbose)
7979
server.Use(LogRequestAsync);
8080

81-
// the browser has one write flag, so create/update only differ if something checks first
81+
// the mount has one write flag, so create/update only differ if something checks first
8282
if (NeedsWriteGuard(settings.Permissions))
8383
server.Use(new WriteGuard(prefix, settings.RootPath, settings.Permissions));
8484

85-
var endpoints = server.MapFileBrowser(prefix, o =>
85+
// WebDAV rather than the JSON file browser, because it is two things at once: the file
86+
// manager a browser gets on GET, and a drive Finder, Explorer and the Linux file managers
87+
// can mount at the same address - which is the shortest path from "a directory on this
88+
// machine" to "a folder on that one".
89+
var mount = server.MapWebDav(prefix, o =>
8690
{
8791
o.RootPath = settings.RootPath;
8892
o.AllowWrite = settings.Permissions.Has(Permissions.Create) || settings.Permissions.Has(Permissions.Update);
89-
o.AllowCreateDirectories = settings.Permissions.Has(Permissions.Create);
9093
o.AllowDelete = settings.Permissions.Has(Permissions.Delete);
9194
o.ServeHiddenFiles = settings.ServeHidden;
9295
o.MaxUploadBytes = settings.MaxUploadBytes;
96+
// The directory's own name, which is what the manager's breadcrumb and a mounted drive
97+
// are labelled with. A root directory has no name, and there the mount's own default
98+
// is the better answer than an empty label.
99+
if (Path.GetFileName(Path.TrimEndingDirectorySeparator(settings.RootPath)) is { Length: > 0 } name)
100+
o.DisplayName = name;
93101
});
94102

95103
if (settings.AuthEnabled)
96104
{
97105
if (settings.AuthChangesOnly)
98-
endpoints.RequireAuthorizationForChanges();
106+
mount.RequireAuthorizationForChanges();
99107
else
100-
endpoints.RequireAuthorization();
108+
mount.RequireAuthorization();
101109
}
102110

103111
// mounted anywhere else the site root is a 404, which is a worse answer than the listing
@@ -276,6 +284,7 @@ static void PrintBanner(ServeSettings settings, string prefix, string? tunnelUrl
276284
Line("Tunnel", TunnelUrl(tunnelUrl, prefix));
277285

278286
Line("Operations", settings.Permissions.Describe());
287+
Line("Mount", "WebDAV - Finder, Explorer and any WebDAV client can open the URL as a drive");
279288
Line(
280289
"Auth",
281290
settings.AuthEnabled

src/Shiny.Net.HttpServer.CommandLine/ServeSettings.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public sealed record ServeSettings
1111
public required IPAddress Address { get; init; }
1212
public required int Port { get; init; }
1313

14-
/// <summary>Where the browser is mounted - "/" serves the directory at the root of the site.</summary>
14+
/// <summary>Where the directory is mounted - "/" serves it at the root of the site.</summary>
1515
public required string UrlPrefix { get; init; }
1616

1717
public required Permissions Permissions { get; init; }

src/Shiny.Net.HttpServer.CommandLine/Shiny.Net.HttpServer.CommandLine.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
55
<RootNamespace>Shiny.Net.HttpServer.CommandLine</RootNamespace>
6-
<Description>Serves a directory over HTTP from the command line with the Shiny.Net.HttpServer file browser: basic auth, per-operation permissions (read/create/update/delete) and optional TLS. Installs as a .NET tool.</Description>
7-
<PackageTags>$(PackageTags) cli dotnet-tool filebrowser</PackageTags>
6+
<Description>Serves a directory over HTTP from the command line: a browser file manager with upload, rename and delete, and the same address mounts as a WebDAV drive in Finder or Explorer. Basic auth, per-operation permissions (read/create/update/delete), optional TLS and a public tunnel. Installs as a .NET tool.</Description>
7+
<PackageTags>$(PackageTags) cli dotnet-tool filebrowser filemanager webdav</PackageTags>
88

99
<!-- The command is the product here, so the assembly carries the command's name — which means
1010
the package id has to be stated rather than inherited from it. -->
@@ -23,6 +23,7 @@
2323
<ItemGroup>
2424
<ProjectReference Include="../Shiny.Net.HttpServer/Shiny.Net.HttpServer.csproj" />
2525
<ProjectReference Include="../Shiny.Net.HttpServer.Ssh/Shiny.Net.HttpServer.Ssh.csproj" />
26+
<ProjectReference Include="../Shiny.Net.HttpServer.WebDav/Shiny.Net.HttpServer.WebDav.csproj" />
2627
</ItemGroup>
2728

2829
<ItemGroup>

src/Shiny.Net.HttpServer.CommandLine/WriteGuard.cs

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
21
namespace Shiny.Net.HttpServer.CommandLine;
32

43

54
/// <summary>
6-
/// Splits the file browser's single PUT into create and update.
5+
/// Splits the mount's single write switch into create and update.
6+
/// <para>
7+
/// WebDAV has one permission for writing, so create and update are the same <c>PUT</c> to it -
8+
/// and the same <c>MKCOL</c>, <c>COPY</c> and <c>MOVE</c>. Asking the file system whether the
9+
/// target already exists is the only thing that tells them apart, and it has to happen before the
10+
/// handler runs: after that the file is already written.
11+
/// </para>
712
/// <para>
8-
/// The browser has one write switch, so create and update are the same route to it. Asking the
9-
/// file system whether the target already exists is the only thing that tells them apart, and it
10-
/// has to happen before the handler runs - after that the file is already written.
13+
/// Deleting needs none of this. It has a permission of its own on the mount, so it never reaches
14+
/// here.
1115
/// </para>
1216
/// </summary>
1317
public sealed class WriteGuard(string prefix, string rootPath, Permissions permissions) : IHttpMiddleware
@@ -32,32 +36,85 @@ await context.Response
3236
}
3337

3438

35-
/// <summary>Null when this request is not a write, or when the path is one the browser will reject anyway.</summary>
39+
/// <summary>Null when this request is not a write, or when the path is one the mount will reject anyway.</summary>
3640
Permissions? RequiredPermission(HttpRequest request)
3741
{
38-
if (!String.Equals(request.Method, "PUT", StringComparison.OrdinalIgnoreCase))
39-
return null;
42+
// PROPPATCH and LOCK are left out on purpose. Neither changes a file's contents, and both
43+
// are sent by Finder and by the Windows redirector around an ordinary upload - refusing
44+
// them on a create-only server would break the upload the server does allow.
45+
switch (request.Method.ToUpperInvariant())
46+
{
47+
case "PUT":
48+
return this.ForTarget(request.Path);
49+
50+
// A collection either exists - in which case MKCOL is the mount's own 405 - or is about
51+
// to, which is a create however the server is configured.
52+
case "MKCOL":
53+
return Permissions.Create;
4054

41-
if (!request.Path.StartsWith(this.pathPrefix, StringComparison.Ordinal))
55+
// Both land bytes at the destination, and the destination is what decides which write
56+
// it is. MOVE also removes the source, which the mount's delete permission gates.
57+
case "COPY":
58+
case "MOVE":
59+
return request.Headers.GetFirst("Destination") is { Length: > 0 } destination
60+
? this.ForTarget(DestinationPath(destination))
61+
: null;
62+
63+
default:
64+
return null;
65+
}
66+
}
67+
68+
69+
/// <summary>What writing to this path would be: replacing something, or making it.</summary>
70+
Permissions? ForTarget(string path)
71+
{
72+
if (!path.StartsWith(this.pathPrefix, StringComparison.Ordinal))
4273
return null;
4374

44-
var relative = request.Path[this.pathPrefix.Length..];
75+
var relative = path[this.pathPrefix.Length..];
4576
if (relative.Length == 0)
4677
return null;
4778

48-
// a trailing slash is the browser's directory create, never an overwrite
79+
// a trailing slash names a collection, which is only ever created
4980
if (relative.EndsWith('/'))
5081
return Permissions.Create;
5182

5283
var target = this.Resolve(relative);
5384
if (target == null)
5485
return null;
5586

56-
return File.Exists(target) ? Permissions.Update : Permissions.Create;
87+
return File.Exists(target) || Directory.Exists(target) ? Permissions.Update : Permissions.Create;
88+
}
89+
90+
91+
/// <summary>
92+
/// The path part of a <c>Destination</c>, which RFC 4918 allows to be a full URL. Percent
93+
/// decoded, because that is the form the request path arrives in and the two are compared.
94+
/// </summary>
95+
static string DestinationPath(string destination)
96+
{
97+
var value = destination.Trim();
98+
99+
if (Uri.TryCreate(value, UriKind.Absolute, out var absolute))
100+
value = absolute.AbsolutePath;
101+
102+
var query = value.IndexOfAny(['?', '#']);
103+
if (query >= 0)
104+
value = value[..query];
105+
106+
try
107+
{
108+
return Uri.UnescapeDataString(value);
109+
}
110+
catch (UriFormatException)
111+
{
112+
return value;
113+
}
57114
}
58115

59116

60-
/// <summary>The full path, or null when it escapes the root and the browser's own check will refuse it.</summary>
117+
/// <summary>The full path, or null when it escapes the root and the mount's own check will refuse it.</summary>
61118
string? Resolve(string relative)
62119
{
63120
try

0 commit comments

Comments
 (0)