Add per-plugin view resolver (todo §2); render plugins/<id>/views/<view>.ejs with nested names + traversal guard, core partials reachable via include()
This commit is contained in:
@@ -121,7 +121,8 @@ 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 });
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), "<h1>Hello <%= who %></h1>");
|
||||
// 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") %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const url = await startApp(t, [demoPlugin], dir);
|
||||
|
||||
@@ -140,8 +141,10 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
|
||||
assert.equal(go.status, 303);
|
||||
assert.equal(go.headers.get("location"), "/demo/hello/world");
|
||||
|
||||
// view rendered from the plugin's own views/
|
||||
assert.match(await (await fetch(url + "/demo/page")).text(), /Hello Plainpages/);
|
||||
// view rendered from the plugin's own views/, including a core partial
|
||||
const page = await (await fetch(url + "/demo/page")).text();
|
||||
assert.match(page, /Hello Plainpages/);
|
||||
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
|
||||
|
||||
// gated route with no session → 403
|
||||
assert.equal((await fetch(url + "/demo/secret")).status, 403);
|
||||
|
||||
10
src/app.ts
10
src/app.ts
@@ -8,6 +8,7 @@ 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 { renderPluginView } from "./view-resolver.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
@@ -31,10 +32,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache });
|
||||
|
||||
// A `view` RouteResult resolves against the plugin's own views/ (the richer per-plugin
|
||||
// resolver — core-partial includes, subfolders — is the next §2 item).
|
||||
const renderPluginView = (plugin: Plugin) => (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(pluginsDir, plugin.id, "views", `${view}.ejs`), data, { cache });
|
||||
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -61,7 +61,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
const result = await match.route.handler(ctx);
|
||||
await sendResult(res, result ?? null, renderPluginView(match.plugin));
|
||||
await sendResult(res, result ?? null, (view, data) => renderView(match.plugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
44
src/view-resolver.test.ts
Normal file
44
src/view-resolver.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { renderPluginView, resolveViewPath } from "./view-resolver.ts";
|
||||
|
||||
const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
|
||||
|
||||
test("resolveViewPath resolves names + nested subfolders within the plugin's views dir", () => {
|
||||
const dir = "/srv/plugins";
|
||||
assert.equal(resolveViewPath(dir, "demo", "page"), "/srv/plugins/demo/views/page.ejs");
|
||||
assert.equal(resolveViewPath(dir, "demo", "shifts/edit"), "/srv/plugins/demo/views/shifts/edit.ejs");
|
||||
assert.equal(resolveViewPath(dir, "demo", "page.ejs"), "/srv/plugins/demo/views/page.ejs"); // extension not doubled
|
||||
});
|
||||
|
||||
test("resolveViewPath rejects traversal and control chars", () => {
|
||||
assert.equal(resolveViewPath("/srv/plugins", "demo", "../../secret"), null);
|
||||
assert.equal(resolveViewPath("/srv/plugins", "demo", "a\x00b"), null);
|
||||
});
|
||||
|
||||
test("renderPluginView: a (nested) view includes a core building-block partial and its own partial", async (t: TestContext) => {
|
||||
const pluginsDir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
||||
t.after(() => rmSync(pluginsDir, { force: true, recursive: true }));
|
||||
const views = join(pluginsDir, "demo", "views");
|
||||
mkdirSync(join(views, "partials"), { recursive: true });
|
||||
mkdirSync(join(views, "sub"), { recursive: true });
|
||||
writeFileSync(join(views, "partials", "local.ejs"), "<span class=local><%= who %></span>");
|
||||
writeFileSync(
|
||||
join(views, "sub", "page.ejs"),
|
||||
`<%- include("partials/theme-switch") %><%- include("partials/local", { who }) %>`,
|
||||
);
|
||||
|
||||
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir });
|
||||
const html = await render("demo", "sub/page", { who: "Plug" });
|
||||
assert.match(html, /role="radiogroup"/); // core partial, resolved from coreViewsDir
|
||||
assert.match(html, /<span class=local>Plug<\/span>/); // the plugin's own partial, with data
|
||||
});
|
||||
|
||||
test("renderPluginView throws on an out-of-bounds view name", async () => {
|
||||
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir: "/srv/plugins" });
|
||||
await assert.rejects(render("demo", "../../etc/passwd", {}), /invalid view name/);
|
||||
});
|
||||
38
src/view-resolver.ts
Normal file
38
src/view-resolver.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Per-plugin view resolver (todo §2): render plugins/<id>/views/<view>.ejs and let a plugin view
|
||||
// reuse the core building-block partials. EJS resolves an include() relative to the current file
|
||||
// first, then against the `views` roots — so passing [plugin views, core views] makes both the
|
||||
// plugin's own partials/subfolders and every core partial reachable (the plugin root first, so a
|
||||
// plugin may deliberately shadow a core partial). The §2 router calls this for a `view` RouteResult.
|
||||
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const CONTROL_CHARS = /[\x00-\x1f]/;
|
||||
|
||||
// Resolve a view name → absolute .ejs path within plugins/<id>/views, or null if it escapes that
|
||||
// dir (traversal) or carries a control char. Names may be nested ("shifts/edit"); a missing
|
||||
// extension defaults to .ejs.
|
||||
export function resolveViewPath(pluginsDir: string, pluginId: string, view: string): string | null {
|
||||
if (CONTROL_CHARS.test(view)) return null;
|
||||
const viewsDir = join(pluginsDir, pluginId, "views");
|
||||
const file = join(viewsDir, view.endsWith(".ejs") ? view : `${view}.ejs`);
|
||||
const rel = relative(viewsDir, file);
|
||||
return rel.startsWith("..") || isAbsolute(rel) ? null : file;
|
||||
}
|
||||
|
||||
export interface PluginViewOptions {
|
||||
cache: boolean;
|
||||
coreViewsDir: string; // core views/ root — its partials become include() roots for plugin views
|
||||
pluginsDir: string;
|
||||
}
|
||||
|
||||
// Bind the dirs/cache once; the returned fn renders a named view for a given plugin id. Rejects on
|
||||
// an out-of-bounds view name (developer error — fail loud, like the rest of the host).
|
||||
export function renderPluginView(options: PluginViewOptions) {
|
||||
return async (pluginId: string, view: string, data: Record<string, unknown>): Promise<string> => {
|
||||
const file = resolveViewPath(options.pluginsDir, pluginId, view);
|
||||
if (file === null) throw new Error(`invalid view name "${view}" for plugin "${pluginId}"`);
|
||||
const views = [join(options.pluginsDir, pluginId, "views"), options.coreViewsDir];
|
||||
return ejs.renderFile(file, data, { cache: options.cache, views });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user