Skip to content

Commit 4118ba2

Browse files
committed
fix(server): rewrite local preview resources
1 parent b784c91 commit 4118ba2

3 files changed

Lines changed: 475 additions & 10 deletions

File tree

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import { isPathInsideRoot } from "../fs/path-safety.js";
4+
5+
const PREVIEW_RESOURCE_TAGS = new Set([
6+
"embed",
7+
"iframe",
8+
"image",
9+
"img",
10+
"input",
11+
"link",
12+
"source",
13+
"track",
14+
"audio",
15+
"video",
16+
"script",
17+
]);
18+
19+
const PREVIEW_RESOURCE_ATTRS = new Set(["href", "poster", "src", "xlink:href"]);
20+
21+
const STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
22+
const PREVIEW_TAG_PATTERN = /<\s*([A-Za-z][\w:-]*)([^<>]*?)>/g;
23+
const PREVIEW_ATTR_PATTERN =
24+
/(\s+)(srcset|src|href|poster|xlink:href|style)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
25+
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]*?))\s*\)/gi;
26+
27+
interface PreviewRewriteInput {
28+
sessionId: string;
29+
workspaceRootPath: string;
30+
baseWorkspacePath?: string;
31+
}
32+
33+
export function encodePathSegments(inputPath: string): string {
34+
return inputPath
35+
.split("/")
36+
.map((segment) => encodeURIComponent(segment))
37+
.join("/");
38+
}
39+
40+
export function rewritePreviewHtmlResourceUrls(
41+
html: string,
42+
input: { sessionId: string; workspaceRootPath: string; entryPath: string }
43+
): string {
44+
const rewriteInput = {
45+
sessionId: input.sessionId,
46+
workspaceRootPath: input.workspaceRootPath,
47+
baseWorkspacePath: input.entryPath,
48+
};
49+
const htmlWithStyleBlocks = html.replace(
50+
STYLE_BLOCK_PATTERN,
51+
(match, rawAttributes: string, css: string) => {
52+
const rewrittenCss = rewritePreviewCssResourceUrls(css, rewriteInput);
53+
return rewrittenCss === css ? match : `<style${rawAttributes}>${rewrittenCss}</style>`;
54+
}
55+
);
56+
57+
return htmlWithStyleBlocks.replace(
58+
PREVIEW_TAG_PATTERN,
59+
(match, rawTagName: string, rawAttributes: string) => {
60+
const canRewriteResourceAttrs = PREVIEW_RESOURCE_TAGS.has(rawTagName.toLowerCase());
61+
let changed = false;
62+
const nextAttributes = rawAttributes.replace(
63+
PREVIEW_ATTR_PATTERN,
64+
(
65+
attrMatch,
66+
leadingWhitespace: string,
67+
rawAttrName: string,
68+
doubleQuotedValue: string | undefined,
69+
singleQuotedValue: string | undefined,
70+
bareValue: string | undefined
71+
) => {
72+
const attrName = rawAttrName.toLowerCase();
73+
const originalValue = doubleQuotedValue ?? singleQuotedValue ?? bareValue ?? "";
74+
let rewrittenValue = originalValue;
75+
76+
if (attrName === "style") {
77+
rewrittenValue = rewritePreviewCssResourceUrls(originalValue, rewriteInput);
78+
} else if (canRewriteResourceAttrs && attrName === "srcset") {
79+
rewrittenValue = rewritePreviewSrcset(originalValue, rewriteInput);
80+
} else if (canRewriteResourceAttrs && PREVIEW_RESOURCE_ATTRS.has(attrName)) {
81+
rewrittenValue = rewritePreviewResourceUrl(originalValue, rewriteInput);
82+
}
83+
84+
if (rewrittenValue === originalValue) {
85+
return attrMatch;
86+
}
87+
88+
changed = true;
89+
90+
if (doubleQuotedValue !== undefined) {
91+
return `${leadingWhitespace}${rawAttrName}="${escapeHtmlAttribute(rewrittenValue, '"')}"`;
92+
}
93+
94+
if (singleQuotedValue !== undefined) {
95+
return `${leadingWhitespace}${rawAttrName}='${escapeHtmlAttribute(rewrittenValue, "'")}'`;
96+
}
97+
98+
return `${leadingWhitespace}${rawAttrName}=${rewrittenValue}`;
99+
}
100+
);
101+
102+
return changed ? `<${rawTagName}${nextAttributes}>` : match;
103+
}
104+
);
105+
}
106+
107+
export function rewritePreviewCssResourceUrls(css: string, input: PreviewRewriteInput): string {
108+
return css.replace(
109+
CSS_URL_PATTERN,
110+
(
111+
match,
112+
doubleQuotedValue: string | undefined,
113+
singleQuotedValue: string | undefined,
114+
bareValue: string | undefined
115+
) => {
116+
const originalValue = doubleQuotedValue ?? singleQuotedValue ?? bareValue ?? "";
117+
const rewrittenValue = rewritePreviewResourceUrl(originalValue.trim(), input);
118+
119+
if (rewrittenValue === originalValue.trim()) {
120+
return match;
121+
}
122+
123+
if (doubleQuotedValue !== undefined) {
124+
return `url("${escapeCssQuotedUrl(rewrittenValue, '"')}")`;
125+
}
126+
127+
if (singleQuotedValue !== undefined) {
128+
return `url('${escapeCssQuotedUrl(rewrittenValue, "'")}')`;
129+
}
130+
131+
return `url(${rewrittenValue})`;
132+
}
133+
);
134+
}
135+
136+
function rewritePreviewSrcset(srcset: string, input: PreviewRewriteInput): string {
137+
return splitSrcsetCandidates(srcset)
138+
.map((candidate) => rewritePreviewSrcsetCandidate(candidate, input))
139+
.join(",");
140+
}
141+
142+
function rewritePreviewSrcsetCandidate(candidate: string, input: PreviewRewriteInput): string {
143+
const leadingWhitespace = candidate.match(/^\s*/)?.[0] ?? "";
144+
const trimmedCandidate = candidate.trim();
145+
146+
if (!trimmedCandidate) {
147+
return candidate;
148+
}
149+
150+
const urlMatch = /^(\S+)(.*)$/.exec(trimmedCandidate);
151+
if (!urlMatch) {
152+
return candidate;
153+
}
154+
155+
const rawUrl = urlMatch[1];
156+
const descriptor = urlMatch[2] ?? "";
157+
if (!rawUrl) {
158+
return candidate;
159+
}
160+
161+
return `${leadingWhitespace}${rewritePreviewResourceUrl(rawUrl, input)}${descriptor}`;
162+
}
163+
164+
function splitSrcsetCandidates(srcset: string): string[] {
165+
const candidates: string[] = [];
166+
let startIndex = 0;
167+
let urlStarted = false;
168+
let seenWhitespaceAfterUrl = false;
169+
let isDataCandidate = false;
170+
171+
for (let index = 0; index < srcset.length; index += 1) {
172+
const current = srcset[index] ?? "";
173+
174+
if (!urlStarted) {
175+
if (/\s/.test(current)) {
176+
continue;
177+
}
178+
179+
urlStarted = true;
180+
isDataCandidate = srcset.slice(index, index + 5).toLowerCase() === "data:";
181+
} else if (/\s/.test(current)) {
182+
seenWhitespaceAfterUrl = true;
183+
}
184+
185+
if (current !== ",") {
186+
continue;
187+
}
188+
189+
if (isDataCandidate && !seenWhitespaceAfterUrl) {
190+
continue;
191+
}
192+
193+
candidates.push(srcset.slice(startIndex, index));
194+
startIndex = index + 1;
195+
urlStarted = false;
196+
seenWhitespaceAfterUrl = false;
197+
isDataCandidate = false;
198+
}
199+
200+
candidates.push(srcset.slice(startIndex));
201+
return candidates;
202+
}
203+
204+
function rewritePreviewResourceUrl(rawValue: string, input: PreviewRewriteInput): string {
205+
const { pathPart, suffix } = splitUrlSuffix(rawValue);
206+
const trimmedValue = pathPart.trim();
207+
208+
if (!trimmedValue || trimmedValue.startsWith("//")) {
209+
return rawValue;
210+
}
211+
212+
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmedValue)) {
213+
if (!trimmedValue.toLowerCase().startsWith("file:")) {
214+
return rawValue;
215+
}
216+
217+
try {
218+
const absolutePath = fileURLToPath(trimmedValue);
219+
const workspaceRelativePath = resolveWorkspaceRelativePath(
220+
input.workspaceRootPath,
221+
absolutePath
222+
);
223+
224+
if (!workspaceRelativePath) {
225+
return rawValue;
226+
}
227+
228+
return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`;
229+
} catch {
230+
return rawValue;
231+
}
232+
}
233+
234+
const decodedPath = decodePathSegments(trimmedValue.replaceAll("\\", "/"));
235+
236+
if (decodedPath.startsWith("/")) {
237+
const workspaceRelativePath =
238+
resolveWorkspaceRelativePath(input.workspaceRootPath, decodedPath) ??
239+
normalizeWorkspaceRelativePath(decodedPath.slice(1));
240+
241+
if (!workspaceRelativePath) {
242+
return rawValue;
243+
}
244+
245+
return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`;
246+
}
247+
248+
if (path.win32.isAbsolute(decodedPath)) {
249+
const workspaceRelativePath = resolveWorkspaceRelativePath(
250+
input.workspaceRootPath,
251+
decodedPath
252+
);
253+
if (!workspaceRelativePath) {
254+
return rawValue;
255+
}
256+
257+
return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`;
258+
}
259+
260+
if (input.baseWorkspacePath) {
261+
const workspaceRelativePath = resolveRelativeWorkspacePath(
262+
input.baseWorkspacePath,
263+
decodedPath
264+
);
265+
266+
if (workspaceRelativePath) {
267+
return `${createPreviewAssetUrl(input.sessionId, workspaceRelativePath)}${suffix}`;
268+
}
269+
}
270+
271+
return rawValue;
272+
}
273+
274+
function resolveRelativeWorkspacePath(
275+
baseWorkspacePath: string,
276+
relativePath: string
277+
): string | null {
278+
return normalizeWorkspaceRelativePath(
279+
path.posix.join(path.posix.dirname(baseWorkspacePath.replaceAll("\\", "/")), relativePath)
280+
);
281+
}
282+
283+
function createPreviewAssetUrl(sessionId: string, workspaceRelativePath: string): string {
284+
return `/api/preview/session/${sessionId}/${encodePathSegments(workspaceRelativePath)}`;
285+
}
286+
287+
function normalizeWorkspaceRelativePath(rawPath: string): string | null {
288+
const normalized = path.posix.normalize(rawPath.replaceAll("\\", "/").replace(/^\/+/, ""));
289+
290+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
291+
return null;
292+
}
293+
294+
return normalized;
295+
}
296+
297+
function resolveWorkspaceRelativePath(
298+
workspaceRootPath: string,
299+
absolutePath: string
300+
): string | null {
301+
const absoluteWorkspaceRoot = path.resolve(workspaceRootPath);
302+
const absoluteTargetPath = path.resolve(absolutePath);
303+
304+
if (!isPathInsideRoot(absoluteWorkspaceRoot, absoluteTargetPath)) {
305+
return null;
306+
}
307+
308+
const workspaceRelativePath = absoluteTargetPath
309+
.slice(absoluteWorkspaceRoot.length)
310+
.replace(/^[/\\]/, "")
311+
.replaceAll("\\", "/");
312+
313+
return normalizeWorkspaceRelativePath(workspaceRelativePath);
314+
}
315+
316+
function splitUrlSuffix(value: string): { pathPart: string; suffix: string } {
317+
const queryIndex = value.indexOf("?");
318+
const hashIndex = value.indexOf("#");
319+
320+
let suffixIndex = value.length;
321+
if (queryIndex !== -1 && hashIndex !== -1) {
322+
suffixIndex = Math.min(queryIndex, hashIndex);
323+
} else if (queryIndex !== -1) {
324+
suffixIndex = queryIndex;
325+
} else if (hashIndex !== -1) {
326+
suffixIndex = hashIndex;
327+
}
328+
329+
return {
330+
pathPart: value.slice(0, suffixIndex),
331+
suffix: value.slice(suffixIndex),
332+
};
333+
}
334+
335+
function decodePathSegments(inputPath: string): string {
336+
return inputPath
337+
.split("/")
338+
.map((segment) => {
339+
try {
340+
return decodeURIComponent(segment);
341+
} catch {
342+
return segment;
343+
}
344+
})
345+
.join("/");
346+
}
347+
348+
function escapeHtmlAttribute(value: string, quote: '"' | "'"): string {
349+
return value
350+
.replaceAll("&", "&amp;")
351+
.replaceAll("<", "&lt;")
352+
.replaceAll(">", "&gt;")
353+
.replaceAll(quote, quote === '"' ? "&quot;" : "&#39;");
354+
}
355+
356+
function escapeCssQuotedUrl(value: string, quote: '"' | "'"): string {
357+
return value.replaceAll("\\", "\\\\").replaceAll(quote, `\\${quote}`);
358+
}

0 commit comments

Comments
 (0)