Skip to content

Commit b37da3b

Browse files
committed
Serve the Angular UI correctly under an HttpPathPrefix, and give CI node
1 parent eee570e commit b37da3b

3 files changed

Lines changed: 61 additions & 5 deletions

File tree

.github/workflows/build.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ on:
2222

2323
env:
2424
DOTNET_VERSION: '10.0.x'
25+
# The service embeds the Angular UI, so packaging needs node. Kept in step
26+
# with the Dockerfile's node:22-alpine stage -- the container build and the
27+
# Windows build must not compile the UI with different toolchains.
28+
NODE_VERSION: '22.x'
2529
CONFIGURATION: Release
2630

2731
jobs:
@@ -43,6 +47,13 @@ jobs:
4347
with:
4448
dotnet-version: ${{ env.DOTNET_VERSION }}
4549

50+
- name: Setup Node.js
51+
uses: actions/setup-node@v4
52+
with:
53+
node-version: ${{ env.NODE_VERSION }}
54+
cache: npm
55+
cache-dependency-path: src/Papercut.Service/Web/package-lock.json
56+
4657
- name: Install GitVersion
4758
uses: gittools/actions/gitversion/setup@v4.1.0
4859
with:

src/Papercut.Service/Application/StaticContent/StaticContentController.cs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Papercut
1+
// Papercut
22
//
33
// Copyright © 2008 - 2012 Ken Robertson
44
// Copyright © 2013 - 2025 Jaben Cargman
@@ -19,6 +19,7 @@
1919
namespace Papercut.Service.Application.StaticContent;
2020

2121
using System.Reflection;
22+
using System.Text.RegularExpressions;
2223

2324
public class StaticContentController : ControllerBase
2425
{
@@ -44,6 +45,17 @@ public class StaticContentController : ControllerBase
4445
{ "webmanifest", "application/manifest+json" },
4546
};
4647

48+
/// <summary>
49+
/// The SPA ships with a root base href. When the service is mounted under an
50+
/// HttpPathPrefix that is wrong for every url the app builds from it, so the
51+
/// tag is rewritten on the way out to whatever prefix this request arrived on.
52+
/// </summary>
53+
static readonly Regex _baseHrefRegex = new(
54+
@"<base\s+href\s*=\s*([""'])[^""']*\1",
55+
RegexOptions.IgnoreCase | RegexOptions.Compiled);
56+
57+
const string IndexResource = "index.html";
58+
4759
[HttpGet("{*anything}", Order = short.MaxValue)]
4860
[ResponseCache(
4961
#if DEBUG
@@ -52,7 +64,7 @@ public class StaticContentController : ControllerBase
5264
Duration = 600
5365
#endif
5466
)]
55-
public IActionResult Get()
67+
public async Task<IActionResult> Get()
5668
{
5769
var resourceName = GetRequestedResourceName(Request.Path);
5870
var resourceContent = GetResourceStream(resourceName);
@@ -61,7 +73,7 @@ public IActionResult Get()
6173
{
6274
// deep links into the Angular SPA (e.g. /message/{id}) fall back to index.html
6375
// so the client-side router can handle the route
64-
resourceName = "index.html";
76+
resourceName = IndexResource;
6577
resourceContent = GetResourceStream(resourceName);
6678
}
6779

@@ -70,9 +82,26 @@ public IActionResult Get()
7082
return NotFound();
7183
}
7284

85+
if (string.Equals(resourceName, IndexResource, StringComparison.OrdinalIgnoreCase))
86+
{
87+
return Content(await ReadIndexWithBaseHrefAsync(resourceContent), "text/html", Encoding.UTF8);
88+
}
89+
7390
return new FileStreamResult(resourceContent, GetMimeType(resourceName));
7491
}
7592

93+
async Task<string> ReadIndexWithBaseHrefAsync(Stream content)
94+
{
95+
using var reader = new StreamReader(content, Encoding.UTF8);
96+
var html = await reader.ReadToEndAsync();
97+
98+
// PathBase is what UsePathBase stripped off, i.e. exactly the prefix this
99+
// request came in on. Empty when there is no prefix, giving "/" as before.
100+
var baseHref = $"{Request.PathBase.Value?.TrimEnd('/')}/";
101+
102+
return _baseHrefRegex.Replace(html, match => $"<base href=\"{baseHref}\"", 1);
103+
}
104+
76105
static string GetRequestedResourceName(string requestUri)
77106
{
78107
var filename = requestUri

src/Papercut.Service/Web/src/app/services/environment.service.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,28 @@ export class EnvironmentService {
2222
return environment.name === 'staging';
2323
}
2424

25+
/**
26+
* The configured paths are root-relative ('/api'), which is only correct when
27+
* the app is served from the root. Under an HttpPathPrefix ('/webmail') every
28+
* request has to carry that prefix, so both are resolved against the document
29+
* base -- the server writes <base href> to match the prefix it is serving on.
30+
*
31+
* In dev, baseURI is http://localhost:4200/ and the paths come out unchanged,
32+
* so the ng serve proxy still catches /api and /hubs.
33+
*/
2534
get apiBaseUrl(): string {
26-
return environment.apiBaseUrl;
35+
return this.resolveAgainstBase(environment.apiBaseUrl);
2736
}
2837

2938
get signalRUrl(): string {
30-
return environment.signalRUrl;
39+
return this.resolveAgainstBase(environment.signalRUrl);
40+
}
41+
42+
private resolveAgainstBase(path: string): string {
43+
// an absolute url in config wins -- someone pointed this at another host
44+
if (/^https?:\/\//i.test(path)) return path;
45+
46+
return new URL(path.replace(/^\/+/, ''), document.baseURI).toString().replace(/\/$/, '');
3147
}
3248

3349
get isLoggingEnabled(): boolean {

0 commit comments

Comments
 (0)