diff --git a/tools/render/README.md b/tools/render/README.md
index 7cafc3f5..6ced1dd9 100644
--- a/tools/render/README.md
+++ b/tools/render/README.md
@@ -273,7 +273,17 @@ docker run ... -v /path/to/release-files:/files:ro -e RENDER_FILES=/files websit
A file missing from the folder is not reported as such by the page: the render
waits out `RENDER_TIMEOUT`. So check the folder holds every file before a bulk
-run. `/health` says which source is in use (`files`).
+run. The service refuses to start if `RENDER_FILES` is not a directory, and
+`/health` says which source is in use (`files`). A link inside the folder is
+followed only if it stays inside it.
+
+Only requests to the download site are answered from the folder; a deployment
+whose profile takes its assets from the site's own host (`assetsFromHost`,
+`downloadBase`) is not redirected. The page also asks the download site for
+icons and structure films, which the render page does not draw; from a folder
+without them they are 404s. Figures cached from the download site and from a
+folder have different cache keys, so switching `RENDER_FILES` on or off never
+serves one for the other.
Query parameters: `token`, `scale`, `subpathways=false`, and for GIF `delay`
(ms per frame) and `maxSize` (longest side). All of them are part of the cache
diff --git a/tools/render/local-files.spec.ts b/tools/render/local-files.spec.ts
index 9ff47ec7..7716b4ef 100644
--- a/tools/render/local-files.spec.ts
+++ b/tools/render/local-files.spec.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -12,6 +12,11 @@ beforeAll(async () => {
await writeFile(path.join(root, '98', 'diagram', 'R-HSA-1.json'), '{"nodes":[]}');
await writeFile(path.join(root, '98', 'ehld', 'R-HSA-2.svg'), '');
await writeFile(path.join(root, 'outside.txt'), 'not under 98');
+ // A sibling whose name starts with the folder's: `98x` is not inside `98`.
+ await mkdir(path.join(root, '98x'));
+ await writeFile(path.join(root, '98x', 'f.txt'), 'a sibling');
+ // A link inside the folder to a file outside it.
+ await symlink(path.join(root, 'outside.txt'), path.join(root, '98', 'link.txt'));
});
afterAll(() => rm(root, { recursive: true, force: true }));
@@ -47,6 +52,8 @@ describe('the download site from a local folder', () => {
// An encoded slash survives the URL's own normalising and decodes to a
// step out of the folder, to a file that exists.
'/%2e%2e%2foutside.txt',
+ '/%2e%2e%2f98x%2ff.txt',
+ '/link.txt',
'/%E0%A4%A',
]) {
expect((await localAnswer(path.join(root, '98'), at(escape))).status, escape).toBe(404);
diff --git a/tools/render/local-files.ts b/tools/render/local-files.ts
index 64ef510f..1296d3e9 100644
--- a/tools/render/local-files.ts
+++ b/tools/render/local-files.ts
@@ -9,9 +9,10 @@
* until the release is published. So when a folder is given, every request the
* page makes to the download site is answered from it, laid out as the site is
* (`/98/diagram/R-HSA-69620.json`), and a file the folder lacks is a
- * 404 -- never a request to the site.
+ * 404 -- never a request to the site. A link inside the folder is followed only
+ * if it stays inside it.
*/
-import { readFile } from 'node:fs/promises';
+import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
/** The download site, as the site's profiles name it (environments.ts, `S3`). */
@@ -37,7 +38,12 @@ export async function localAnswer(
url: URL
): Promise<{ status: number; contentType: string; body: Buffer | string }> {
const missing = { status: 404, contentType: 'text/plain', body: 'not in the local files' };
- const base = path.resolve(root);
+ let base: string;
+ try {
+ base = await realpath(root);
+ } catch {
+ return missing;
+ }
let relative: string;
try {
relative = decodeURIComponent(url.pathname);
@@ -47,6 +53,8 @@ export async function localAnswer(
const file = path.resolve(base, `.${relative}`);
if (!file.startsWith(base + path.sep)) return missing;
try {
+ // Where the file really is: a link out of the folder is no file of it.
+ if (!(await realpath(file)).startsWith(base + path.sep)) return missing;
const body = await readFile(file);
const contentType = TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
return { status: 200, contentType, body };
diff --git a/tools/render/render-core.spec.ts b/tools/render/render-core.spec.ts
index 86c4db0f..094b9ab8 100644
--- a/tools/render/render-core.spec.ts
+++ b/tools/render/render-core.spec.ts
@@ -1,4 +1,7 @@
-import type { Page } from '@playwright/test';
+import type { Page, Route } from '@playwright/test';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { render } from './render-core.ts';
@@ -79,11 +82,13 @@ describe('a figure of another species', () => {
describe("the release's files", () => {
// A stand-in page that records its routes and stops at the first navigation.
function routed() {
- const routes: { matcher: unknown; handler: unknown }[] = [];
+ const routes: { matcher: (url: URL) => boolean; handler: (route: Route) => Promise }[] =
+ [];
+ let seen: typeof routes = [];
const page = {
on() {},
off() {},
- async route(matcher: unknown, handler: unknown) {
+ async route(matcher: (url: URL) => boolean, handler: (route: Route) => Promise) {
routes.push({ matcher, handler });
},
async unroute(matcher: unknown, handler: unknown) {
@@ -91,26 +96,53 @@ describe("the release's files", () => {
if (i >= 0) routes.splice(i, 1);
},
async goto() {
- seen = routes.length;
+ seen = [...routes];
throw new Error('stop here');
},
};
- let seen = 0;
return { page, routes, seen: () => seen };
}
it('are read from the folder while the page draws, and the route removed after', async () => {
- const { page, routes, seen } = routed();
- await render(stand(page), { base: 'http://site', pathway: 'R-HSA-1', files: '/files' }).catch(
- () => undefined
- );
- expect(seen()).toBe(1);
- expect(routes).toHaveLength(0);
+ const root = await mkdtemp(path.join(tmpdir(), 'render-core-files-'));
+ try {
+ await mkdir(path.join(root, '98', 'diagram'), { recursive: true });
+ await writeFile(path.join(root, '98', 'diagram', 'R-HSA-1.json'), '{"x":1}');
+ const { page, routes, seen } = routed();
+ await render(stand(page), { base: 'http://site', pathway: 'R-HSA-1', files: root }).catch(
+ () => undefined
+ );
+ const [only] = seen();
+ expect(seen()).toHaveLength(1);
+ expect(only?.matcher(new URL('https://download.reactome.org/98/diagram/R-HSA-1.json'))).toBe(
+ true
+ );
+ expect(only?.matcher(new URL('http://site/ContentService/data/database/version'))).toBe(
+ false
+ );
+ const fulfilled: unknown[] = [];
+ const route = (url: string) =>
+ ({
+ request: () => ({ url: () => url }),
+ fulfill: async (answer: unknown) => void fulfilled.push(answer),
+ continue: async () => void fulfilled.push('continued'),
+ }) as unknown as Route;
+ await only?.handler(route('https://download.reactome.org/98/diagram/R-HSA-1.json'));
+ await only?.handler(route('https://download.reactome.org/98/diagram/R-HSA-2.json'));
+ expect(fulfilled).toMatchObject([
+ { status: 200, contentType: 'application/json' },
+ { status: 404 },
+ ]);
+ expect(String((fulfilled[0] as { body: Buffer }).body)).toBe('{"x":1}');
+ expect(routes).toHaveLength(0);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
});
it('are left to the download site when no folder is given', async () => {
const { page, seen } = routed();
await render(stand(page), { base: 'http://site', pathway: 'R-HSA-1' }).catch(() => undefined);
- expect(seen()).toBe(0);
+ expect(seen()).toHaveLength(0);
});
});
diff --git a/tools/render/render-core.ts b/tools/render/render-core.ts
index 4f486d32..64f1da38 100644
--- a/tools/render/render-core.ts
+++ b/tools/render/render-core.ts
@@ -186,12 +186,12 @@ export async function render(
};
page.on('pageerror', onPageError);
page.on('console', onConsole);
- const fromFiles = async (route: Route) => {
- if (!files) return route.continue();
- const { status, contentType, body } = await localAnswer(files, new URL(route.request().url()));
+ const fromFiles = async (route: Route, root: string) => {
+ const { status, contentType, body } = await localAnswer(root, new URL(route.request().url()));
return route.fulfill({ status, contentType, body });
};
- if (files) await page.route(isDownload, fromFiles);
+ const handler = files ? (route: Route) => fromFiles(route, files) : undefined;
+ if (handler) await page.route(isDownload, handler);
try {
await page.goto(
@@ -270,7 +270,7 @@ export async function render(
} finally {
page.off('pageerror', onPageError);
page.off('console', onConsole);
- if (files) await page.unroute(isDownload, fromFiles);
+ if (handler) await page.unroute(isDownload, handler);
}
}
diff --git a/tools/render/service.ts b/tools/render/service.ts
index 43f6c9e0..8b4d7b13 100644
--- a/tools/render/service.ts
+++ b/tools/render/service.ts
@@ -33,6 +33,9 @@
* RENDER_CONCURRENCY simultaneous renders, default 2
* RENDER_QUEUE pending renders before 503, default 8
* RENDER_CACHE_MAX bytes of cache to keep, default 2 GB (0 disables)
+ * RENDER_TIMEOUT ms before a render is abandoned, default 45000
+ * RENDER_FILES a folder laid out as the download site, read instead of it
+ * (local-files.ts); unset, the page fetches from the site
*
* Query parameters: token, scale, subpathways=false, dark=true, select=,
* highlight=,...,
@@ -54,7 +57,7 @@ import {
} from './params.ts';
import { chromium, type Browser } from '@playwright/test';
import { createHash } from 'node:crypto';
-import { readFileSync, readdirSync, realpathSync } from 'node:fs';
+import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
mkdir,
@@ -108,6 +111,12 @@ const CACHE_KEY = process.env['RENDER_CACHE_KEY'] || 'v12';
// for the page to read instead of the site (local-files.ts). Unset, it fetches
// them from the site, as a reader's browser does.
const FILES = process.env['RENDER_FILES'] || undefined;
+// A mistyped folder would otherwise fail every render after the full timeout,
+// with /health saying the files are local.
+if (FILES && !statSync(FILES, { throwIfNoEntry: false })?.isDirectory()) {
+ console.error(`RENDER_FILES is not a directory: ${FILES}`);
+ process.exit(2);
+}
const CONCURRENCY = Number(process.env['RENDER_CONCURRENCY'] || 2);
// Generous next to a real render, which is 3-8s, but far short of the two
// minutes a page that never becomes ready would otherwise hold a browser for.
@@ -278,6 +287,10 @@ function cacheKey({
highlight,
// Only when set, so every figure cached before it keeps its key.
...(species ? [species] : []),
+ // Where the page's files came from: a figure drawn from the download
+ // site is not served as one drawn from a release's own files, nor the
+ // reverse. Only when set, as above.
+ ...(FILES ? ['files'] : []),
])
)
.digest('hex');