Skip to content

Commit e43b6ae

Browse files
committed
Updates
1 parent 65d554c commit e43b6ae

22 files changed

Lines changed: 820 additions & 52 deletions

File tree

‎readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ is built on the one below and they compose in the same app.
147147
| **Diagnostics** | Health checks with liveness/readiness tags, telemetry on the in-box primitives — one `Activity` per request continuing the caller's `traceparent`, and the OpenTelemetry HTTP metrics an ASP.NET dashboard already reads — and W3C access logs, rolled and pruned, written off the request path |
148148
| **Formats** | Content negotiation in both directions — responses chosen from `Accept`, request bodies from `Content-Type`. JSON out of the box; XML, MessagePack and protobuf are one line each, and a format of your own is an `IOutputFormatter`/`IInputFormatter` pair. XML and MessagePack need no dependency and no attributes on your DTOs: they read the same `JsonTypeInfo` the JSON path reads, which is what keeps them AOT-clean where `XmlSerializer` cannot be |
149149
| **Protocols** | HTTP/1.1, HTTP/2 (own HPACK), HTTP/3 (own QPACK), WebSockets, Server-Sent Events, trailing headers on all three versions, 103 Early Hints and other 1xx interim responses on all three. Never guessed — ALPN over TLS, connection preface over cleartext. WebSockets carry permessage-deflate, keepalive pings, and a registry for broadcasting to a group |
150-
| **Content** | Static files from disk, embedded resources *or* a zip archive (on disk or embedded), a published Blazor WebAssembly app, streaming multipart uploads, downloads with byte ranges and conditional GETs, a file browser over a directory, and brotli/gzip/deflate compression in both directions, and RFC 9530 Content-Digest/Repr-Digest verified on uploads as they stream and sent on responses as a header or a trailer |
150+
| **Content** | Static files from disk, embedded resources *or* a zip archive (on disk or embedded), a published Blazor WebAssembly app, streaming multipart uploads with a request body limit per endpoint (a large-upload route stays open while the rest of the server keeps a small default), downloads with byte ranges and conditional GETs, a file browser over a directory, and brotli/gzip/deflate compression in both directions, and RFC 9530 Content-Digest/Repr-Digest verified on uploads as they stream and sent on responses as a header or a trailer |
151151
| **Security** | Authentication and authorization split ASP.NET-style, with Basic, API key, cookie and JWT schemes; policies, roles and claims; CORS, rate limiting and IP filtering, all with per-endpoint policies; host filtering against DNS rebinding; webhook receivers that verify GitHub, Stripe, Slack, Standard Webhooks and generic HMAC signatures over the raw body, with replay protection, duplicate suppression and key rotation, plus a signer for outgoing webhooks; signed double-submit antiforgery, the browser security headers, HSTS and an HTTPS redirect |
152152
| **TLS** | Several endpoints with per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for the app's own `HttpClient`; the PROXY protocol (v1 and v2) per endpoint, so the real client behind a TCP load balancer reaches the IP filter, rate limiter and logs; and publicly trusted certificates issued and renewed automatically over ACME (Let's Encrypt, ZeroSSL) and hot-swapped with no restart |
153153
| **OpenAPI** | An OpenAPI 3.0.3 document built entirely from compile-time metadata and your `JsonSerializerContext` — no reflection, no document object model — one document per API version, and a Scalar API reference page that the browser loads from the CDN, so the app ships no UI |

‎skills/shiny-httpserver/SKILL.md‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ triggers:
8989
- UseRequestTimeouts
9090
- RequestTimeout
9191
- DisableRequestTimeout
92+
- MaxRequestBodySize
93+
- MaxBodySize
94+
- RequestSizeLimit
95+
- DisableRequestSizeLimit
96+
- WithRequestSizeLimit
97+
- 413 Payload Too Large
9298
- UseOutputCache
9399
- CacheOutput
94100
- OutputCache
@@ -693,7 +699,9 @@ the OS pick; read it back from `server.ListenUrl`.
693699
### Defaults worth knowing
694700

695701
- Binds **loopback** by default. Set `Address = IPAddress.Any` for LAN access — deliberately.
696-
- `Limits.MaxRequestBodySize` is 30 MB; raise it for uploads.
702+
- `Limits.MaxRequestBodySize` is 30 MB, the default for every route. For uploads, raise it **on the
703+
upload route** (`.WithRequestSizeLimit(bytes)` / `[RequestSizeLimit(bytes)]`), not server-wide —
704+
see Content. HTTP/3 is always held to the server-wide value.
697705
- `HideExceptionDetails` is on; turn it off in development only.
698706

699707
### Behind a TCP load balancer: PROXY protocol
@@ -1317,6 +1325,22 @@ new ZipFileSource("./site.zip", "wwwroot"); // zipped with its parent fo
13171325
- Uploads: `await foreach (var part in ctx.Request.ReadMultipartAsync(ct))` and
13181326
`part.SafeFileName()` (never `part.FileName` — traversal). `ReadFormAsync` buffers; use it only for
13191327
small fields.
1328+
- Body size: keep the server-wide `Limits.MaxRequestBodySize` small and give the upload route its own
1329+
limit. This is route metadata, so it belongs to tier 1 (route builder) and tier 3 (attributes):
1330+
1331+
```csharp
1332+
app.MapPost("/images", Upload).WithRequestSizeLimit(500 * 1024 * 1024); // tier 1
1333+
app.MapPut("/backups/{name}", Restore).DisableRequestSizeLimit(); // no limit
1334+
1335+
[Post("/images")] [RequestSizeLimit(500 * 1024 * 1024)] // tier 3
1336+
public Task<IActionResult> Upload(HttpContext ctx) => ...;
1337+
```
1338+
1339+
Routing applies it before authorization and the handler run. Middleware ahead of routing can set
1340+
`ctx.Request.MaxBodySize` instead. It can't change once the body has started to be read
1341+
(`IsMaxBodySizeReadOnly`; setting it then throws). Over the limit is a 413 when the body is read,
1342+
never a truncated read. HTTP/1.1 sends `100 Continue` only when the body is first read. The
1343+
per-route limit has no effect on HTTP/3, which is held to the server-wide value.
13201344
- Downloads: `FileDownloadResult.FromFile(...)` gives ranges, ETags and conditional GETs.
13211345

13221346
### WebDAV — a directory as a mountable drive
@@ -1420,7 +1444,7 @@ app.UseCors(p => p.WithOrigins("https://app.example.com").WithTusHeaders()); //
14201444

14211445
- Do not hand-roll chunked or resumable uploads with `MapPost`. Use this.
14221446
- Each PATCH is bounded by `Limits.MaxRequestBodySize` (30 MB). Tell the client to use a smaller
1423-
chunk size (tus-js-client `chunkSize`), or raise the limit.
1447+
chunk size (tus-js-client `chunkSize`), or give the tus routes their own `WithRequestSizeLimit`.
14241448
- Metadata values are client input: `ctx.Metadata["filename"]` is text, never a path to write to.
14251449
- A custom `ITusStore.AppendAsync` must keep the bytes when the stream ends early and roll back
14261450
when the stream throws. Throw `TusException(409)` for a wrong offset and `TusException(404)` for a
@@ -1461,8 +1485,8 @@ app.MapNuGetFeed("/nuget", o =>
14611485
adds authorization.
14621486
- NuGet refuses plain-HTTP sources unless `allowInsecureConnections="true"` is set on the source. On a
14631487
public host, serve it with TLS or ACME.
1464-
- Push size: `MaxPackageSize` (250 MB) **and** `Limits.MaxRequestBodySize` (30 MB) both apply. Raise
1465-
the server limit for big packages.
1488+
- Push size: `MaxPackageSize` (250 MB) **and** `Limits.MaxRequestBodySize` (30 MB) both apply. For big
1489+
packages raise it on the write routes only: `.ForWrites(r => r.WithRequestSizeLimit(250 * 1024 * 1024))`.
14661490
- Behind a proxy that rewrites the host, set `PublicBaseUrl`. Every link in the protocol is absolute.
14671491
- `DiskNuGetPackageStore` uses NuGet's own folder-feed layout (`{id}/{version}/{id}.{version}.nupkg`),
14681492
so the directory is also a local source and can be seeded by copying files in (call `Reload()`).
@@ -1502,7 +1526,8 @@ app.MapNpmRegistry("/npm", o =>
15021526
not cached. Tarball requests for public packages come back here (npm's replace-registry-host)
15031527
and are passed on as well.
15041528
- Publish bodies are base64 JSON, a third larger than the tarball. `Limits.MaxRequestBodySize`
1505-
(30 MB) applies before `MaxTarballSize` (100 MB).
1529+
(30 MB) applies before `MaxTarballSize` (100 MB). Raise it on the write routes only:
1530+
`.ForWrites(r => r.WithRequestSizeLimit(150 * 1024 * 1024))`.
15061531
- Errors are npm-style `{"error": "..."}` bodies, not problem details. Do not wrap them.
15071532
- Not supported: upstream caching, web login, 2FA/OTP, orgs/teams, provenance. Do not promise them.
15081533

@@ -1548,7 +1573,7 @@ await sync.RunAsync(ct); // continuous: long-poll + FileSystemWa
15481573
- Every client should use the same chunk sizes, or their uploads will not deduplicate.
15491574
`AverageChunkSize` must be a power of two.
15501575
- Pack uploads through `PUT chunks/pack` (non-tus) are bounded by `Limits.MaxRequestBodySize`
1551-
(30 MB). Client packs default to 8 MB.
1576+
(30 MB) unless the route raises it with `WithRequestSizeLimit`. Client packs default to 8 MB.
15521577
- Not supported: version history/restore, empty-folder sync, sharing between accounts, selective
15531578
sync, a browser client. Do not promise them.
15541579
- The routes are excluded from OpenAPI. Do not describe them.
@@ -2459,7 +2484,8 @@ anything about how the OS frames bytes.
24592484

24602485
The generator also emits metadata for `[RequestTimeout]`, `[DisableRequestTimeout]`, `[OutputCache]`,
24612486
`[NoOutputCache]`, `[ValidateAntiforgery]`, `[DisableAntiforgery]`, `[Idempotent]`, `[DisableIdempotency]`,
2462-
`[ContentDigest]`, `[DisableContentDigest]`, `[RequireWebhookSignature]` and `[ApiVersion]`/`[MapToApiVersion]`/
2487+
`[ContentDigest]`, `[DisableContentDigest]`, `[RequireWebhookSignature]`, `[RequestSizeLimit]`,
2488+
`[DisableRequestSizeLimit]` and `[ApiVersion]`/`[MapToApiVersion]`/
24632489
`[ApiVersionNeutral]`, exactly as it does for
24642490
`[Authorize]`, `[EnableCors]`, `[EnableRateLimiting]` and `[RequireIpFilter]` — a method's attribute
24652491
replaces the class's, and a `Disable` anywhere wins.
@@ -2490,8 +2516,10 @@ replaces the class's, and a `Disable` anywhere wins.
24902516
12. **A request timeout is cancellation, not a kill.** Pass `ctx.RequestAborted` into the slow work
24912517
or the timeout only changes what the client sees.
24922518
13. **Never cache a response for an authenticated caller** without adding the identity to the key.
2493-
14. **Prefer the in-memory harness for endpoint tests**, and a real socket for anything about the
2519+
14. **Raise the body limit on the route that needs it**, not server-wide: `WithRequestSizeLimit` /
2520+
`[RequestSizeLimit]` on the upload, a small `Limits.MaxRequestBodySize` everywhere else.
2521+
15. **Prefer the in-memory harness for endpoint tests**, and a real socket for anything about the
24942522
socket.
2495-
15. **Subscribe to `StateTransitioned`, not `StateChanged`, when the app has to report why the server
2523+
16. **Subscribe to `StateTransitioned`, not `StateChanged`, when the app has to report why the server
24962524
stopped.** `StateChanged` cannot tell "the user switched it off" from "the listener died", and on
24972525
a device that distinction is the whole bug report. Treat `Reason == Restarting` as *not* down.

‎src/Shiny.Net.HttpServer.FileSync/FileSyncOptions.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ public sealed class FileSyncOptions
2929

3030
/// <summary>
3131
/// Largest pack accepted, through tus or a plain <c>PUT</c>. Default 64 MB. A plain <c>PUT</c> is
32-
/// also bounded by <see cref="HttpServerLimits.MaxRequestBodySize"/> (30 MB by default); tus
33-
/// splits a pack into requests below it.
32+
/// also bounded by <see cref="HttpServerLimits.MaxRequestBodySize"/> (30 MB by default), unless
33+
/// the route raises it with <c>WithRequestSizeLimit</c>; tus splits a pack into requests below it.
3434
/// </summary>
3535
public long MaxPackSize { get; set; } = 64L * 1024 * 1024;
3636

‎src/Shiny.Net.HttpServer.Npm/NpmRegistryOptions.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ public sealed class NpmRegistryOptions
5151
/// <summary>
5252
/// Largest tarball a publish accepts. Default 100 MB. npm sends it base64-encoded inside JSON,
5353
/// a third larger, and the server's <see cref="HttpServerLimits.MaxRequestBodySize"/> (30 MB by
54-
/// default) is checked first - raise that too for big packages.
54+
/// default) is checked first - raise it for the registry's write routes for big packages:
55+
/// <c>.ForWrites(r =&gt; r.WithRequestSizeLimit(...))</c> on what <c>MapNpmRegistry</c> returns.
5556
/// </summary>
5657
public long MaxTarballSize { get; set; } = 100L * 1024 * 1024;
5758

‎src/Shiny.Net.HttpServer.NuGet/NuGetFeedOptions.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ public sealed class NuGetFeedOptions
6161
/// <summary>
6262
/// Largest <c>.nupkg</c> a push accepts. Default 250 MB, nuget.org's limit. The server's own
6363
/// <see cref="HttpServerLimits.MaxRequestBodySize"/> (30 MB by default) applies first, so raise
64-
/// that too if packages are bigger.
64+
/// it for the feed's write routes if packages are bigger -
65+
/// <c>.ForWrites(r =&gt; r.WithRequestSizeLimit(...))</c> on what <c>MapNuGetFeed</c> returns.
6566
/// </summary>
6667
public long MaxPackageSize { get; set; } = 250L * 1024 * 1024;
6768

‎src/Shiny.Net.HttpServer.SourceGenerators/EndpointEmitter.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ static List<string> PolicyMetadata(EndpointPolicyModel policies)
185185
if (policies.HasContentDigest)
186186
metadata.Add($"new {ContentDigestMetadata} {{ {DigestAssignments(policies)} }}");
187187

188+
// The attributes are the metadata routing reads, so they are emitted as they were written.
189+
if (policies.HasRequestSizeLimit)
190+
{
191+
metadata.Add(policies.RequestSizeLimitDisabled
192+
? "new global::Shiny.Net.HttpServer.DisableRequestSizeLimitAttribute()"
193+
: $"new global::Shiny.Net.HttpServer.RequestSizeLimitAttribute({policies.RequestSizeLimit!.Value}L)");
194+
}
195+
188196
return metadata;
189197

190198
static string IdempotencyAssignments(EndpointPolicyModel policies)

‎src/Shiny.Net.HttpServer.SourceGenerators/EndpointGenerator.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public sealed class EndpointGenerator : IIncrementalGenerator
4343
const string AllowAnyIpAttributeName = "Shiny.Net.HttpServer.AllowAnyIpAttribute";
4444
const string RequestTimeoutAttributeName = "Shiny.Net.HttpServer.RequestTimeoutAttribute";
4545
const string DisableRequestTimeoutAttributeName = "Shiny.Net.HttpServer.DisableRequestTimeoutAttribute";
46+
const string RequestSizeLimitAttributeName = "Shiny.Net.HttpServer.RequestSizeLimitAttribute";
47+
const string DisableRequestSizeLimitAttributeName = "Shiny.Net.HttpServer.DisableRequestSizeLimitAttribute";
4648
const string OutputCacheAttributeName = "Shiny.Net.HttpServer.OutputCacheAttribute";
4749
const string NoOutputCacheAttributeName = "Shiny.Net.HttpServer.NoOutputCacheAttribute";
4850
const string ValidateAntiforgeryAttributeName = "Shiny.Net.HttpServer.ValidateAntiforgeryAttribute";
@@ -433,11 +435,13 @@ static EndpointPolicyModel PoliciesFor(INamedTypeSymbol type, IMethodSymbol meth
433435

434436
var idempotencyDisabled = HasAttribute(DisableIdempotencyAttributeName);
435437
var digestDisabled = HasAttribute(DisableContentDigestAttributeName);
438+
var sizeLimitDisabled = HasAttribute(DisableRequestSizeLimitAttributeName);
436439

437440
var timeout = timeoutDisabled ? null : Nearest(RequestTimeoutAttributeName);
438441
var cache = cacheDisabled ? null : Nearest(OutputCacheAttributeName);
439442
var idempotent = idempotencyDisabled ? null : Nearest(IdempotentAttributeName);
440443
var digest = digestDisabled ? null : Nearest(ContentDigestAttributeName);
444+
var sizeLimit = sizeLimitDisabled ? null : Nearest(RequestSizeLimitAttributeName);
441445

442446
var model = new EndpointPolicyModel(
443447
corsDisabled ? null : PolicyName(EnableCorsAttributeName),
@@ -468,7 +472,9 @@ static EndpointPolicyModel PoliciesFor(INamedTypeSymbol type, IMethodSymbol meth
468472
digest?.GetNamedBool("RequireRequestDigest") ?? false,
469473
digest?.GetNamedBool("AlwaysEmitResponseDigest") ?? true,
470474
digestDisabled,
471-
WebhookVerifierName()
475+
WebhookVerifierName(),
476+
sizeLimit?.GetConstructorLong(0),
477+
sizeLimitDisabled
472478
);
473479

474480
return model.HasAny ? model : EndpointPolicyModel.None;

‎src/Shiny.Net.HttpServer.SourceGenerators/EndpointModel.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ sealed record EndpointPolicyModel(
115115
bool ContentDigestRequireRequest,
116116
bool ContentDigestAlwaysEmit,
117117
bool ContentDigestDisabled,
118-
string? WebhookVerifier = null
118+
string? WebhookVerifier = null,
119+
long? RequestSizeLimit = null,
120+
bool RequestSizeLimitDisabled = false
119121
) : IEquatable<EndpointPolicyModel>
120122
{
121123
public static readonly EndpointPolicyModel None = new(
@@ -143,6 +145,8 @@ public bool HasOutputCache
143145

144146
public bool HasContentDigest => this.ContentDigest || this.ContentDigestDisabled;
145147

148+
public bool HasRequestSizeLimit => this.RequestSizeLimitDisabled || this.RequestSizeLimit is not null;
149+
146150
public bool HasAny
147151
=> this.HasCors
148152
|| this.HasRateLimit
@@ -152,7 +156,8 @@ public bool HasAny
152156
|| this.HasAntiforgery
153157
|| this.HasWebhook
154158
|| this.HasIdempotency
155-
|| this.HasContentDigest;
159+
|| this.HasContentDigest
160+
|| this.HasRequestSizeLimit;
156161
}
157162

158163
/// <summary>What <c>[Authorize]</c> and <c>[AllowAnonymous]</c> on a class and method add up to.</summary>

‎src/Shiny.Net.HttpServer.SourceGenerators/TypeAnalysis.cs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ public static ScalarKind ClassifyScalar(this ITypeSymbol type, out ITypeSymbol?
156156
? value
157157
: null;
158158

159+
/// <summary>Reads a constructor argument that is a long, e.g. <c>[RequestSizeLimit(500_000_000)]</c>.</summary>
160+
public static long? GetConstructorLong(this AttributeData attribute, int index)
161+
=> attribute.ConstructorArguments.Length > index
162+
? attribute.ConstructorArguments[index].Value switch
163+
{
164+
long value => value,
165+
int value => value,
166+
_ => null
167+
}
168+
: null;
169+
159170
/// <summary>Reads a named argument that is a number, e.g. <c>[OutputCache(Seconds = 30)]</c>.</summary>
160171
public static int? GetNamedInt(this AttributeData attribute, string name)
161172
=> attribute.NamedArguments.FirstOrDefault(a => a.Key == name).Value.Value is int value ? value : null;

‎src/Shiny.Net.HttpServer.Tus/TusOptions.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public sealed class TusOptions
1515
/// <para>
1616
/// This is the whole file. Each <c>PATCH</c> is separately bounded by the server's
1717
/// <see cref="HttpServerLimits.MaxRequestBodySize"/>, so a client that sends a big file in one
18-
/// request needs a chunk size below that - tus-js-client's <c>chunkSize</c>, for example.
18+
/// request needs a chunk size below that - tus-js-client's <c>chunkSize</c>, for example - or
19+
/// the tus routes need a higher limit of their own, set with <c>WithRequestSizeLimit</c>.
1920
/// </para>
2021
/// </summary>
2122
public long? MaxSize { get; set; }

0 commit comments

Comments
 (0)