Skip to content

Commit 4d64882

Browse files
committed
Updates
1 parent 9b18af9 commit 4d64882

16 files changed

Lines changed: 1041 additions & 4 deletions

File tree

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10"/>
1919
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10"/>
2020
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="10.0.10"/>
21+
22+
<!-- The CLI's argument parsing. Nothing that ships as a library takes it. -->
23+
<PackageVersion Include="System.CommandLine" Version="2.0.11"/>
2124
</ItemGroup>
2225
<ItemGroup>
2326
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0"/>

Shiny.Net.HttpServer.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</Folder>
2424

2525
<Project Path="src/Shiny.Net.HttpServer.AzureRelay/Shiny.Net.HttpServer.AzureRelay.csproj" />
26+
<Project Path="src/Shiny.Net.HttpServer.CommandLine/Shiny.Net.HttpServer.CommandLine.csproj" />
2627
<Project Path="src/Shiny.Net.HttpServer.DocumentDb/Shiny.Net.HttpServer.DocumentDb.csproj" />
2728
<Project Path="src/Shiny.Net.HttpServer.Grpc/Shiny.Net.HttpServer.Grpc.csproj" />
2829
<Project Path="src/Shiny.Net.HttpServer.Jwt/Shiny.Net.HttpServer.Jwt.csproj" />

build.slnf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"src\\Shiny.Net.HttpServer.WebDav\\Shiny.Net.HttpServer.WebDav.csproj",
1313
"src\\Shiny.Net.HttpServer.DocumentDb\\Shiny.Net.HttpServer.DocumentDb.csproj",
1414
"src\\Shiny.Net.HttpServer.Mediator\\Shiny.Net.HttpServer.Mediator.csproj",
15-
"src\\Shiny.Net.HttpServer.Mediator.SourceGenerators\\Shiny.Net.HttpServer.Mediator.SourceGenerators.csproj"
15+
"src\\Shiny.Net.HttpServer.Mediator.SourceGenerators\\Shiny.Net.HttpServer.Mediator.SourceGenerators.csproj",
16+
"src\\Shiny.Net.HttpServer.CommandLine\\Shiny.Net.HttpServer.CommandLine.csproj"
1617
]
1718
}
1819
}

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
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 |
2424
| [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 |
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 and per-operation permissions |
2627

2728
## Getting Started
2829

skills/shiny-httpserver/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ triggers:
9494
- MapDocumentCollection
9595
- DocumentEndpoints
9696
- DocumentResourceBuilder
97+
- Shiny.Net.HttpServer.CommandLine
98+
- shinyhttpserver
99+
- serve a directory from the command line
97100
- Shiny.Net.HttpServer.WebDav
98101
- MapWebDav
99102
- WebDavOptions
@@ -193,8 +196,14 @@ dotnet add package Shiny.Net.HttpServer.Mediator # Shiny.Mediator handle
193196
dotnet add package Shiny.Net.HttpServer.DocumentDb # Shiny.DocumentDb types as REST resources
194197
dotnet add package Shiny.Net.HttpServer.WebDav # a directory as a WebDAV mount (RFC 4918)
195198
dotnet add package Shiny.Net.HttpServer.Grpc # gRPC + gRPC-Web services
199+
200+
dotnet tool install -g Shiny.Net.HttpServer.CommandLine # `shinyhttpserver`, not a library reference
196201
```
197202

203+
`Shiny.Net.HttpServer.CommandLine` is a .NET tool, not something an app references: it serves a
204+
directory over HTTP from a terminal (`shinyhttpserver [path] -m read|create|update|delete|all
205+
-u user:password`). Reach for it when the ask is "serve this folder", not "add a server to my app".
206+
198207
## The four tiers — the spine of this library
199208

200209
Every new API belongs to one of these. Say which when you introduce one. They compose in one app.
@@ -510,6 +519,8 @@ app.MapFileBrowser("/files", o => o.RootPath = FileSystem.AppDataDirectory).Requ
510519

511520
- Unknown file extensions are **not served** by default. Add `ContentTypeOverrides[".x"]` rather than
512521
turning on `ServeUnknownFileTypes`.
522+
- `MapFileBrowser("/", …)` mounts the browser on the whole site — the shape a "serve this directory"
523+
CLI wants. Literals still beat its catch-all, so routes mapped alongside it keep answering.
513524
- Uploads: `await foreach (var part in ctx.Request.ReadMultipartAsync(ct))` and
514525
`part.SafeFileName()` (never `part.FileName` — traversal). `ReadFormAsync` buffers; use it only for
515526
small fields.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Parsing;
3+
using System.Net;
4+
5+
namespace Shiny.Net.HttpServer.CommandLine;
6+
7+
8+
/// <summary>
9+
/// The command line surface. Everything the server needs is decided here so that
10+
/// <see cref="Runner"/> only ever deals with a validated <see cref="ServeSettings"/>.
11+
/// </summary>
12+
public static class Cli
13+
{
14+
const long DefaultMaxUpload = 64 * 1024 * 1024;
15+
16+
public static RootCommand Build(Func<ServeSettings, CancellationToken, Task<int>> run)
17+
{
18+
var pathArg = new Argument<string>("path")
19+
{
20+
Description = "Directory to serve. Defaults to the current directory.",
21+
Arity = ArgumentArity.ZeroOrOne,
22+
DefaultValueFactory = _ => "."
23+
};
24+
25+
var portOpt = new Option<int>("--port", "-p")
26+
{
27+
Description = "Port to listen on.",
28+
DefaultValueFactory = _ => 8080
29+
};
30+
31+
var addressOpt = new Option<IPAddress>("--address", "-a")
32+
{
33+
Description = "Address to bind: an IP, 'any' (all interfaces) or 'localhost'.",
34+
HelpName = "address",
35+
DefaultValueFactory = _ => IPAddress.Loopback,
36+
CustomParser = ParseAddress
37+
};
38+
39+
var prefixOpt = new Option<string>("--prefix")
40+
{
41+
Description = "URL prefix the browser is mounted at.",
42+
DefaultValueFactory = _ => "/"
43+
};
44+
45+
var allowOpt = new Option<Permissions>("--allow", "-m")
46+
{
47+
Description = "Operations to allow: read, create, update, delete, all. Repeatable or comma separated. Read is always allowed.",
48+
HelpName = "read|create|update|delete|all",
49+
Arity = ArgumentArity.ZeroOrMore,
50+
AllowMultipleArgumentsPerToken = true,
51+
DefaultValueFactory = _ => Permissions.Read,
52+
CustomParser = ParsePermissions
53+
};
54+
55+
var userOpt = new Option<BasicUser[]>("--user", "-u")
56+
{
57+
Description = "Enables basic auth with user:password. Repeat for more than one user.",
58+
HelpName = "user:password",
59+
Arity = ArgumentArity.ZeroOrMore,
60+
AllowMultipleArgumentsPerToken = true,
61+
DefaultValueFactory = _ => [],
62+
CustomParser = ParseUsers
63+
};
64+
65+
var realmOpt = new Option<string>("--realm")
66+
{
67+
Description = "Basic auth realm shown in the browser prompt.",
68+
DefaultValueFactory = _ => "shinyhttpserver"
69+
};
70+
71+
var authChangesOpt = new Option<bool>("--auth-changes-only")
72+
{
73+
Description = "Leaves reads open and only requires a login for create/update/delete."
74+
};
75+
76+
var insecureAuthOpt = new Option<bool>("--allow-insecure-auth")
77+
{
78+
Description = "Allows basic auth over unencrypted, non-loopback connections. The password crosses the network in the clear on every request."
79+
};
80+
81+
var httpsOpt = new Option<bool>("--https")
82+
{
83+
Description = "Serves over HTTPS with a self-signed certificate generated at startup. Clients will warn about it."
84+
};
85+
86+
var hiddenOpt = new Option<bool>("--hidden")
87+
{
88+
Description = "Includes dotfiles and hidden files in listings and downloads."
89+
};
90+
91+
var maxUploadOpt = new Option<long>("--max-upload")
92+
{
93+
Description = "Largest accepted upload, e.g. 500k, 64mb, 2gb.",
94+
HelpName = "size",
95+
DefaultValueFactory = _ => DefaultMaxUpload,
96+
CustomParser = ParseSize
97+
};
98+
99+
var verboseOpt = new Option<bool>("--verbose", "-v")
100+
{
101+
Description = "Logs every request."
102+
};
103+
104+
var root = new RootCommand("Serves a directory over HTTP with the Shiny.Net.HttpServer file browser.")
105+
{
106+
pathArg,
107+
portOpt,
108+
addressOpt,
109+
prefixOpt,
110+
allowOpt,
111+
userOpt,
112+
realmOpt,
113+
authChangesOpt,
114+
insecureAuthOpt,
115+
httpsOpt,
116+
hiddenOpt,
117+
maxUploadOpt,
118+
verboseOpt
119+
};
120+
121+
root.SetAction((parseResult, ct) =>
122+
{
123+
var settings = new ServeSettings
124+
{
125+
RootPath = Path.GetFullPath(parseResult.GetRequiredValue(pathArg)),
126+
Address = parseResult.GetRequiredValue(addressOpt),
127+
Port = parseResult.GetRequiredValue(portOpt),
128+
UrlPrefix = NormalizePrefix(parseResult.GetRequiredValue(prefixOpt)),
129+
Permissions = parseResult.GetRequiredValue(allowOpt) | Permissions.Read,
130+
Users = parseResult.GetRequiredValue(userOpt),
131+
Realm = parseResult.GetRequiredValue(realmOpt),
132+
AuthChangesOnly = parseResult.GetValue(authChangesOpt),
133+
AllowInsecureAuth = parseResult.GetValue(insecureAuthOpt),
134+
UseHttps = parseResult.GetValue(httpsOpt),
135+
ServeHidden = parseResult.GetValue(hiddenOpt),
136+
MaxUploadBytes = parseResult.GetRequiredValue(maxUploadOpt),
137+
Verbose = parseResult.GetValue(verboseOpt)
138+
};
139+
return run(settings, ct);
140+
});
141+
return root;
142+
}
143+
144+
145+
/// <summary>A prefix is a route, so it needs a leading slash and no trailing one.</summary>
146+
static string NormalizePrefix(string prefix)
147+
{
148+
var value = prefix.Trim();
149+
if (value.Length == 0 || value == "/")
150+
return "/";
151+
152+
if (!value.StartsWith('/'))
153+
value = "/" + value;
154+
155+
return value.TrimEnd('/');
156+
}
157+
158+
159+
static IPAddress ParseAddress(ArgumentResult result)
160+
{
161+
var value = result.Tokens[0].Value;
162+
switch (value.ToLowerInvariant())
163+
{
164+
case "any":
165+
case "all":
166+
return IPAddress.Any;
167+
168+
case "localhost":
169+
case "loopback":
170+
return IPAddress.Loopback;
171+
}
172+
173+
if (IPAddress.TryParse(value, out var address))
174+
return address;
175+
176+
result.AddError($"'{value}' is not an IP address. Use an IP, 'any' or 'localhost'.");
177+
return IPAddress.Loopback;
178+
}
179+
180+
181+
static Permissions ParsePermissions(ArgumentResult result)
182+
{
183+
var permissions = Permissions.Read;
184+
185+
foreach (var token in result.Tokens)
186+
{
187+
foreach (var raw in token.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
188+
{
189+
switch (raw.ToLowerInvariant())
190+
{
191+
case "read":
192+
break;
193+
194+
case "create":
195+
permissions |= Permissions.Create;
196+
break;
197+
198+
case "update":
199+
permissions |= Permissions.Update;
200+
break;
201+
202+
case "delete":
203+
permissions |= Permissions.Delete;
204+
break;
205+
206+
case "all":
207+
permissions |= Permissions.Create | Permissions.Update | Permissions.Delete;
208+
break;
209+
210+
default:
211+
result.AddError($"'{raw}' is not an operation. Use read, create, update, delete or all.");
212+
break;
213+
}
214+
}
215+
}
216+
return permissions;
217+
}
218+
219+
220+
static BasicUser[] ParseUsers(ArgumentResult result)
221+
{
222+
var users = new List<BasicUser>();
223+
224+
foreach (var token in result.Tokens)
225+
{
226+
var index = token.Value.IndexOf(':');
227+
if (index < 1 || index == token.Value.Length - 1)
228+
{
229+
result.AddError($"'{token.Value}' is not a credential. Use user:password.");
230+
continue;
231+
}
232+
users.Add(new BasicUser(token.Value[..index], token.Value[(index + 1)..]));
233+
}
234+
return users.ToArray();
235+
}
236+
237+
238+
static long ParseSize(ArgumentResult result)
239+
{
240+
var value = result.Tokens[0].Value.Trim().ToLowerInvariant();
241+
var multiplier = 1L;
242+
243+
foreach (var (suffix, scale) in new[] { ("gb", 1024L * 1024 * 1024), ("mb", 1024L * 1024), ("kb", 1024L), ("g", 1024L * 1024 * 1024), ("m", 1024L * 1024), ("k", 1024L), ("b", 1L) })
244+
{
245+
if (value.EndsWith(suffix))
246+
{
247+
multiplier = scale;
248+
value = value[..^suffix.Length].Trim();
249+
break;
250+
}
251+
}
252+
253+
if (Int64.TryParse(value, out var number) && number > 0)
254+
return number * multiplier;
255+
256+
result.AddError($"'{result.Tokens[0].Value}' is not a size. Use bytes or a suffix like 500k, 64mb, 2gb.");
257+
return DefaultMaxUpload;
258+
}
259+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace Shiny.Net.HttpServer.CommandLine;
2+
3+
/// <summary>
4+
/// What the file browser is allowed to do. Read is always on - a server that cannot
5+
/// be read from is not one worth starting.
6+
/// </summary>
7+
[Flags]
8+
public enum Permissions
9+
{
10+
Read = 1,
11+
Create = 2,
12+
Update = 4,
13+
Delete = 8
14+
}
15+
16+
17+
public static class PermissionsExtensions
18+
{
19+
public static bool Has(this Permissions permissions, Permissions flag)
20+
=> (permissions & flag) == flag;
21+
22+
/// <summary>Anything that changes the disk. Drives the "you are exposed" warning.</summary>
23+
public static bool AllowsChanges(this Permissions permissions)
24+
=> permissions.Has(Permissions.Create) ||
25+
permissions.Has(Permissions.Update) ||
26+
permissions.Has(Permissions.Delete);
27+
28+
public static string Describe(this Permissions permissions)
29+
=> String.Join(
30+
", ",
31+
Enum.GetValues<Permissions>()
32+
.Where(x => permissions.Has(x))
33+
.Select(x => x.ToString().ToLowerInvariant())
34+
);
35+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System.CommandLine;
2+
using Shiny.Net.HttpServer.CommandLine;
3+
4+
return await Cli
5+
.Build(Runner.RunAsync)
6+
.Parse(args)
7+
.InvokeAsync()
8+
.ConfigureAwait(false);

0 commit comments

Comments
 (0)