diff --git a/src/server/assets-path.ts b/src/server/assets-path.ts new file mode 100644 index 0000000..ca16a5d --- /dev/null +++ b/src/server/assets-path.ts @@ -0,0 +1,17 @@ +/** Maps a URL path to a file path relative to the assets root, or null. No dot-segments, no dotfiles. */ +export function resolveAssetPath(pathname: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return null; + } + if (decoded.includes("\0") || decoded.includes("\\")) return null; + const segments: string[] = []; + for (const segment of decoded.split("/")) { + if (segment === "") continue; + if (segment.startsWith(".")) return null; + segments.push(segment); + } + return segments.length ? segments.join("/") : "index.html"; +} diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..37abe5b --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,68 @@ +// Web-standard: parses the environment; no Bun or node APIs. + +export interface ServerConfig { + port: number; + hostname: string; + publicUrl: string | null; + storageDir: string | null; + storageMaxBytes: number; + trustProxy: boolean; + assetsDir: string; +} + +export class ConfigError extends Error { + override name = "ConfigError"; +} + +export const DEFAULT_STORAGE_MAX_BYTES = 1024 ** 3; + +export function readConfig(env: Record): ServerConfig { + const publicUrl = parsePublicUrl(env.MBSEED_PUBLIC_URL); + const storageDir = env.MBSEED_STORAGE_DIR?.trim() || null; + if (storageDir && !publicUrl) { + throw new ConfigError("MBSEED_STORAGE_DIR needs MBSEED_PUBLIC_URL: MangaBaka must be able to download stored covers."); + } + return { + port: wholeNumber(env, "MBSEED_PORT", 8787), + hostname: env.MBSEED_HOST?.trim() || "127.0.0.1", + publicUrl, + storageDir, + storageMaxBytes: wholeNumber(env, "MBSEED_STORAGE_MAX_BYTES", DEFAULT_STORAGE_MAX_BYTES), + trustProxy: env.MBSEED_TRUST_PROXY === "1", + assetsDir: env.MBSEED_ASSETS_DIR?.trim() || "dist/web", + }; +} + +function parsePublicUrl(raw: string | undefined): string | null { + const value = raw?.trim(); + if (!value) return null; + let url: URL; + try { + url = new URL(value); + } catch { + throw new ConfigError(`MBSEED_PUBLIC_URL is not a URL: ${value}`); + } + if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash) { + throw new ConfigError(`MBSEED_PUBLIC_URL must be an origin like https://mbseed.example.org, got ${value}`); + } + return url.origin; +} + +function wholeNumber(env: Record, name: string, fallback: number): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + if (!/^\d+$/.test(raw)) throw new ConfigError(`${name} must be a whole number, got "${raw}".`); + return Number(raw); +} + +/** + * The address rate limits key on. Behind our reverse proxy (MBSEED_TRUST_PROXY=1) that is the + * last X-Forwarded-For entry — the one the proxy appended; earlier entries come from the client. + */ +export function clientIp(req: Request, trustProxy: boolean, peer?: string): string { + if (trustProxy) { + const last = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()).filter(Boolean).at(-1); + if (last) return last; + } + return peer ?? "unknown"; +} diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts new file mode 100644 index 0000000..92cd433 --- /dev/null +++ b/src/server/rate-limit.ts @@ -0,0 +1,23 @@ +/** Fixed-window counter per key (client IP). In memory: one server process. */ +export class RateLimiter { + private readonly hits = new Map(); + + constructor(readonly limit: number, readonly windowMs: number, private readonly now: () => number = Date.now) {} + + take(key: string): boolean { + const t = this.now(); + const hit = this.hits.get(key); + if (!hit || t - hit.start >= this.windowMs) { + if (this.hits.size > 10_000) this.prune(t); + this.hits.set(key, { start: t, count: 1 }); + return true; + } + if (hit.count >= this.limit) return false; + hit.count++; + return true; + } + + private prune(t: number): void { + for (const [key, hit] of this.hits) if (t - hit.start >= this.windowMs) this.hits.delete(key); + } +} diff --git a/test/server-config.test.ts b/test/server-config.test.ts new file mode 100644 index 0000000..74ee6c6 --- /dev/null +++ b/test/server-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAssetPath } from "../src/server/assets-path"; +import { ConfigError, clientIp, readConfig } from "../src/server/config"; +import { RateLimiter } from "../src/server/rate-limit"; + +test("RateLimiter: N per window per key", () => { + let now = 0; + const limiter = new RateLimiter(2, 60_000, () => now); + expect([limiter.take("a"), limiter.take("a"), limiter.take("a"), limiter.take("b")]).toEqual([true, true, false, true]); + now = 60_000; + expect(limiter.take("a")).toBe(true); +}); + +describe("readConfig", () => { + test("defaults", () => { + expect(readConfig({})).toEqual({ + port: 8787, hostname: "127.0.0.1", publicUrl: null, storageDir: null, + storageMaxBytes: 1073741824, trustProxy: false, assetsDir: "dist/web", + }); + }); + test("everything set", () => { + expect(readConfig({ + MBSEED_PORT: "9000", MBSEED_HOST: "0.0.0.0", MBSEED_PUBLIC_URL: "https://mbseed.example.org/", + MBSEED_STORAGE_DIR: "/srv/mbseed/covers", MBSEED_STORAGE_MAX_BYTES: "5000000", MBSEED_TRUST_PROXY: "1", MBSEED_ASSETS_DIR: "/srv/mbseed/web", + })).toEqual({ + port: 9000, hostname: "0.0.0.0", publicUrl: "https://mbseed.example.org", storageDir: "/srv/mbseed/covers", + storageMaxBytes: 5000000, trustProxy: true, assetsDir: "/srv/mbseed/web", + }); + }); + test.each([ + [{ MBSEED_STORAGE_DIR: "/x" }, /MBSEED_PUBLIC_URL/], + [{ MBSEED_PUBLIC_URL: "mbseed.example.org" }, /not a URL/], + [{ MBSEED_PUBLIC_URL: "https://example.org/sub/path" }, /origin/], + [{ MBSEED_PORT: "eighty" }, /whole number/], + [{ MBSEED_STORAGE_MAX_BYTES: "1GB" }, /whole number/], + ])("%o is refused", (env, message) => { + expect(() => readConfig(env)).toThrow(ConfigError); + expect(() => readConfig(env)).toThrow(message); + }); +}); + +describe("clientIp", () => { + const req = (xff?: string) => new Request("http://x/", { headers: xff ? { "x-forwarded-for": xff } : {} }); + test("ignores X-Forwarded-For unless the proxy is trusted", () => { + expect(clientIp(req("1.2.3.4"), false, "10.0.0.1")).toBe("10.0.0.1"); + }); + test("trusts only the last entry, the one our proxy appended", () => { + // Review Focus 4: the client sent "1.2.3.4" itself; the proxy appended the real address. + expect(clientIp(req("1.2.3.4, 203.0.113.9"), true, "127.0.0.1")).toBe("203.0.113.9"); + expect(clientIp(req(), true, "127.0.0.1")).toBe("127.0.0.1"); + expect(clientIp(req(), false)).toBe("unknown"); + }); +}); + +describe("resolveAssetPath", () => { + test.each([ + ["/", "index.html"], + ["/index.html", "index.html"], + ["/main-ab12cd.js", "main-ab12cd.js"], + ["/mbseed.user.js", "mbseed.user.js"], + ["/fonts/x.woff2", "fonts/x.woff2"], + ])("%p → %p", (path, file) => { + expect(resolveAssetPath(path)).toBe(file); + }); + // Review Focus 5 + test.each(["/..%2f.env", "/%2e%2e/package.json", "/a/../../b", "/.env", "/%00x", "/a\\..\\b", "/%E0%A4%A"])("%p is refused", (path) => { + expect(resolveAssetPath(path)).toBeNull(); + }); +});