Merge pull request #201 from Coffey-Labs/feat/base-path
Serve ihasmail from a subpath
This commit is contained in:
+28
-7
@@ -116,12 +116,28 @@ const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scope the session cookie to the mount, not the whole host.
|
||||
*
|
||||
* Under a prefix the browser is talking to a hostname that other applications
|
||||
* share, and a cookie at `/` would be sent to every one of them. Path scoping
|
||||
* is not a security boundary -- anything on the origin can reach the cookie
|
||||
* jar -- but it keeps the credential out of requests that have no business
|
||||
* carrying it, and it lets two ihasmail instances live at `/mail` and
|
||||
* `/mail2` on one host without signing each other out, which a shared cookie
|
||||
* name at `/` would do.
|
||||
*
|
||||
* `/` for the root case: an empty Path is not the same thing and browsers
|
||||
* would fall back to the directory of the request that set it.
|
||||
*/
|
||||
const cookiePath = config.basePath || "/";
|
||||
|
||||
function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
setCookie(c, config.cookieName, value, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: "/",
|
||||
path: cookiePath,
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
@@ -138,7 +154,12 @@ function upstreamFailure(c: Context, err: unknown) {
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
||||
}
|
||||
|
||||
export function createApp(): Hono<Env> {
|
||||
/**
|
||||
* `basePath` is a parameter rather than read straight from the config so the
|
||||
* tests can mount the same app twice, at the root and under a prefix, without
|
||||
* re-importing the module to change one environment variable.
|
||||
*/
|
||||
export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
|
||||
@@ -247,7 +268,7 @@ export function createApp(): Hono<Env> {
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -260,7 +281,7 @@ export function createApp(): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -473,7 +494,7 @@ export function createApp(): Hono<Env> {
|
||||
if (res.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
return passthrough(res);
|
||||
@@ -599,10 +620,10 @@ export function createApp(): Hono<Env> {
|
||||
return c.json({ error: "internal_error" }, 500);
|
||||
});
|
||||
|
||||
app.route("/api", api);
|
||||
app.route(`${basePath}/api`, api);
|
||||
|
||||
// ---------- Static SPA ----------
|
||||
app.get("*", staticHandler(config.staticDir));
|
||||
app.get("*", staticHandler(config.staticDir, basePath));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
/**
|
||||
* `BASE_PATH` is read once, into `config`, so these mount the app by argument
|
||||
* instead of re-importing the module with a different environment. The
|
||||
* root-mounted half is the one that matters most: every instance in existence
|
||||
* is at `/`, and this feature has to be invisible to them.
|
||||
*/
|
||||
|
||||
test("at the root, the API is exactly where it was", async () => {
|
||||
const app = createApp("");
|
||||
const res = await app.request("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("under a prefix, the API moves with it", async () => {
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/mail/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { ok?: boolean };
|
||||
assert.equal(body.ok, true);
|
||||
});
|
||||
|
||||
test("under a prefix, the unprefixed API is gone", async () => {
|
||||
// Not merely unrouted: a proxy that forwards without the prefix, against a
|
||||
// server told to expect one, would otherwise appear to half-work -- the API
|
||||
// answering while the app shell it belongs to 404s.
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/api/health");
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
/*
|
||||
* Whether a route reached the static handler, without depending on there being
|
||||
* a web build in the tree. With one it serves the index; without one it says
|
||||
* the build is missing. Either is proof the request got that far -- a routing
|
||||
* mistake is the 404, and asserting on 200 or 503 would make these tests pass
|
||||
* or fail on whether somebody had run `npm run build` first.
|
||||
*/
|
||||
const reachedTheApp = (status: number) => status === 200 || status === 503;
|
||||
|
||||
test("a deep SPA route under the prefix reaches the static handler", async () => {
|
||||
const app = createApp("/mail");
|
||||
const res = await app.request("/mail/calendar/week/2026-09-01");
|
||||
assert.ok(reachedTheApp(res.status), `expected the app shell, got ${res.status}`);
|
||||
});
|
||||
|
||||
test("a path that only shares the prefix's letters is not the app", async () => {
|
||||
// `/mailbox` under a `/mail` mount belongs to whatever else the proxy
|
||||
// serves on this host; answering it with our shell would shadow it.
|
||||
const app = createApp("/mail");
|
||||
assert.equal((await app.request("/mailbox")).status, 404);
|
||||
assert.equal((await app.request("/")).status, 404);
|
||||
});
|
||||
|
||||
test("the root mount still serves the SPA from the root", async () => {
|
||||
const app = createApp("");
|
||||
assert.ok(reachedTheApp((await app.request("/calendar/week/2026-09-01")).status));
|
||||
assert.ok(reachedTheApp((await app.request("/")).status));
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveVersion } from "../../scripts/version.mjs";
|
||||
import { normalizeBasePath } from "../../scripts/basePath.mjs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
@@ -130,6 +131,19 @@ export const config = {
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
/**
|
||||
* The subpath this instance answers on: `/mail` for a proxy that maps
|
||||
* `https://example.com/mail/` here, and `""` -- the default -- for the root.
|
||||
*
|
||||
* The prefix is expected to arrive intact: a proxy that strips it before
|
||||
* forwarding should leave BASE_PATH unset, because then as far as this
|
||||
* process is concerned it *is* at the root. What must match is the web
|
||||
* build, which bakes the same variable into its asset URLs; a server that
|
||||
* strips a prefix the bundle still asks for serves an app that cannot load
|
||||
* its own scripts. `staticHandler` says so at the first request rather than
|
||||
* leaving a blank page to explain itself.
|
||||
*/
|
||||
basePath: normalizeBasePath(process.env.BASE_PATH),
|
||||
stalwartUrl,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
|
||||
+35
-2
@@ -3,6 +3,7 @@ import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import type { Context, Handler } from "hono";
|
||||
import { stripBasePath } from "../../scripts/basePath.mjs";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -47,9 +48,30 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
export function staticHandler(root: string): Handler {
|
||||
export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
let mismatchWarned = false;
|
||||
|
||||
/**
|
||||
* A build that does not know the prefix loads nothing under it, and says so
|
||||
* with a blank page and a 404 in a console nobody has open. The shell is
|
||||
* already being read here, so checking what it asks for costs one substring
|
||||
* search per rebuild and turns a mystery into a line in the log.
|
||||
*
|
||||
* A warning rather than a refusal: this reads a built artefact to guess at a
|
||||
* misconfiguration, and a wrong guess that stops the server from starting is
|
||||
* worse than the problem it is describing.
|
||||
*/
|
||||
function warnOnBaseMismatch(body: string) {
|
||||
if (mismatchWarned || !basePath) return;
|
||||
if (body.includes(`src="${basePath}/assets/`)) return;
|
||||
mismatchWarned = true;
|
||||
console.warn(
|
||||
`[ihasmail] BASE_PATH is ${basePath}, but the web build in ${absRoot} references its assets elsewhere. ` +
|
||||
`The prefix is baked in at build time: rebuild with BASE_PATH=${basePath} set, or the app will not load.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function serveIndex(c: Context) {
|
||||
try {
|
||||
@@ -57,7 +79,9 @@ export function staticHandler(root: string): Handler {
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
mismatchWarned = false;
|
||||
}
|
||||
warnOnBaseMismatch(indexCache.body);
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
@@ -70,7 +94,16 @@ export function staticHandler(root: string): Handler {
|
||||
|
||||
return async (c) => {
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
|
||||
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
/*
|
||||
* Everything below works in paths relative to the mount, so the prefix
|
||||
* comes off once, here. Anything outside it is a 404 and not the app
|
||||
* shell: under `/mail` this process shares a hostname with whatever else
|
||||
* the proxy serves, and answering `/` or `/other-app/thing` with our
|
||||
* index would shadow a neighbour rather than let it 404 honestly.
|
||||
*/
|
||||
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
const urlPath = stripBasePath(basePath, fullPath);
|
||||
if (urlPath === null) return c.text("Not Found", 404);
|
||||
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
|
||||
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = join(absRoot, rel);
|
||||
|
||||
Reference in New Issue
Block a user