mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
07fb752547
* 🐛 fix(portal): guide outside-workspace HTML publishing * 💄 style(portal): keep external-read consent above footer * ✨ feat(portal): confirm external file access * 💄 style(portal): align external-read consent typography * 🐛 fix(portal): harden outside-workspace publishing * 🐛 fix: secure external workspace HTML publishing * 🐛 fix(portal): bound external publish reads and honour local base hrefs - reject an external publish asset by `stat` size before reading it, on both the device-control and Electron transports, instead of letting the renderer find out after a multi-gigabyte base64 round trip - keep a `<base href>` that escapes the workspace, so its refs are reported as real outside-workspace dependencies instead of resolving back inside - drop a local `<base>` from the relocated copy, whose resource hrefs are already rewritten relative to its own directory Claude-Session: https://claude.ai/code/session_017KB2isGJ1RDpSaHiv88Xto * 🔧 chore(deps): register html-artifact in the standalone app workspaces `apps/cli` and `apps/desktop` enumerate their workspace packages instead of globbing, so `packages/device-control`'s new `@lobechat/html-artifact` dependency could not resolve there. Claude-Session: https://claude.ai/code/session_017KB2isGJ1RDpSaHiv88Xto
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
collectCssResourceHrefs,
|
|
collectJsResourceHrefs,
|
|
collectLocalResourceRefs,
|
|
} from './collectHtmlLocalResources';
|
|
import { extractHtmlTitle } from './htmlTagScanner';
|
|
import {
|
|
createWorkspaceHtmlArtifactIdentifier,
|
|
isPathInsideWorkspace,
|
|
lowestCommonAncestorDirectory,
|
|
resolveLocalResourceHref,
|
|
toWorkspaceAbsolutePath,
|
|
toWorkspaceRelativePath,
|
|
} from './workspaceHtmlPath';
|
|
|
|
describe('collectLocalResourceRefs', () => {
|
|
const workingDirectory = '/project';
|
|
const htmlFilePath = '/project/pages/index.html';
|
|
|
|
it('collects static local stylesheet, script, and image refs', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<link rel="stylesheet" href="../assets/app.css">
|
|
<script src="./main.js"></script>
|
|
<img src="images/logo.png">
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs.map((ref) => ref.absolutePath).sort()).toEqual([
|
|
'/project/assets/app.css',
|
|
'/project/pages/images/logo.png',
|
|
'/project/pages/main.js',
|
|
]);
|
|
});
|
|
|
|
it('collects srcset candidates and inline style urls', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<img srcset="img-1x.png 1x, img-2x.png 2x">
|
|
<div style="background: url('bg.webp')"></div>
|
|
<style>.hero { background-image: url("../shared/hero.jpg"); }</style>
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs.map((ref) => ref.absolutePath).sort()).toEqual(
|
|
[
|
|
'/project/pages/img-1x.png',
|
|
'/project/pages/img-2x.png',
|
|
'/project/pages/bg.webp',
|
|
'/project/shared/hero.jpg',
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it('does not follow navigation links or remote urls', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<a href="about.html">About</a>
|
|
<script src="https://cdn.example.com/app.js"></script>
|
|
<img src="data:image/png;base64,abc">
|
|
<img src="#logo">
|
|
<link rel="stylesheet" href="//cdn.example.com/app.css">
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([]);
|
|
expect(result.skipped.map((item) => item.reason).sort()).toEqual([
|
|
'empty',
|
|
'remote',
|
|
'remote',
|
|
'remote',
|
|
]);
|
|
});
|
|
|
|
it('rejects escaped paths and disallowed extensions', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<img src="../../.env">
|
|
<img src="../secrets/key.pem">
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([]);
|
|
expect(result.skipped.map((item) => item.reason).sort()).toEqual(['escape', 'extension']);
|
|
});
|
|
|
|
it('resolves root-relative paths against the working directory', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: '<link rel="stylesheet" href="/assets/app.css">',
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([
|
|
{ absolutePath: '/project/assets/app.css', href: '/assets/app.css' },
|
|
]);
|
|
});
|
|
|
|
it('treats every ref as remote when html has an absolute base', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<base href="https://cdn.example.com/app/">
|
|
<link rel="stylesheet" href="app.css">
|
|
<link rel="stylesheet" href="/assets/local.css">
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([]);
|
|
expect(result.skipped).toContainEqual({ href: 'app.css', reason: 'remote' });
|
|
expect(result.skipped).toContainEqual({ href: '/assets/local.css', reason: 'remote' });
|
|
});
|
|
|
|
it('resolves a relative base against the html file', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
<base href="../shared/">
|
|
<img src="logo.png">
|
|
`,
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([{ absolutePath: '/project/shared/logo.png', href: 'logo.png' }]);
|
|
});
|
|
|
|
it('keeps a base that escapes the workspace so its refs report the real files', () => {
|
|
const escaped = {
|
|
content: `
|
|
<base href="../../shared/">
|
|
<img src="logo.png">
|
|
`,
|
|
sourceKind: 'html' as const,
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
};
|
|
|
|
expect(collectLocalResourceRefs(escaped).skipped).toContainEqual({
|
|
absolutePath: '/shared/logo.png',
|
|
href: 'logo.png',
|
|
reason: 'escape',
|
|
});
|
|
expect(collectLocalResourceRefs({ ...escaped, allowExternalReads: true }).refs).toEqual([
|
|
{ absolutePath: '/shared/logo.png', href: 'logo.png' },
|
|
]);
|
|
});
|
|
|
|
it('collects css url and import refs from the stylesheet directory', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
@import "./theme.css";
|
|
body { background: url("../images/bg.png"); }
|
|
`,
|
|
sourceKind: 'css',
|
|
sourcePath: '/project/pages/css/app.css',
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs.map((ref) => ref.absolutePath).sort()).toEqual([
|
|
'/project/pages/css/theme.css',
|
|
'/project/pages/images/bg.png',
|
|
]);
|
|
});
|
|
|
|
it('collects vite-style import.meta.url and root-absolute assets from js', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: `
|
|
const hero = new URL(\`hero-CLDdwZDr.png\`, import.meta.url);
|
|
const logo = new URL("react.svg",import.meta.url);
|
|
const icon = "/icons.svg#documentation-icon";
|
|
const skip = "https://cdn.example.com/app.js";
|
|
`,
|
|
rootDirectory: '/project/dist',
|
|
sourceKind: 'js',
|
|
sourcePath: '/project/dist/assets/index.js',
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs.map((ref) => ref.absolutePath).sort()).toEqual([
|
|
'/project/dist/assets/hero-CLDdwZDr.png',
|
|
'/project/dist/assets/react.svg',
|
|
'/project/dist/icons.svg',
|
|
]);
|
|
expect(result.skipped).toEqual([]);
|
|
});
|
|
|
|
it('dedupes identical refs but keeps every spelling of the same file', () => {
|
|
const result = collectLocalResourceRefs({
|
|
content: '<img src="logo.png"><img src="logo.png"><img src="./logo.png">',
|
|
sourceKind: 'html',
|
|
sourcePath: htmlFilePath,
|
|
workingDirectory,
|
|
});
|
|
|
|
expect(result.refs).toEqual([
|
|
{ absolutePath: '/project/pages/logo.png', href: 'logo.png' },
|
|
{ absolutePath: '/project/pages/logo.png', href: './logo.png' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('collectJsResourceHrefs', () => {
|
|
it('reads backtick new URL and strips svg sprite hashes', () => {
|
|
expect(
|
|
collectJsResourceHrefs(
|
|
'd=``+new URL(`hero-CLDdwZDr.png`,import.meta.url).href;use.href=`/icons.svg#documentation-icon`',
|
|
),
|
|
).toEqual(['hero-CLDdwZDr.png', '/icons.svg']);
|
|
});
|
|
});
|
|
|
|
describe('path helpers', () => {
|
|
it('keeps workspace containment strict', () => {
|
|
expect(isPathInsideWorkspace('/project/pages/a.html', '/project')).toBe(true);
|
|
expect(isPathInsideWorkspace('/project-other/a.html', '/project')).toBe(false);
|
|
});
|
|
|
|
it('treats macOS /private/tmp and /tmp as the same workspace', () => {
|
|
expect(
|
|
isPathInsideWorkspace(
|
|
'/private/tmp/lobe-html-publish-fixture/app.css',
|
|
'/tmp/lobe-html-publish-fixture',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isPathInsideWorkspace(
|
|
'/tmp/lobe-html-publish-fixture/app.css',
|
|
'/private/tmp/lobe-html-publish-fixture',
|
|
),
|
|
).toBe(true);
|
|
expect(isPathInsideWorkspace('/private/tmp-other/app.css', '/tmp')).toBe(false);
|
|
|
|
expect(
|
|
resolveLocalResourceHref({
|
|
href: './app.css',
|
|
sourcePath: '/private/tmp/lobe-html-publish-fixture/index.html',
|
|
workingDirectory: '/tmp/lobe-html-publish-fixture',
|
|
}),
|
|
).toEqual({
|
|
absolutePath: '/private/tmp/lobe-html-publish-fixture/app.css',
|
|
href: './app.css',
|
|
kind: 'resolved',
|
|
});
|
|
|
|
expect(
|
|
toWorkspaceRelativePath(
|
|
'/private/tmp/lobe-html-publish-fixture/index.html',
|
|
'/tmp/lobe-html-publish-fixture',
|
|
),
|
|
).toBe('index.html');
|
|
expect(
|
|
toWorkspaceAbsolutePath(
|
|
'/private/tmp/lobe-html-publish-fixture/index.html',
|
|
'/tmp/lobe-html-publish-fixture',
|
|
),
|
|
).toBe('/private/tmp/lobe-html-publish-fixture/index.html');
|
|
});
|
|
|
|
it('computes the lowest common ancestor inside the workspace', () => {
|
|
expect(
|
|
lowestCommonAncestorDirectory(
|
|
['/project/pages/index.html', '/project/pages/css/app.css'],
|
|
'/project',
|
|
),
|
|
).toBe('/project/pages');
|
|
expect(
|
|
lowestCommonAncestorDirectory(
|
|
['/project/pages/index.html', '/project/shared/logo.png'],
|
|
'/project',
|
|
),
|
|
).toBe('/project');
|
|
expect(
|
|
lowestCommonAncestorDirectory(
|
|
['/project/pages/index.html', '/outside/assets/logo.png'],
|
|
'/project',
|
|
),
|
|
).toBe('/');
|
|
});
|
|
|
|
it('builds a stable artifact identifier from the html relative path', () => {
|
|
expect(createWorkspaceHtmlArtifactIdentifier('pages/index.html')).toBe(
|
|
'workspace-html-pages-index-html-6afd7f0435',
|
|
);
|
|
expect(createWorkspaceHtmlArtifactIdentifier('pages/index.html')).toBe(
|
|
createWorkspaceHtmlArtifactIdentifier('pages/index.html'),
|
|
);
|
|
expect(createWorkspaceHtmlArtifactIdentifier('页面.html')).not.toBe(
|
|
createWorkspaceHtmlArtifactIdentifier('报告.html'),
|
|
);
|
|
expect(createWorkspaceHtmlArtifactIdentifier('a/b.html')).not.toBe(
|
|
createWorkspaceHtmlArtifactIdentifier('a-b.html'),
|
|
);
|
|
});
|
|
|
|
it('extracts a title and returns undefined when missing', () => {
|
|
expect(extractHtmlTitle('<html><title> Demo Page </title></html>')).toBe('Demo Page');
|
|
expect(extractHtmlTitle('<html></html>')).toBeUndefined();
|
|
});
|
|
|
|
it('resolves windows-style relative hrefs', () => {
|
|
expect(
|
|
resolveLocalResourceHref({
|
|
href: 'assets\\logo.png',
|
|
sourcePath: 'C:\\repo\\pages\\index.html',
|
|
workingDirectory: 'C:\\repo',
|
|
}),
|
|
).toEqual({
|
|
absolutePath: 'C:\\repo\\pages\\assets\\logo.png',
|
|
href: 'assets\\logo.png',
|
|
kind: 'resolved',
|
|
});
|
|
});
|
|
|
|
it('returns the workspace-relative path', () => {
|
|
expect(toWorkspaceRelativePath('/project/pages/index.html', '/project')).toBe(
|
|
'pages/index.html',
|
|
);
|
|
});
|
|
|
|
it('joins a workspace-relative html path onto the working directory', () => {
|
|
expect(toWorkspaceAbsolutePath('index.html', '/tmp/site')).toBe('/tmp/site/index.html');
|
|
expect(toWorkspaceAbsolutePath('/tmp/site/index.html', '/tmp/site')).toBe(
|
|
'/tmp/site/index.html',
|
|
);
|
|
});
|
|
it('strips css comments without going quadratic on unterminated openers', () => {
|
|
const css = `
|
|
@import "kept.css";
|
|
/* a comment with url("ignored.png") inside */
|
|
body { background: url("used.png"); }
|
|
`;
|
|
|
|
expect(collectCssResourceHrefs(css)).toEqual(['kept.css', 'used.png']);
|
|
|
|
// A sheet of unclosed openers is what makes the lazy regex backtrack; the
|
|
// scanner must return promptly and treat the first opener as running to EOF.
|
|
const pathological = `body{background:url("late.png")}${'a/*'.repeat(40_000)}`;
|
|
const started = Date.now();
|
|
expect(collectCssResourceHrefs(pathological)).toEqual(['late.png']);
|
|
expect(Date.now() - started).toBeLessThan(1000);
|
|
});
|
|
});
|