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