Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected

This commit is contained in:
2026-06-16 15:18:20 +02:00
parent fe89dd1c06
commit 3cdefff233
6 changed files with 50 additions and 8 deletions

View File

@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { createApp } from "./app.ts";
import type { Plugin } from "./plugin.ts";
import { contentTypeFor, resolveStaticPath } from "./static.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
@@ -121,8 +121,10 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true });
// The view also include()s a core building-block partial, proving plugin views reuse them.
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
t.after(() => rmSync(dir, { force: true, recursive: true }));
const url = await startApp(t, [demoPlugin], dir);
@@ -146,6 +148,13 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.match(page, /Hello Plainpages/);
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
// static asset served from the plugin's own public/ at /public/<id>/
const css = await fetch(url + "/public/demo/app.css");
assert.equal(css.status, 200);
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
assert.match(await css.text(), /\.demo/);
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
// gated route with no session → 403
assert.equal((await fetch(url + "/demo/secret")).status, 403);
@@ -171,3 +180,11 @@ test("contentTypeFor maps known and unknown extensions", () => {
assert.match(contentTypeFor("a.css"), /text\/css/);
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
});
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
const ids = new Set(["scheduling"]);
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});