Skip to content
Merged
75 changes: 72 additions & 3 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Express, Request, Response } from 'express';
import type { LintArtifactRequest, LintArtifactResponse } from '@open-design/contracts';
import {
PREVIEW_OBSERVABILITY_BRIDGE_MARKER,
buildPreviewBaseHrefBridge,
buildPreviewObservabilityBridge,
} from '@open-design/contracts/runtime/preview-observability';
import {
Expand Down Expand Up @@ -5446,6 +5447,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
projectId: string,
ownerFilePath: string,
scope: string,
expiresAt: number,
): string {
// Respect an artifact-authored base URL. Only generated documents without
// one need the containment base that keeps runtime-created relative URLs
Expand All @@ -5456,10 +5458,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
? ''
: `${encodeProjectPathForUrl(ownerDir)}/`;
const baseTag = `<base href="/api/projects/${encodeURIComponent(projectId)}`
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}">`;
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}" data-od-project-preview-base>`;
const baseHref = `/api/projects/${encodeURIComponent(projectId)}`
+ `/preview/${encodeURIComponent(scope)}/${dirSuffix}`;
const bridge = buildPreviewBaseHrefBridge({ href: baseHref, expiresAt });
const head = /<head\b[^>]*>/i;
if (head.test(html)) return html.replace(head, (tag) => `${tag}${baseTag}`);
return `${baseTag}${html}`;
if (head.test(html)) return html.replace(head, (tag) => `${tag}${baseTag}${bridge}`);
return `${baseTag}${bridge}${html}`;
}

function rewriteWorkspaceScopedHtmlAssetUrls(
Expand Down Expand Up @@ -5832,13 +5837,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
workspaceMemberId: requestContext.workspaceMemberId,
},
);
const expiresAt = projectPreviewScopes.expiresAt(project.id, scope);
if (expiresAt === undefined) {
sendApiError(res, 503, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
return;
}
/** @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,
expiresAt,
};
res.setHeader('Cache-Control', 'no-store');
res.json(body);
Expand All @@ -5853,6 +5864,61 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
}
});

app.post('/api/projects/:id/preview/:scope/renew', async (req, res) => {
try {
const projectId = String(req.params.id ?? '');
const scope = String(req.params.scope ?? '');
// The scope is embedded in untrusted preview HTML. Requiring a custom
// header makes renewal a host-only operation: an opaque-origin iframe
// cannot set it without a CORS preflight, and this route grants no CORS.
if (req.get('x-od-preview-scope-renewal') !== '1') {
sendApiError(res, 403, 'FORBIDDEN', 'preview scope renewal requires host authorization');
return;
}
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;
}
const previewWorkspace = projectPreviewScopes.resolve(project.id, scope);
if (previewWorkspace === undefined) {
sendApiError(res, 404, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
return;
}
const authorityRequest = previewWorkspace
? {
query: {
...req.query,
workspaceId: previewWorkspace.workspaceId,
workspaceMemberId: previewWorkspace.workspaceMemberId,
},
get: req.get.bind(req),
}
: req;
if (!await authorizeProjectRequest(
authorityRequest,
res,
project.id,
{ mode: 'read', allowNavigationQuery: true },
)) return;
const expiresAt = projectPreviewScopes.renew(project.id, scope);
if (expiresAt === undefined) {
sendApiError(res, 404, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found');
return;
}
/** @type {import('@open-design/contracts').ProjectPreviewScopeRenewResponse} */
const body = { expiresAt };
res.setHeader('Cache-Control', 'no-store');
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});

app.get(/^\/api\/projects\/([^/]+)\/text-preview\/(.+)$/u, async (req, res) => {
let handle: import('fs/promises').FileHandle | null = null;
try {
Expand Down Expand Up @@ -6082,11 +6148,14 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
}
: null;
const scope = projectPreviewScopes.mint(projectId, previewWorkspace);
const expiresAt = projectPreviewScopes.expiresAt(projectId, scope);
if (expiresAt === undefined) return html;
return injectProjectPreviewBase(
html,
projectId,
relPath,
scope,
expiresAt,
);
},
true, // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets
Expand Down
6 changes: 6 additions & 0 deletions apps/daemon/src/server-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ export interface ProjectPreviewScopeDeps {
options?: { readonly ttlMs?: number },
) => string;
revoke: (scope: string) => void;
expiresAt: (projectId: string, scope: string) => number | undefined;
renew: (
projectId: string,
scope: string,
options?: { readonly ttlMs?: number },
) => number | undefined;
validate: (projectId: string, scope: string) => boolean;
resolve: (
projectId: string,
Expand Down
23 changes: 23 additions & 0 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2157,6 +2157,29 @@ function createProjectPreviewScopeRegistry() {
revoke(scope) {
scopes.delete(String(scope || ''));
},
expiresAt(projectId, scope) {
const key = String(scope || '');
const entry = scopes.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
scopes.delete(key);
return undefined;
}
if (entry.projectId !== String(projectId)) return undefined;
return entry.expiresAt;
},
renew(projectId, scope, options = {}) {
const key = String(scope || '');
const entry = scopes.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
scopes.delete(key);
return undefined;
}
if (entry.projectId !== String(projectId)) return undefined;
entry.expiresAt = Date.now() + (options.ttlMs ?? PROJECT_PREVIEW_SCOPE_TTL_MS);
return entry.expiresAt;
},
validate(projectId, scope) {
const key = String(scope || '');
const entry = scopes.get(key);
Expand Down
33 changes: 33 additions & 0 deletions apps/daemon/tests/project-preview-containment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe('project preview containment routes', () => {
csp: string;
iframeSandbox: string;
opaqueOrigin: true;
expiresAt: number;
};

expect(body.file).toBe('pages/index.html');
Expand All @@ -123,6 +124,25 @@ describe('project preview containment routes', () => {
expect(body.csp).toContain("connect-src 'none'");
expect(body.csp).not.toContain('allow-same-origin');
expect(body.opaqueOrigin).toBe(true);
expect(body.expiresAt).toBeGreaterThan(Date.now());

const renewalScope = /\/preview\/([^/]+)\//u.exec(body.url)?.[1];
expect(renewalScope).toBeTruthy();
const rejectedRenewal = await fetch(
`${baseUrl}/api/projects/${projectId}/preview/${renewalScope}/renew`,
{ method: 'POST' },
);
expect(rejectedRenewal.status).toBe(403);
const renewal = await fetch(
`${baseUrl}/api/projects/${projectId}/preview/${renewalScope}/renew`,
{
method: 'POST',
headers: { 'x-od-preview-scope-renewal': '1' },
},
);
expect(renewal.status).toBe(200);
const renewed = await renewal.json() as { expiresAt: number };
expect(renewed.expiresAt).toBeGreaterThanOrEqual(body.expiresAt);

const previewResponse = await fetch(`${baseUrl}${body.url}`, {
headers: { Origin: 'null' },
Expand Down Expand Up @@ -327,6 +347,19 @@ describe('project preview containment routes', () => {
expect(baseHref).toMatch(
new RegExp(`^/api/projects/${projectId}/preview/[A-Za-z0-9_-]{8,128}/$`, 'u'),
);
expect(html).toContain('data-od-project-preview-base');
expect(html).toContain('data-od-preview-base-bridge');
expect(html).toContain("type: 'od:preview-base-scope'");

const workspaceScope = /\/preview\/([^/]+)\//u.exec(baseHref!)?.[1];
const workspaceRenewal = await fetch(
`${baseUrl}/api/projects/${projectId}/preview/${workspaceScope}/renew`,
{
method: 'POST',
headers: { 'x-od-preview-scope-renewal': '1' },
},
);
expect(workspaceRenewal.status).toBe(200);

// The browser resolves runtime-created `img.src = "logos/mark.png"`
// against <base>. A query-scoped raw document cannot do this because URL
Expand Down
Loading
Loading