fix(web): make share-menu "Download as .zip" return the actual project tree (#341)

* fix(web): download project tree as zip from share menu

The "Download as .zip" share action previously produced a single-file
ZIP of the rendered HTML srcdoc. Add a daemon archive endpoint that
bundles the on-disk project tree (scoped to the active top-level
directory when applicable) and have FileViewer call it, falling back to
the in-memory single-file ZIP on failure. UTF-8 filenames are preserved
via RFC 5987 Content-Disposition.

* fix(daemon,web): address PR #341 review — diagnostics, comments, more tests

- Stat the archive root up-front so a missing/non-directory target
  surfaces a clear ENOENT/ENOTDIR ("does not exist" vs "is empty"),
  instead of being swallowed by the recursive walk and reported as
  empty. Distinguishes a deleted project from one with no archivable
  files for on-call diagnostics.
- Document the DEFLATE level-6 choice in the archive builder so future
  maintainers don't have to guess at the speed/ratio trade-off.
- Add a daemon test that the baseName preserves a multi-byte UTF-8
  directory name (café-design), covering the path that feeds RFC 5987
  filename* on the server.
- Add a daemon test that a missing archive root surfaces ENOENT with a
  "does not exist" message, distinct from the empty-directory case.
- Export archiveRootFromFilePath and archiveFilenameFrom for testing,
  and add web unit tests covering the Content-Disposition fallback
  chain (UTF-8 filename* → legacy quoted filename= → root slug → title
  slug, plus malformed-encoding fall-through).
- Replace the CJK example string in code comments and tests with a
  Latin-extended example (café-design) so the codebase stays English
  while still exercising multi-byte UTF-8 handling.
This commit is contained in:
monshunter 2026-05-03 10:34:33 +08:00 committed by GitHub
parent 648374d839
commit 30f8036c9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 364 additions and 2 deletions

View file

@ -9,6 +9,7 @@
import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
import {
inferLegacyManifest,
parsePersistedManifest,
@ -71,6 +72,93 @@ async function collectFiles(dir, relDir, out) {
}
}
// Build a ZIP of every file under the project directory (or under `root`,
// if it points at a subdirectory). Mirrors listFiles' filtering — dotfiles
// and `.artifact.json` sidecars are excluded — so the archive matches what
// the user sees in the file panel. Used by the "Download as .zip" share
// menu item, which exports the user's actual project tree (e.g. the
// uploaded `ui-design/` folder), not just the rendered HTML.
export async function buildProjectArchive(projectsRoot, projectId, root) {
const projectRoot = projectDir(projectsRoot, projectId);
let archiveRoot = projectRoot;
let archiveBaseName = '';
if (typeof root === 'string' && root.trim().length > 0) {
archiveRoot = resolveSafe(projectRoot, root);
archiveBaseName = path.basename(archiveRoot);
}
// Stat the archive root up-front so a missing/non-directory target gives a
// clear ENOENT/ENOTDIR error. Without this the recursive walk swallows
// ENOENT and we'd report the directory as "empty" instead — confusing if
// the project (or a subdir) was deleted concurrently with the download.
let rootStat;
try {
rootStat = await stat(archiveRoot);
} catch (err) {
if (err && err.code === 'ENOENT') {
const e = new Error('archive root does not exist');
e.code = 'ENOENT';
throw e;
}
throw err;
}
if (!rootStat.isDirectory()) {
const err = new Error('archive root is not a directory');
err.code = 'ENOTDIR';
throw err;
}
const entries = [];
await collectArchiveEntries(archiveRoot, '', entries);
if (entries.length === 0) {
const err = new Error('archive root is empty');
err.code = 'ENOENT';
throw err;
}
const zip = new JSZip();
for (const entry of entries) {
const buf = await readFile(entry.fullPath);
zip.file(entry.relPath, buf, {
date: new Date(entry.mtime),
binary: true,
});
}
// Level 6 is the zlib default — balances speed and ratio for typical
// project trees (HTML/CSS/JS plus a handful of assets). Level 9 buys
// <5% on already-compressed PNGs/fonts at 2-3× CPU; level 1 produces
// noticeably larger archives. Revisit only if profiling says so.
const buffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return { buffer, baseName: archiveBaseName };
}
async function collectArchiveEntries(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
if (!e.isDirectory() && !e.isFile()) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectArchiveEntries(full, rel, out);
continue;
}
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
out.push({ relPath: rel, fullPath: full, mtime: st.mtimeMs });
}
}
export async function readProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);

View file

@ -48,6 +48,7 @@ import {
} from './media-models.js';
import { readMaskedConfig, writeConfig } from './media-config.js';
import {
buildProjectArchive,
decodeMultipartFilename,
deleteProjectFile,
ensureProject,
@ -350,6 +351,20 @@ function sendApiError(res, status, code, message, init = {}) {
return res.status(status).json(createCompatApiErrorResponse(code, message, init));
}
// Filename slug for the Content-Disposition header on archive downloads.
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
// so a project name with non-ASCII characters (e.g. "café-design")
// survives instead of becoming a row of underscores.
function sanitizeArchiveFilename(raw) {
const cleaned = String(raw ?? '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/\s+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return cleaned;
}
/**
* @param {ApiErrorCode} code
* @param {string} message
@ -1463,6 +1478,46 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
// Streams a ZIP of the project's on-disk tree so the "Download as .zip"
// share menu can hand the user the actual files they uploaded — e.g. the
// imported `ui-design/` folder — instead of a one-file snapshot of the
// rendered HTML. `root` scopes the archive to a subdirectory; without
// it, the whole project is packed.
app.get('/api/projects/:id/archive', async (req, res) => {
try {
const root = typeof req.query?.root === 'string' ? req.query.root : '';
const { buffer, baseName } = await buildProjectArchive(
PROJECTS_DIR,
req.params.id,
root,
);
const project = getProject(db, req.params.id);
const fallbackName = project?.name || req.params.id;
const fileSlug = sanitizeArchiveFilename(baseName || fallbackName) || 'project';
const filename = `${fileSlug}.zip`;
// RFC 5987 dance: legacy `filename=` carries an ASCII fallback, while
// `filename*=UTF-8''…` lets modern browsers pick up project names
// with non-ASCII characters (accents, CJK, etc.) without mojibake.
const asciiFallback =
filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '_') || 'project.zip';
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
res.send(buffer);
} catch (err) {
const code = err && err.code;
const status = code === 'ENOENT' || code === 'ENOTDIR' ? 404 : 400;
sendApiError(
res,
status,
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
String(err?.message || 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
// artifacts ever add custom request headers.

View file

@ -0,0 +1,89 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildProjectArchive } from '../src/projects.js';
describe('buildProjectArchive', () => {
let projectsRoot = '';
const projectId = 'proj-archive-test';
beforeEach(async () => {
projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-archive-'));
const dir = path.join(projectsRoot, projectId);
await mkdir(path.join(dir, 'ui-design', 'src'), { recursive: true });
await mkdir(path.join(dir, 'ui-design', 'frames'), { recursive: true });
await writeFile(path.join(dir, 'ui-design', 'index.html'), '<!doctype html>hi');
await writeFile(path.join(dir, 'ui-design', 'src', 'app.css'), 'body{}');
await writeFile(path.join(dir, 'ui-design', 'frames', 'phone.html'), '<frame/>');
await writeFile(path.join(dir, 'ui-design', 'index.html.artifact.json'), '{}');
await writeFile(path.join(dir, 'ui-design', '.hidden'), 'secret');
await writeFile(path.join(dir, 'README.md'), '# top-level readme');
});
afterEach(() => {
if (projectsRoot) rmSync(projectsRoot, { recursive: true, force: true });
});
it('zips the requested subdirectory tree', async () => {
const { buffer, baseName } = await buildProjectArchive(projectsRoot, projectId, 'ui-design');
expect(baseName).toBe('ui-design');
const zip = await JSZip.loadAsync(buffer);
const fileEntries = Object.values(zip.files)
.filter((entry) => !entry.dir)
.map((entry) => entry.name)
.sort();
expect(fileEntries).toEqual(['frames/phone.html', 'index.html', 'src/app.css']);
});
it('zips the whole project when no root is given', async () => {
const { buffer, baseName } = await buildProjectArchive(projectsRoot, projectId, '');
expect(baseName).toBe('');
const zip = await JSZip.loadAsync(buffer);
const fileEntries = Object.values(zip.files)
.filter((entry) => !entry.dir)
.map((entry) => entry.name);
expect(fileEntries).toContain('README.md');
expect(fileEntries).toContain('ui-design/index.html');
expect(fileEntries).toContain('ui-design/src/app.css');
// dotfiles and .artifact.json sidecars are filtered, matching listFiles
expect(fileEntries.find((n) => n.includes('.hidden'))).toBeUndefined();
expect(fileEntries.find((n) => n.endsWith('.artifact.json'))).toBeUndefined();
});
it('rejects path traversal in root', async () => {
await expect(buildProjectArchive(projectsRoot, projectId, '../foo')).rejects.toThrow();
});
it('throws when the root directory has no archivable files', async () => {
const dir = path.join(projectsRoot, projectId, 'empty');
await mkdir(dir, { recursive: true });
await expect(buildProjectArchive(projectsRoot, projectId, 'empty')).rejects.toThrow(/empty/);
});
it('throws ENOENT with "does not exist" when the archive root is missing', async () => {
// Distinct from the "empty directory" case so callers — and on-call
// engineers reading logs — can tell a deleted project from a project
// that simply has no archivable files.
await expect(buildProjectArchive(projectsRoot, projectId, 'no-such-dir')).rejects.toMatchObject(
{ code: 'ENOENT', message: expect.stringMatching(/does not exist/) },
);
});
it('preserves non-ASCII characters in baseName', async () => {
// Mirrors the server's Content-Disposition encoding: the daemon hands
// baseName straight into RFC 5987 filename* via encodeURIComponent, so
// multi-byte UTF-8 characters must survive untouched here.
const dirName = 'café-design';
const dir = path.join(projectsRoot, projectId, dirName);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'index.html'), '<!doctype html>hi');
const { baseName, buffer } = await buildProjectArchive(projectsRoot, projectId, dirName);
expect(baseName).toBe(dirName);
const zip = await JSZip.loadAsync(buffer);
expect(Object.keys(zip.files)).toContain('index.html');
});
});

View file

@ -19,7 +19,7 @@ import {
exportAsHtml,
exportAsJsx,
exportAsPdf,
exportAsZip,
exportProjectAsZip,
exportReactComponentAsHtml,
exportReactComponentAsZip,
} from '../runtime/exports';
@ -1301,7 +1301,12 @@ function HtmlViewer({
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsZip(source ?? '', exportTitle);
void exportProjectAsZip({
projectId,
filePath: file.name,
fallbackHtml: source ?? '',
fallbackTitle: exportTitle,
});
}}
>
<span className="share-menu-icon"><Icon name="download" size={14} /></span>

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { archiveFilenameFrom, archiveRootFromFilePath } from './exports';
function mockResponse(headers: Record<string, string>): Response {
return { headers: new Headers(headers) } as Response;
}
describe('archiveRootFromFilePath', () => {
it('returns the top-level directory name when present', () => {
expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
});
it('returns empty for files at the project root', () => {
expect(archiveRootFromFilePath('index.html')).toBe('');
expect(archiveRootFromFilePath('README.md')).toBe('');
});
it('strips a leading slash before scanning', () => {
expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
});
it('returns empty for empty/garbage input', () => {
expect(archiveRootFromFilePath('')).toBe('');
expect(archiveRootFromFilePath('/')).toBe('');
});
});
describe('archiveFilenameFrom', () => {
it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
// 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
// which is enough to fail under naive ASCII-only handling.
const resp = mockResponse({
'content-disposition':
"attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
});
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
});
it('falls back to the legacy quoted filename= when filename* is absent', () => {
const resp = mockResponse({
'content-disposition': 'attachment; filename="ui-design.zip"',
});
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
});
it('falls back to the active root slug when the header is missing', () => {
const resp = mockResponse({});
expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
});
it('falls back to the title slug when both header and root are absent', () => {
const resp = mockResponse({});
expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
});
it('falls through to the slug when filename* is malformed', () => {
// Truncated percent-escape — decodeURIComponent throws; we should not
// surface the exception, just fall back to the next strategy.
const resp = mockResponse({
'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
});
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
});
});

View file

@ -84,6 +84,65 @@ export function exportReactComponentAsZip(
triggerDownload(blob, `${slug}.zip`);
}
// Project ZIP export — asks the daemon to bundle the on-disk project tree.
// Used by FileViewer's share menu so the user gets the full uploaded
// project (e.g. the `ui-design/` folder with its subdirs and assets) rather
// than just a srcdoc snapshot of the rendered HTML. `filePath` is the
// active file's project-relative path; if it lives inside a top-level
// directory we scope the archive to that directory, otherwise we ask the
// daemon for the whole project. Falls back to the in-memory single-file
// ZIP on any failure so the action never silently no-ops.
export async function exportProjectAsZip(opts: {
projectId: string;
filePath: string;
fallbackHtml: string;
fallbackTitle: string;
}): Promise<void> {
const root = archiveRootFromFilePath(opts.filePath);
const url = `/api/projects/${encodeURIComponent(opts.projectId)}/archive${
root ? `?root=${encodeURIComponent(root)}` : ''
}`;
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`archive request failed (${resp.status})`);
const blob = await resp.blob();
triggerDownload(blob, archiveFilenameFrom(resp, opts.fallbackTitle, root));
} catch (err) {
console.warn('[exportProjectAsZip] falling back to single-file ZIP:', err);
exportAsZip(opts.fallbackHtml, opts.fallbackTitle);
}
}
// Exported for unit tests. Pure string transform with no DOM dependency.
export function archiveRootFromFilePath(filePath: string): string {
const trimmed = (filePath || '').replace(/^\/+/, '');
const slash = trimmed.indexOf('/');
if (slash <= 0) return '';
return trimmed.slice(0, slash);
}
// Exported for unit tests so the Content-Disposition fallback chain
// (UTF-8 → legacy quoted → local slug) can be exercised against mock
// Response objects without spinning up the daemon.
export function archiveFilenameFrom(resp: Response, fallbackTitle: string, root: string): string {
// Honor the daemon's Content-Disposition (it knows the project name and
// handles RFC 5987 UTF-8 encoding). Fall back to the active directory
// name, then to the active file title.
const header = resp.headers.get('content-disposition') || '';
const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (star && star[1]) {
try {
return decodeURIComponent(star[1]);
} catch {
// fall through to the legacy filename= or local fallback
}
}
const plain = /filename="([^"]+)"/i.exec(header);
if (plain && plain[1]) return plain[1];
const slug = safeFilename(root || fallbackTitle, 'project');
return `${slug}.zip`;
}
// Open the artifact in a new tab via a Blob URL with a self-printing
// script injected. Going through a Blob URL (rather than `window.open('')`
// + `document.write`) avoids two failure modes we hit before: