Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildDashboardModel } from "./dashboard.ts";
|
||||
import { PLUGINS_DIR } from "./discovery.ts";
|
||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||
import { serveStatic } from "./static.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "./view-resolver.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -25,6 +25,7 @@ export interface AppOptions {
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
const cache = options.cache ?? false;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||
@@ -48,7 +49,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
await serveStatic(publicDir, pathname.slice("/public/".length), res, method === "HEAD");
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,24 @@ export function resolveStaticPath(dir: string, requestedPath: string): string |
|
||||
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
|
||||
}
|
||||
|
||||
export interface StaticRoute {
|
||||
dir: string;
|
||||
subPath: string;
|
||||
}
|
||||
|
||||
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
||||
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
||||
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
|
||||
// and traversal-guards the sub-path as before.
|
||||
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
||||
const slash = restPath.indexOf("/");
|
||||
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
||||
if (pluginIds.has(first)) {
|
||||
return { dir: join(pluginsDir, first, "public"), subPath: slash === -1 ? "" : restPath.slice(slash + 1) };
|
||||
}
|
||||
return { dir: publicDir, subPath: restPath };
|
||||
}
|
||||
|
||||
function plain(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user