Line data Source code
1 : import JSZip from 'jszip';
2 :
3 : import type { ScriptData, StorageBackend } from '../../storage/storage_backend.js';
4 : import {
5 : STORAGE_ATTACHMENTS_FOLDER,
6 : validateDataFileName,
7 : STORAGE_SCRIPTS_FOLDER,
8 : } from '../../storage/storage_backend.js';
9 : import type { SessionBundle, SessionBundleWriteOptions } from '../../storage/session_bundle.js';
10 : import {
11 : assembleSessionBundle,
12 : isBundleCatalogPath,
13 : readSessionBundleHead,
14 : regenerateBundleConnectionIds,
15 : seedConnectionCatalogs,
16 : writeSessionBundle,
17 : } from '../../storage/session_bundle.js';
18 : import { MemoryFolder } from '../../storage/migration/migration_folder.js';
19 :
20 : export interface SessionZipImportOptions extends SessionBundleWriteOptions {
21 : /// Generate fresh connection identities for a clone/create-new import
22 : regenerateConnectionIds?: boolean;
23 : }
24 :
25 : type SessionZipImportOptionsInput = SessionZipImportOptions | (() => string);
26 :
27 : type ZipObjectWithOriginalName = JSZip.JSZipObject & { unsafeOriginalName?: string };
28 :
29 : interface ParsedZipEntry {
30 : path: string;
31 : file: JSZip.JSZipObject;
32 : }
33 :
34 1 : const NATURAL_SORT = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
35 :
36 : /// Parse and validate a portable session ZIP without modifying storage.
37 1 : export async function readSessionBundleFromZip(zipBlob: Blob): Promise<SessionBundle> {
38 1 : const zip = await JSZip.loadAsync(zipBlob);
39 1 : const destinations = new Map<string, ParsedZipEntry>();
40 :
41 1 : for (const file of Object.values(zip.files)) {
42 1 : const originalPath = (file as ZipObjectWithOriginalName).unsafeOriginalName ?? file.name;
43 1 : validateDataPath(originalPath);
44 1 : const path = normalizeBundlePath(originalPath);
45 1 : validateDataPath(path);
46 1 : if (file.dir) continue;
47 1 : if (!isBundleFile(path)) {
48 1 : continue;
49 : }
50 1 : const key = destinationKey(path);
51 1 : if (destinations.has(key)) {
52 0 : throw new Error(`Invalid ZIP: duplicate destination ${path}`);
53 : }
54 1 : destinations.set(key, { path, file });
55 1 : }
56 :
57 : // The top-level files are migrated in memory, the scripts and attachments are read as they are
58 1 : const head = await readSessionBundleHead(new MemoryFolder(await Promise.all([...destinations]
59 1 : .filter(([key, entry]) => isTopLevelFile(key) || isBundleCatalogPath(entry.path))
60 1 : .map(async ([key, entry]) => [isBundleCatalogPath(entry.path) ? entry.path : key, await entry.file.async('text')] as [string, string]))), 'Invalid ZIP');
61 :
62 1 : const scripts: Array<{ name: string; file: JSZip.JSZipObject }> = [];
63 :
64 1 : for (const entry of destinations.values()) {
65 1 : const entryKey = destinationKey(entry.path);
66 1 : if (!entryKey.startsWith(`${destinationKey(STORAGE_SCRIPTS_FOLDER)}/`)) {
67 1 : continue;
68 : }
69 1 : const parts = entry.path.split('/');
70 1 : if (!entryKey.endsWith('.sql')) {
71 0 : continue;
72 : }
73 1 : if (parts.length !== 2) {
74 1 : throw new Error(`Invalid ZIP: SQL scripts must be directly inside scripts/: ${entry.path}`);
75 : }
76 1 : const [, scriptName] = parts;
77 1 : if (destinationKey(scriptName) === destinationKey('dashql-draft.sql')) {
78 1 : throw new Error(`Invalid ZIP: draft scripts are not supported: ${entry.path}`);
79 : }
80 1 : scripts.push({ name: scriptName, file: entry.file });
81 1 : }
82 :
83 1 : const parsedScripts: ScriptData[] = await Promise.all(scripts
84 1 : .sort((a, b) => NATURAL_SORT.compare(a.name, b.name))
85 1 : .map(async script => ({ name: script.name, sql: await script.file.async('text') })));
86 :
87 1 : const data = await Promise.all([...destinations.values()]
88 1 : .filter(entry => destinationKey(entry.path).startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`))
89 1 : .sort((a, b) => NATURAL_SORT.compare(a.path, b.path))
90 1 : .map(async entry => ({ name: entry.path.split('/')[1], bytes: await entry.file.async('uint8array') })));
91 1 : return assembleSessionBundle(head, parsedScripts, data);
92 1 : }
93 :
94 1 : function validateDataPath(path: string): void {
95 1 : if (!path.replace(/\\/g, '/').toLowerCase().startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)) return;
96 1 : if (path.toLowerCase() === `${STORAGE_ATTACHMENTS_FOLDER}/`) return;
97 1 : const parts = path.split('/');
98 1 : if (parts.length !== 2 || parts[0].toLowerCase() !== STORAGE_ATTACHMENTS_FOLDER) {
99 1 : throw new Error(`Invalid ZIP: attachments must be directly inside attachments/: ${path}`);
100 : }
101 1 : validateDataFileName(parts[1]);
102 1 : }
103 :
104 : /// Import a portable session ZIP. By default the source UUID is preserved.
105 1 : export async function importSessionFromZip(
106 1 : zipBlob: Blob,
107 1 : backend: StorageBackend,
108 1 : optionsInput: SessionZipImportOptionsInput = {},
109 1 : ): Promise<string> {
110 1 : let bundle = await readSessionBundleFromZip(zipBlob);
111 1 : const { regenerateConnectionIds, ...options }: SessionZipImportOptions = typeof optionsInput === 'function'
112 0 : ? { targetSessionId: optionsInput(), targetIsFresh: true, regenerateConnectionIds: true }
113 1 : : optionsInput;
114 1 : if (regenerateConnectionIds) bundle = regenerateBundleConnectionIds(bundle);
115 1 : const sessionId = await writeSessionBundle(bundle, backend, options);
116 1 : await seedConnectionCatalogs(backend, bundle, sessionId);
117 1 : return sessionId;
118 1 : }
119 :
120 1 : function normalizeBundlePath(input: string): string {
121 1 : if (input.includes('\0')) throw new Error(`Invalid ZIP: NUL in path ${input}`);
122 1 : const path = input.replace(/\\/g, '/');
123 1 : if (path.startsWith('/')) {
124 0 : throw new Error(`Invalid ZIP: absolute path ${input}`);
125 : }
126 1 : const parts: string[] = [];
127 1 : for (const part of path.split('/')) {
128 1 : if (!part || part === '.') {
129 1 : continue;
130 : }
131 1 : if (part === '..') {
132 0 : throw new Error(`Invalid ZIP: path traversal ${input}`);
133 : }
134 1 : parts.push(part.normalize('NFC'));
135 1 : }
136 1 : return parts.join('/');
137 1 : }
138 :
139 1 : function destinationKey(path: string): string {
140 1 : return path.normalize('NFC').toLowerCase();
141 1 : }
142 :
143 : /// Is the path a top-level file of the session folder? Older versions name the files differently,
144 : /// the session migration needs any of them.
145 1 : function isTopLevelFile(key: string): boolean {
146 1 : return !key.includes('/') && key.startsWith('dashql-');
147 1 : }
148 :
149 1 : function isBundleFile(path: string): boolean {
150 1 : const key = destinationKey(path);
151 1 : return isTopLevelFile(key)
152 1 : || isBundleCatalogPath(path)
153 1 : || key.startsWith(`${STORAGE_ATTACHMENTS_FOLDER}/`)
154 1 : || (key.startsWith(`${destinationKey(STORAGE_SCRIPTS_FOLDER)}/`) && key.endsWith('.sql'));
155 1 : }
|