mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): add contained project preview URLs
This commit is contained in:
parent
0bffe6ba40
commit
755d8173df
5 changed files with 319 additions and 59 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type { Express } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Express, Response } from 'express';
|
||||
import {
|
||||
defaultScenarioPluginIdForProjectMetadata,
|
||||
type PluginManifest,
|
||||
|
|
@ -1208,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
||||
const { buildDocumentPreview } = ctx.documents;
|
||||
const { validateArtifactManifestInput } = ctx.artifacts;
|
||||
const projectPreviewIframeSandbox = 'allow-scripts allow-forms';
|
||||
const projectPreviewCsp = [
|
||||
`sandbox ${projectPreviewIframeSandbox}`,
|
||||
"default-src 'self' data: blob:",
|
||||
"img-src 'self' data: blob:",
|
||||
"media-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"connect-src 'none'",
|
||||
"form-action 'none'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u;
|
||||
|
||||
function setProjectPreviewHeaders(res: Response) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', projectPreviewCsp);
|
||||
}
|
||||
|
||||
async function sendProjectFile(
|
||||
req: any,
|
||||
res: Response,
|
||||
projectId: string,
|
||||
relPath: string,
|
||||
metadata?: unknown,
|
||||
beforeSend?: (mime: string) => void,
|
||||
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string,
|
||||
) {
|
||||
const meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
projectId,
|
||||
relPath,
|
||||
metadata,
|
||||
);
|
||||
beforeSend?.(meta.mime);
|
||||
|
||||
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Type', meta.mime);
|
||||
|
||||
if (meta.size === 0) {
|
||||
res.setHeader('Content-Length', '0');
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const range = parseByteRange(req.headers.range, meta.size);
|
||||
|
||||
if (range === 'unsatisfiable') {
|
||||
res.setHeader('Content-Range', `bytes */${meta.size}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
|
||||
let start;
|
||||
let end;
|
||||
let statusCode;
|
||||
if (range) {
|
||||
({ start, end } = range);
|
||||
statusCode = 206;
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
|
||||
res.setHeader('Content-Length', String(end - start + 1));
|
||||
} else {
|
||||
start = 0;
|
||||
end = meta.size - 1;
|
||||
statusCode = 200;
|
||||
res.setHeader('Content-Length', String(meta.size));
|
||||
}
|
||||
|
||||
res.status(statusCode);
|
||||
const stream = fs.createReadStream(meta.filePath, { start, end });
|
||||
stream.on('error', (streamErr: any) => {
|
||||
if (!res.headersSent) {
|
||||
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
|
||||
} else {
|
||||
res.destroy(streamErr);
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata);
|
||||
res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer);
|
||||
}
|
||||
|
||||
function previewFilePathForProject(project: any, queryFile: unknown): string {
|
||||
if (typeof queryFile === 'string' && queryFile.trim().length > 0) {
|
||||
return queryFile;
|
||||
}
|
||||
const entryFile = project?.metadata?.entryFile;
|
||||
return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html';
|
||||
}
|
||||
|
||||
function encodeProjectPathForUrl(filePath: string): string {
|
||||
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
||||
}
|
||||
|
||||
// Project files. Each project owns a flat folder under .od/projects/<id>/
|
||||
// containing every file the user has uploaded, pasted, sketched, or that
|
||||
|
|
@ -1266,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/preview-url', async (req, res) => {
|
||||
try {
|
||||
const project = getProject(db, req.params.id);
|
||||
if (!project) {
|
||||
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||
return;
|
||||
}
|
||||
const requestedPath = previewFilePathForProject(project, req.query.file);
|
||||
const meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
project.id,
|
||||
requestedPath,
|
||||
project.metadata,
|
||||
);
|
||||
const scope = randomUUID();
|
||||
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
|
||||
const body = {
|
||||
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
|
||||
file: meta.name,
|
||||
csp: projectPreviewCsp,
|
||||
iframeSandbox: projectPreviewIframeSandbox,
|
||||
opaqueOrigin: true,
|
||||
};
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(body);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
sendApiError(
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => {
|
||||
try {
|
||||
const params = req.params as unknown as { 0?: string; 1?: string; 2?: string };
|
||||
const projectId = String(params[0] ?? '');
|
||||
const scope = String(params[1] ?? '');
|
||||
const relPath = String(params[2] ?? '');
|
||||
if (!previewScopeRe.test(scope)) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
|
||||
return;
|
||||
}
|
||||
const project = getProject(db, projectId);
|
||||
if (!project) {
|
||||
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||
return;
|
||||
}
|
||||
if (req.headers.origin === 'null') {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
await sendProjectFile(
|
||||
req,
|
||||
res,
|
||||
project.id,
|
||||
relPath,
|
||||
project.metadata,
|
||||
() => setProjectPreviewHeaders(res),
|
||||
);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
sendApiError(
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
||||
// (no preflight needed), but an explicit handler future-proofs the route if
|
||||
|
|
@ -1293,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
res.header('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
|
||||
const meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
await sendProjectFile(
|
||||
req,
|
||||
res,
|
||||
projectId,
|
||||
relPath,
|
||||
project?.metadata,
|
||||
);
|
||||
|
||||
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Type', meta.mime);
|
||||
|
||||
if (meta.size === 0) {
|
||||
res.setHeader('Content-Length', '0');
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const range = parseByteRange(req.headers.range, meta.size);
|
||||
|
||||
if (range === 'unsatisfiable') {
|
||||
res.setHeader('Content-Range', `bytes */${meta.size}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
|
||||
let start;
|
||||
let end;
|
||||
let statusCode;
|
||||
if (range) {
|
||||
({ start, end } = range);
|
||||
statusCode = 206;
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
|
||||
res.setHeader('Content-Length', String(end - start + 1));
|
||||
} else {
|
||||
start = 0;
|
||||
end = meta.size - 1;
|
||||
statusCode = 200;
|
||||
res.setHeader('Content-Length', String(meta.size));
|
||||
}
|
||||
|
||||
res.status(statusCode);
|
||||
const stream = fs.createReadStream(meta.filePath, { start, end });
|
||||
stream.on('error', (streamErr: any) => {
|
||||
if (!res.headersSent) {
|
||||
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
|
||||
} else {
|
||||
res.destroy(streamErr);
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
||||
undefined,
|
||||
(file) => {
|
||||
if (
|
||||
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
||||
/^text\/html(?:;|$)/i.test(file.mime)
|
||||
) {
|
||||
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
|
||||
return;
|
||||
return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
|
||||
}
|
||||
res.type(file.mime).send(file.buffer);
|
||||
return file.buffer;
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
sendApiError(
|
||||
|
|
|
|||
|
|
@ -626,7 +626,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta
|
|||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||
const file = await resolveSafeReal(dir, name);
|
||||
const st = await stat(file);
|
||||
const rel = toProjectPath(path.relative(dir, file));
|
||||
const rootReal = await realpath(dir).catch(() => dir);
|
||||
const rel = toProjectPath(path.relative(rootReal, file));
|
||||
return {
|
||||
filePath: file,
|
||||
name: rel,
|
||||
|
|
|
|||
|
|
@ -4174,7 +4174,7 @@ export async function startServer({
|
|||
// Routes that serve content to sandboxed iframes (Origin: null) for
|
||||
// read-only purposes. All other /api routes reject Origin: null.
|
||||
const _NULL_ORIGIN_SAFE_GET_RE =
|
||||
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
||||
/^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/;
|
||||
|
||||
// Reject cross-origin requests to API endpoints.
|
||||
// Health/version remain open for monitoring probes.
|
||||
|
|
|
|||
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
122
apps/daemon/tests/project-preview-containment.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
describe('project preview containment routes', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const projectsToClean: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
};
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of projectsToClean.splice(0)) {
|
||||
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function createProject(metadata: Record<string, unknown> = {}): Promise<string> {
|
||||
const id = `preview-containment-${randomUUID()}`;
|
||||
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
name: 'Preview containment project',
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
projectsToClean.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function writeProjectFile(projectId: string, name: string, content: string): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, content }),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
}
|
||||
|
||||
it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => {
|
||||
const projectId = await createProject({ entryFile: 'pages/index.html' });
|
||||
await writeProjectFile(
|
||||
projectId,
|
||||
'pages/index.html',
|
||||
'<!doctype html><title>Preview</title><link rel="stylesheet" href="../styles/app.css">',
|
||||
);
|
||||
await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }');
|
||||
|
||||
const urlResponse = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`,
|
||||
);
|
||||
expect(urlResponse.ok).toBe(true);
|
||||
expect(urlResponse.headers.get('cache-control')).toBe('no-store');
|
||||
const body = await urlResponse.json() as {
|
||||
url: string;
|
||||
file: string;
|
||||
csp: string;
|
||||
iframeSandbox: string;
|
||||
opaqueOrigin: true;
|
||||
};
|
||||
|
||||
expect(body.file).toBe('pages/index.html');
|
||||
expect(body.url).toContain(`/api/projects/${projectId}/preview/`);
|
||||
expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u);
|
||||
expect(body.iframeSandbox).toBe('allow-scripts allow-forms');
|
||||
expect(body.iframeSandbox).not.toContain('allow-same-origin');
|
||||
expect(body.csp).toContain('sandbox allow-scripts allow-forms');
|
||||
expect(body.csp).toContain("connect-src 'none'");
|
||||
expect(body.csp).not.toContain('allow-same-origin');
|
||||
expect(body.opaqueOrigin).toBe(true);
|
||||
|
||||
const previewResponse = await fetch(`${baseUrl}${body.url}`, {
|
||||
headers: { Origin: 'null' },
|
||||
});
|
||||
expect(previewResponse.status).toBe(200);
|
||||
expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(previewResponse.headers.get('cache-control')).toBe('no-store');
|
||||
expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
const csp = previewResponse.headers.get('content-security-policy') ?? '';
|
||||
expect(csp).toContain('sandbox allow-scripts allow-forms');
|
||||
expect(csp).toContain("connect-src 'none'");
|
||||
expect(csp).not.toContain('allow-same-origin');
|
||||
expect(await previewResponse.text()).toContain('<title>Preview</title>');
|
||||
|
||||
const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1];
|
||||
expect(scope).toBeTruthy();
|
||||
const assetResponse = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`,
|
||||
{ headers: { Origin: 'null' } },
|
||||
);
|
||||
expect(assetResponse.status).toBe(200);
|
||||
expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(assetResponse.headers.get('content-type')).toContain('text/css');
|
||||
expect(await assetResponse.text()).toContain('color: black');
|
||||
});
|
||||
|
||||
it('rejects invalid preview scopes and escaping preview-url paths', async () => {
|
||||
const projectId = await createProject();
|
||||
await writeProjectFile(projectId, 'index.html', '<!doctype html>');
|
||||
|
||||
const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`);
|
||||
expect(invalidScope.status).toBe(400);
|
||||
|
||||
const escapingPath = await fetch(
|
||||
`${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`,
|
||||
);
|
||||
expect(escapingPath.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
@ -81,6 +81,14 @@ export interface ProjectExportManifestResponse {
|
|||
artifacts: ProjectExportManifestArtifact[];
|
||||
}
|
||||
|
||||
export interface ProjectPreviewUrlResponse {
|
||||
url: string;
|
||||
file: string;
|
||||
csp: string;
|
||||
iframeSandbox: string;
|
||||
opaqueOrigin: true;
|
||||
}
|
||||
|
||||
export interface ProjectFileResponse {
|
||||
file: ProjectFile;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue