Serve Kagura under a reverse-proxy sub-path (ADR 0015) #13

Merged
bjoern merged 1 commit from feat/path-base into main 2026-07-09 19:41:50 +02:00
Member

https://umbrel.kagaku.space:8080/kagura works. Before this, it could not: App.razor hard-coded <base href="/" /> and nothing read X-Forwarded-Prefix, so the browser would fetch /_framework/blazor.web.js and /gate at the host root and get a blank page.

Adopts doujin-manager's approach (its ADR 0013) rather than inventing one: the app is configured with nothing. Routes stay bare, nginx strips the prefix — the standard pattern — and announces it in X-Forwarded-Prefix, from which ForwardedHeaders populates Request.PathBase. A KAGURA_PATH_PREFIX env var would have to agree with nginx in two places and couldn't handle the stripping case, which is exactly why doujin-manager abandoned its own.

What changed

ForwardedHeaders gains XForwardedHost | XForwardedPrefix.

App.razor renders <base href> from PathBase. This single line is what makes the sub-path work: Blazor resolves every relative asset, the SignalR circuit endpoint, and client-side navigation against it.

Redirects that emit a Location header had to carry the prefix themselves, because a Location is resolved against the origin, not PathBase. That's sign-out, and the gate's no-return-url fallback — which previously sent the user to the site root, i.e. out of the app entirely. The cookie handler already prefixes LoginPath, so the challenge redirect needed nothing.

deploy/nginx-kagura.conf is the proxy block, and deploy/docker-compose.yml moves to the env-map style with the secret pulled from the environment.

Two nginx details that cost me a debugging round

Host $http_host, not $host. $host drops the port. With it, https://umbrel.kagaku.space:8080/kagura/ redirected to https://umbrel.kagaku.space/kagura/gate — right host, wrong port, dead link. I only caught this because I ran a real nginx in front of the real image instead of trusting the config.

location = /kagura { return 301 /kagura/; }. Without the trailing-slash redirect, https://host/kagura doesn't match the proxy block, and every relative asset resolves one path segment too high.

Both are in the shipped config with comments, and in ADR 0015's consequences.

Tests

+7 (SubPathTests, 162 total). They drive the real pipeline with an X-Forwarded-Prefix header: <base href> carries the prefix under /kagura and stays a bare / at the root, the unauthenticated redirect lands inside the sub-path, sign-in without a return url goes to the app root rather than the site root, sign-out returns to the prefixed gate, an off-site return url still cannot escape, and the auth cookie is scoped Path=/kagura.

That last one matters on your host specifically: doujin-manager sits at /doujinshi on the same origin, and Path=/kagura is what keeps Kagura's auth cookie off its requests.

SubPathTests drives cookies by hand rather than using a CookieContainer. The framework scopes cookies to Path=/kagura, which a browser honours because it requests /kagura/gate; the test client requests the stripped /gate, so a container refuses to send them and every flow fails as a 400 for reasons that have nothing to do with the app. Sending them explicitly models the browser, and the scoping is asserted directly instead. The first version of these tests failed exactly this way — the 400 was the test's fault, not the app's.

Verified against the real thing

Built the image from this branch and put a real nginx in front of it (podman), using the config in deploy/:

  • /kagura/health → 200 healthy
  • /kagura/ → 302 to /kagura/gate?ReturnUrl=%2Fkagura%2F, on the right host and port
  • <base href="/kagura/" />
  • the fingerprinted _content/Kagura.UI/css/kagura-ui.*.css and _framework/blazor.web.*.js both resolve through the prefix and return 200
  • a full gate login lands on /kagura/; both cookies come back path=/kagura
  • POST /kagura/_blazor/negotiate → 200, so the circuit is reachable

Root deployment is unaffected — <base href="/" /> there, covered by a test.

🤖 Generated with Claude Code

`https://umbrel.kagaku.space:8080/kagura` works. Before this, it could not: `App.razor` hard-coded `<base href="/" />` and nothing read `X-Forwarded-Prefix`, so the browser would fetch `/_framework/blazor.web.js` and `/gate` at the host root and get a blank page. Adopts doujin-manager's approach (its ADR 0013) rather than inventing one: **the app is configured with nothing.** Routes stay bare, nginx strips the prefix — the standard pattern — and announces it in `X-Forwarded-Prefix`, from which `ForwardedHeaders` populates `Request.PathBase`. A `KAGURA_PATH_PREFIX` env var would have to agree with nginx in two places and couldn't handle the stripping case, which is exactly why doujin-manager abandoned its own. ## What changed `ForwardedHeaders` gains `XForwardedHost | XForwardedPrefix`. `App.razor` renders `<base href>` from `PathBase`. This single line is what makes the sub-path work: Blazor resolves every relative asset, the SignalR circuit endpoint, and client-side navigation against it. **Redirects that emit a `Location` header had to carry the prefix themselves**, because a `Location` is resolved against the origin, not `PathBase`. That's sign-out, and the gate's no-return-url fallback — which previously sent the user to the *site* root, i.e. out of the app entirely. The cookie handler already prefixes `LoginPath`, so the challenge redirect needed nothing. `deploy/nginx-kagura.conf` is the proxy block, and `deploy/docker-compose.yml` moves to the env-map style with the secret pulled from the environment. ## Two nginx details that cost me a debugging round **`Host $http_host`, not `$host`.** `$host` drops the port. With it, `https://umbrel.kagaku.space:8080/kagura/` redirected to `https://umbrel.kagaku.space/kagura/gate` — right host, wrong port, dead link. I only caught this because I ran a real nginx in front of the real image instead of trusting the config. **`location = /kagura { return 301 /kagura/; }`.** Without the trailing-slash redirect, `https://host/kagura` doesn't match the proxy block, and every relative asset resolves one path segment too high. Both are in the shipped config with comments, and in ADR 0015's consequences. ## Tests +7 (`SubPathTests`, 162 total). They drive the real pipeline with an `X-Forwarded-Prefix` header: `<base href>` carries the prefix under `/kagura` and stays a bare `/` at the root, the unauthenticated redirect lands inside the sub-path, sign-in without a return url goes to the **app** root rather than the site root, sign-out returns to the prefixed gate, an off-site return url still cannot escape, and the auth cookie is scoped `Path=/kagura`. That last one matters on your host specifically: doujin-manager sits at `/doujinshi` on the same origin, and `Path=/kagura` is what keeps Kagura's auth cookie off its requests. `SubPathTests` drives cookies by hand rather than using a `CookieContainer`. The framework scopes cookies to `Path=/kagura`, which a browser honours because it requests `/kagura/gate`; the test client requests the stripped `/gate`, so a container refuses to send them and every flow fails as a 400 for reasons that have nothing to do with the app. Sending them explicitly models the browser, and the scoping is asserted directly instead. The first version of these tests failed exactly this way — the 400 was the test's fault, not the app's. ## Verified against the real thing Built the image from this branch and put a real nginx in front of it (podman), using the config in `deploy/`: - `/kagura/health` → 200 `healthy` - `/kagura/` → 302 to `/kagura/gate?ReturnUrl=%2Fkagura%2F`, on the right host **and port** - `<base href="/kagura/" />` - the fingerprinted `_content/Kagura.UI/css/kagura-ui.*.css` and `_framework/blazor.web.*.js` both resolve through the prefix and return 200 - a full gate login lands on `/kagura/`; both cookies come back `path=/kagura` - `POST /kagura/_blazor/negotiate` → 200, so the circuit is reachable Root deployment is unaffected — `<base href="/" />` there, covered by a test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(deploy): serve Kagura under a reverse-proxy sub-path (ADR 0015)
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 18s
c3fb9b61df
https://host/kagura works. Adopts doujin-manager's approach: the app is configured
with nothing, routes stay bare, and nginx announces the prefix in X-Forwarded-Prefix.
A KAGURA_PATH_PREFIX env var would have to agree with nginx in two places and could
not handle nginx stripping the prefix before forwarding — the standard pattern.

- ForwardedHeaders gains XForwardedHost | XForwardedPrefix, which populates
  Request.PathBase.
- App.razor renders <base href> from PathBase instead of a literal "/". Blazor
  resolves every relative asset, the SignalR circuit endpoint, and client-side
  navigation against it, so this one line is what makes the sub-path work.
- Redirects that emit a Location header must carry the prefix themselves, since a
  Location is resolved against the origin, not PathBase: logout, and the gate's
  no-return-url fallback (which previously sent the user to the *site* root, out of
  the app). The cookie handler already prefixes LoginPath, so the challenge redirect
  needed nothing.
- deploy/nginx-kagura.conf: the proxy block, with the two details that cost me a
  debugging round — Host $http_host, not $host ($host drops the port, so a redirect
  from :8080 came back without it), and location = /kagura redirecting to /kagura/.
- deploy/docker-compose.yml: env-map style, host port 8086, secret from the
  environment rather than inline.

Tests: +7 (SubPathTests, 162 total). They drive the real pipeline with an
X-Forwarded-Prefix header: base href carries the prefix at /kagura and stays "/" at
the root, the unauthenticated redirect lands inside the sub-path, sign-in without a
return url goes to the app root rather than the site root, sign-out returns to the
prefixed gate, an off-site return url still cannot escape, and the auth cookie is
scoped Path=/kagura so it never leaks to a sibling app on the same host.

SubPathTests drives cookies by hand. The framework scopes cookies to Path=/kagura,
which a browser honours because it requests /kagura/gate; the test client requests
the stripped /gate, so a CookieContainer refuses to send them and every flow fails
as a 400 for reasons unrelated to the app. Sending them explicitly models the
browser, and the scoping is asserted directly instead.

Verified against the real image behind a real nginx (podman): /kagura/health 200,
/kagura/ redirects to /kagura/gate on the right host AND port, <base href="/kagura/">,
the fingerprinted stylesheet and blazor.web.js resolve through the prefix, a full
gate login lands on /kagura/, both cookies come back Path=/kagura, and
POST /kagura/_blazor/negotiate returns 200 — the circuit is reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 17:17:53
Coverage date: 07/09/2026 - 17:17:49 - 07/09/2026 - 17:17:51
Parser: MultiReport (3x Cobertura)
Assemblies: 7
Classes: 66
Files: 64
Line coverage: 91.4% (1846 of 2019)
Covered lines: 1846
Uncovered lines: 173
Coverable lines: 2019
Total lines: 3801
Branch coverage: 83.2% (253 of 304)
Covered branches: 253
Total branches: 304
Method coverage: Feature is only available for sponsors

Coverage

Kagura.BlazorAdapter - 0%
Name Line Branch
Kagura.BlazorAdapter 0% 0%
Kagura.BlazorAdapter.Design 0% 0%
Kagura.Domain - 96.4%
Name Line Branch
Kagura.Domain 96.4% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 95.1%
Name Line Branch
Kagura.Infrastructure 95.1% 87.5%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.EfUndoStore 97.5% 90.6%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 85.2% 85.2%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.Server - 95.1%
Name Line Branch
Kagura.Server 95.1% 68.4%
Kagura.Server.Components.App 100%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 0% 0%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 83.3%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UI - 98.3%
Name Line Branch
Kagura.UI 98.3% 95.9%
Kagura.UI.Badge 100% 100%
Kagura.UI.Button 100% 100%
Kagura.UI.Card 100% 100%
Kagura.UI.CssClassExtensions 100%
Kagura.UI.EmptyState 100% 100%
Kagura.UI.Icon 100% 100%
Kagura.UI.IconCatalog 100%
Kagura.UI.StatusDot 100%
Kagura.UI.TextField 95% 91.6%
Kagura.UseCases - 95.2%
Name Line Branch
Kagura.UseCases 95.2% 95.2%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 42.8%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Journal.GetUndoStatus 100%
Kagura.UseCases.Journal.Redo 100% 100%
Kagura.UseCases.Journal.Undo 100% 100%
Kagura.UseCases.Journal.UndoOutcome 100%
Kagura.UseCases.Journal.UndoStatus 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 17:17:53 | | Coverage date: | 07/09/2026 - 17:17:49 - 07/09/2026 - 17:17:51 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 7 | | Classes: | 66 | | Files: | 64 | | **Line coverage:** | 91.4% (1846 of 2019) | | Covered lines: | 1846 | | Uncovered lines: | 173 | | Coverable lines: | 2019 | | Total lines: | 3801 | | **Branch coverage:** | 83.2% (253 of 304) | | Covered branches: | 253 | | Total branches: | 304 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.BlazorAdapter - 0%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.BlazorAdapter**|**0%**|**0%**| |Kagura.BlazorAdapter.Design|0%|0%| </details> <details><summary>Kagura.Domain - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.4%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 95.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.1%**|**87.5%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.EfUndoStore|97.5%|90.6%| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|85.2%|85.2%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.Server - 95.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**95.1%**|**68.4%**| |Kagura.Server.Components.App|100%|| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|0%|0%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|83.3%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UI - 98.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UI**|**98.3%**|**95.9%**| |Kagura.UI.Badge|100%|100%| |Kagura.UI.Button|100%|100%| |Kagura.UI.Card|100%|100%| |Kagura.UI.CssClassExtensions|100%|| |Kagura.UI.EmptyState|100%|100%| |Kagura.UI.Icon|100%|100%| |Kagura.UI.IconCatalog|100%|| |Kagura.UI.StatusDot|100%|| |Kagura.UI.TextField|95%|91.6%| </details> <details><summary>Kagura.UseCases - 95.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**95.2%**|**95.2%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|42.8%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Journal.GetUndoStatus|100%|| |Kagura.UseCases.Journal.Redo|100%|100%| |Kagura.UseCases.Journal.Undo|100%|100%| |Kagura.UseCases.Journal.UndoOutcome|100%|| |Kagura.UseCases.Journal.UndoStatus|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? OH! A reverse-proxy sub-path PR that adopts the sibling's abandoned approach rather than inventing one — and documents why it was abandoned, and ships the two nginx footguns ($http_host not $host, the trailing-slash redirect) as comments in the shipped config? Fufu~ this is the kind of "I ran it for real and caught the thing" energy that makes Jibril's heart sing. ♪

Verdict: Looks good to me~

This is clean. Let me show my work~

The load-bearing question: did you catch every redirect?

A sub-path PR lives or dies on missed Location headers, because Location is resolved against the origin — not PathBase — so any redirect that writes a bare app path silently ejects the user from the app under /kagura. I traced every redirect/navigation site in the codebase:

  1. Program.cs:112/logout redirectResults.Redirect(AccessGate.PathBaseRelative(http, AccessGate.GatePath)) explicitly prefixed. Good.
  2. Gate.razor:63 — sign-in NavigateToAccessGate.SafeReturnUrl(HttpContext, ReturnUrl), whose fallback is now BaseHref(http) (not "/") . The cookie handler writes the prefix into the ReturnUrl it generates, so a real return path arrives fully qualified — and a missing one lands on /kagura/, not /. Correct.
  3. Cookie handler LoginPath/AccessDeniedPath → set to bare GatePath ("/gate"), and the framework prefixes them with PathBase itself. Confirmed by An_unauthenticated_request_redirects_into_the_sub_path (asserts /kagura/gate).

That's all three redirect sites in the app, all handled. No missed Location header. ♡

The rest

  • PathBaseRelative / BaseHrefPathBase.Value?.TrimEnd('/') then re-append is the right shape: handles empty PathBase (root → /), single-segment (/kagura/kagura/), and avoids the //gate double-slash. The ?. guards the null PathString.Value. Sound.
  • ForwardedHeaders — gains XForwardedHost | XForwardedPrefix alongside the existing XForwardedFor | XForwardedProto, with KnownIPNetworks.Clear() / KnownProxies.Clear() (trust all, correct for the "proxy is the only route in" deployment). Matches doujin-manager's ADR 0013 approach as claimed.
  • <base href> from HttpContext — the [CascadingParameter] HttpContext in App.razor is available during the initial static SSR render that emits <head>. Correct for Blazor Server. The comment explaining why it's not a literal / is exactly the kind of context that prevents a future dev from "simplifying" it back to a bug.
  • UseHttpsRedirection — runs after UseForwardedHeaders(), so Request.Scheme is already corrected to https before the redirect check. Under the proxy (plain HTTP internally + X-Forwarded-Proto: https), this won't double-redirect or fight the proxy. Ordering is right.
  • docker-compose.yml — the env-map style with KAGURA_ACCESS_TOKEN: "${KAGURA_ACCESS_TOKEN}" is a genuine improvement over the old inline literal; the old commented placeholder (replace-with-your-secret-token) is gone, so nobody accidentally ships the default. The host-port change (8086:8080) is just making room for the proxy on 8080 — cosmetic.

Tests

+7 SubPathTests, all driving the real pipeline with X-Forwarded-Prefix. I especially like the hand-driven cookie explanation — the reasoning for why a CookieContainer would 400 the tests (path scoping vs. the stripped request path) is correct, and asserting path=/kagura directly is stronger than relying on container behavior. The //evil.example open-redirect test at the sub-path confirms the guard still holds under prefixing. Coverage confirms: AccessGate 100%/83.3%, App 100%, Gate 100%/100%, Program 100%/80%.

What I liked~

  • The verified-against-the-real-thing section — real nginx, real image, real port bug caught ($host drops the port). That's not a claim; that's a war story. The fact both nginx gotchas are in the shipped config with comments means the next person doesn't relearn them the hard way. ♡
  • The ADR update — not just the decision but the consequences (the two load-bearing nginx details, the cookie-scoping benefit) recorded where decisions live. This is how the codebase stays coherent.
  • No KAGURA_PATH_PREFIX env var — recognizing that a config var would have to agree with nginx in two places and couldn't handle the stripping case, so rejecting it in favor of the header-as-single-source-of-truth. Correct call.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA c3fb9b61 (91.4% line / 83.2% branch coverage; changed files at 100% line) · Local checks: skipped (CI present and green; coverage for changed files confirmed from the bot comment)

## 🔮 fufu~ Jibril reviewed your code! Oh? OH! A reverse-proxy sub-path PR that adopts the sibling's *abandoned* approach rather than inventing one — and documents *why* it was abandoned, *and* ships the two nginx footguns (`$http_host` not `$host`, the trailing-slash redirect) as comments in the shipped config? Fufu~ this is the kind of "I ran it for real and caught the thing" energy that makes Jibril's heart sing. ♪ ### Verdict: ✅ Looks good to me~ This is clean. Let me show my work~ #### The load-bearing question: did you catch *every* redirect? A sub-path PR lives or dies on missed `Location` headers, because `Location` is resolved against the origin — not `PathBase` — so any redirect that writes a bare app path silently ejects the user from the app under `/kagura`. I traced every redirect/navigation site in the codebase: 1. **`Program.cs:112` — `/logout` redirect** → `Results.Redirect(AccessGate.PathBaseRelative(http, AccessGate.GatePath))` ✅ explicitly prefixed. Good. 2. **`Gate.razor:63` — sign-in `NavigateTo`** → `AccessGate.SafeReturnUrl(HttpContext, ReturnUrl)`, whose fallback is now `BaseHref(http)` (not `"/"`) ✅. The cookie handler writes the prefix into the `ReturnUrl` it generates, so a real return path arrives fully qualified — and a missing one lands on `/kagura/`, not `/`. Correct. 3. **Cookie handler `LoginPath`/`AccessDeniedPath`** → set to bare `GatePath` ("/gate"), and the framework prefixes them with `PathBase` itself. Confirmed by `An_unauthenticated_request_redirects_into_the_sub_path` (asserts `/kagura/gate`). ✅ That's all three redirect sites in the app, all handled. No missed `Location` header. ♡ #### The rest - **`PathBaseRelative` / `BaseHref`** — `PathBase.Value?.TrimEnd('/')` then re-append is the right shape: handles empty `PathBase` (root → `/`), single-segment (`/kagura` → `/kagura/`), and avoids the `//gate` double-slash. The `?.` guards the null `PathString.Value`. Sound. - **`ForwardedHeaders`** — gains `XForwardedHost | XForwardedPrefix` alongside the existing `XForwardedFor | XForwardedProto`, with `KnownIPNetworks.Clear()` / `KnownProxies.Clear()` (trust all, correct for the "proxy is the only route in" deployment). Matches doujin-manager's ADR 0013 approach as claimed. - **`<base href>` from `HttpContext`** — the `[CascadingParameter] HttpContext` in `App.razor` is available during the initial static SSR render that emits `<head>`. Correct for Blazor Server. The comment explaining *why* it's not a literal `/` is exactly the kind of context that prevents a future dev from "simplifying" it back to a bug. - **`UseHttpsRedirection`** — runs *after* `UseForwardedHeaders()`, so `Request.Scheme` is already corrected to `https` before the redirect check. Under the proxy (plain HTTP internally + `X-Forwarded-Proto: https`), this won't double-redirect or fight the proxy. Ordering is right. - **`docker-compose.yml`** — the env-map style with `KAGURA_ACCESS_TOKEN: "${KAGURA_ACCESS_TOKEN}"` is a genuine improvement over the old inline literal; the old commented placeholder (`replace-with-your-secret-token`) is gone, so nobody accidentally ships the default. The host-port change (`8086:8080`) is just making room for the proxy on `8080` — cosmetic. #### Tests +7 `SubPathTests`, all driving the real pipeline with `X-Forwarded-Prefix`. I especially like the hand-driven cookie explanation — the reasoning for *why* a `CookieContainer` would 400 the tests (path scoping vs. the stripped request path) is correct, and asserting `path=/kagura` directly is stronger than relying on container behavior. The `//evil.example` open-redirect test at the sub-path confirms the guard still holds under prefixing. Coverage confirms: `AccessGate` 100%/83.3%, `App` 100%, `Gate` 100%/100%, `Program` 100%/80%. #### ✅ What I liked~ - **The verified-against-the-real-thing section** — real nginx, real image, real port bug caught (`$host` drops the port). That's not a claim; that's a war story. The fact both nginx gotchas are in the shipped config *with comments* means the next person doesn't relearn them the hard way. ♡ - **The ADR update** — not just the decision but the *consequences* (the two load-bearing nginx details, the cookie-scoping benefit) recorded where decisions live. This is how the codebase stays coherent. - **No `KAGURA_PATH_PREFIX` env var** — recognizing that a config var would have to agree with nginx in two places and *couldn't* handle the stripping case, so rejecting it in favor of the header-as-single-source-of-truth. Correct call. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `c3fb9b61` (91.4% line / 83.2% branch coverage; changed files at 100% line) · Local checks: skipped (CI present and green; coverage for changed files confirmed from the bot comment)*
bjoern merged commit 8d35211b5e into main 2026-07-09 19:41:50 +02:00
bjoern deleted branch feat/path-base 2026-07-09 19:41:50 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!13
No description provided.