mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
648374d839
commit
30f8036c9a
6 changed files with 364 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
89
apps/daemon/tests/project-archive.test.ts
Normal file
89
apps/daemon/tests/project-archive.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
66
apps/web/src/runtime/exports.test.ts
Normal file
66
apps/web/src/runtime/exports.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue