Line data Source code
1 : import JSZip from 'jszip';
2 :
3 : import * as app_event from '@ankoh/dashql-jsonschema/app_event.js';
4 :
5 : import type { StorageBackend, SessionData, ScriptData, DataFileData, ConnectionParams } from '../../storage/storage_backend.js';
6 : import {
7 : STORAGE_SESSION_FILE,
8 : STORAGE_ATTACHMENTS_FOLDER,
9 : validateDataFileName,
10 : STORAGE_SCRIPTS_FOLDER,
11 : STORAGE_CATALOG_FUNCTIONS,
12 : STORAGE_CATALOG_RELATIONS,
13 : } from '../../storage/storage_backend.js';
14 : import { BASE64URL_CODEC } from '../../utils/base64.js';
15 : import { SESSION_FORMAT_VERSION } from '../../storage/migration/versions.js';
16 : import { sanitizeConnectionParamsForSharing } from '../../connections/connection_params.js';
17 : import {
18 : bundleCatalogPath,
19 : pruneBundleConnections,
20 : readSessionBundle,
21 : regenerateBundleConnectionIds,
22 : type SessionBundle,
23 : type SessionBundleCatalog,
24 : } from '../../storage/session_bundle.js';
25 :
26 : /// The target platform a shared session link points at.
27 1 : export enum SessionLinkTarget {
28 1 : NATIVE,
29 1 : WEB
30 1 : }
31 :
32 : /// Options controlling how a session is exported to a ZIP.
33 : export interface SessionExportOptions {
34 : /// Include the catalogs of the connections of the session when they exist.
35 : withCatalog?: boolean;
36 : /// Transform the session metadata and the catalogs after they are loaded from the backend and
37 : /// before they are written into the ZIP. Scripts are exported verbatim from disk. The sharing
38 : /// path uses this to sanitize connection secrets, strip the login hint, or give the session and
39 : /// its connections fresh ids, the catalogs follow their connections.
40 : transformBundle?: (bundle: Pick<SessionBundle, 'session' | 'catalogs'>) => Pick<SessionBundle, 'session' | 'catalogs'>;
41 : }
42 :
43 : /// Options applied after sanitizing session connection parameters for sharing.
44 : export interface SharedSessionExportOptions {
45 : /// Carry the sharer's resolved account username in the shared connection identity.
46 : withLoginHint?: boolean;
47 : /// Include the catalogs of the connections of the session when present.
48 : withCatalog?: boolean;
49 : /// Keep the session and connection UUIDs instead of generating export-only identities.
50 : preserveUUIDs?: boolean;
51 : /// The names the user gave the connections (connectionId -> name), an import names new connections after them.
52 : connectionNames?: ReadonlyMap<string, string>;
53 : }
54 :
55 : type SharedSessionExportOptionsInput = SharedSessionExportOptions | boolean;
56 :
57 : /// Creates a ZIP file from session data and script folders
58 1 : export async function createSessionZip(
59 1 : sessionData: SessionData,
60 1 : scripts: ScriptData[],
61 1 : catalogs: readonly SessionBundleCatalog[] = [],
62 1 : data: readonly DataFileData[] = [],
63 1 : ): Promise<Blob> {
64 1 : const zip = new JSZip();
65 :
66 : // Add session metadata
67 1 : zip.file(STORAGE_SESSION_FILE, JSON.stringify({
68 1 : ...sessionData,
69 1 : connections: sessionData.connections.map(connection => ({
70 1 : connectionId: connection.connectionId,
71 1 : params: sanitizeConnectionParamsForSharing(connection.params),
72 1 : ...(connection.name == null ? {} : { name: connection.name }),
73 1 : })),
74 1 : }, null, 2));
75 : // The catalogs of the connections, an import seeds the connections that are new to the app
76 1 : for (const catalog of catalogs) {
77 1 : if (catalog.relations != null) zip.file(bundleCatalogPath(catalog.connectionId, STORAGE_CATALOG_RELATIONS), catalog.relations);
78 1 : if (catalog.functions != null) zip.file(bundleCatalogPath(catalog.connectionId, STORAGE_CATALOG_FUNCTIONS), catalog.functions);
79 1 : }
80 :
81 : // Add the flat ordered scripts.
82 1 : const scriptsFolder = zip.folder(STORAGE_SCRIPTS_FOLDER);
83 1 : if (!scriptsFolder) {
84 0 : throw new Error('Failed to create scripts folder in ZIP');
85 : }
86 :
87 1 : for (const script of scripts) {
88 1 : if (script.name.toLowerCase() === 'dashql-draft.sql') continue;
89 1 : scriptsFolder.file(script.name, script.sql);
90 1 : }
91 1 : for (const file of data) {
92 1 : validateDataFileName(file.name);
93 1 : zip.file(`${STORAGE_ATTACHMENTS_FOLDER}/${file.name}`, file.bytes);
94 1 : }
95 :
96 : // Generate ZIP blob with compression
97 1 : return await zip.generateAsync({
98 1 : type: 'blob',
99 1 : compression: 'DEFLATE',
100 1 : compressionOptions: { level: 6 }
101 1 : });
102 1 : }
103 :
104 : /// Exports a session as a ZIP file by loading from storage backend.
105 : ///
106 : /// Scripts are always exported exactly as they exist on disk. The export carries only the connections
107 : /// that the scripts use. Pass `options.transformBundle` to adjust the session metadata on the way out
108 : /// (e.g. sanitize connection secrets or drop the login hint when sharing).
109 1 : export async function exportSessionAsZip(
110 1 : sessionId: string,
111 1 : backend: StorageBackend,
112 1 : options: SessionExportOptions = {}
113 1 : ): Promise<Blob> {
114 1 : const bundle = pruneBundleConnections(await readSessionBundle(sessionId, backend, options.withCatalog ?? false));
115 :
116 : // Apply the caller's transform (sharing sanitization, name override, ...)
117 1 : const out = options.transformBundle ? options.transformBundle(bundle) : bundle;
118 : // Create ZIP from loaded data
119 1 : return await createSessionZip(out.session, bundle.scripts, out.catalogs, bundle.data);
120 1 : }
121 :
122 : /// Export a session as a shareable ZIP.
123 : ///
124 : /// Scripts and the session name are read straight from disk (via `exportSessionAsZip`). Connection
125 : /// params are replaced with their live values and sanitized, the connections carry the given names. By default the exported session and
126 : /// connections receive fresh UUIDs so importing a copy cannot collide with existing runtime state.
127 1 : export async function exportSessionAsSharedZip(
128 1 : backend: StorageBackend,
129 1 : sessionId: string,
130 1 : connectionParams: ReadonlyMap<string, ConnectionParams>,
131 1 : optionsInput: SharedSessionExportOptionsInput = {},
132 1 : ): Promise<Blob> {
133 1 : const options = typeof optionsInput === 'boolean' ? { withLoginHint: optionsInput } : optionsInput;
134 1 : const withLoginHint = options.withLoginHint ?? true;
135 1 : return await exportSessionAsZip(sessionId, backend, {
136 1 : withCatalog: options.withCatalog ?? true,
137 1 : transformBundle: bundle => {
138 1 : const { session } = bundle;
139 1 : const sanitized: SessionData = {
140 1 : ...session,
141 1 : connections: session.connections.map(connection => {
142 1 : const name = options.connectionNames?.get(connection.connectionId);
143 1 : return {
144 1 : ...connection,
145 1 : params: sanitizeConnectionParamsForSharing(
146 1 : connectionParams.get(connection.connectionId) ?? connection.params,
147 1 : withLoginHint,
148 1 : ),
149 1 : ...(name == null ? {} : { name }),
150 1 : };
151 1 : }),
152 1 : };
153 1 : return options.preserveUUIDs
154 1 : ? { ...bundle, session: sanitized }
155 1 : : regenerateBundleConnectionIds({ ...bundle, session: { ...sanitized, sessionId: crypto.randomUUID() } });
156 1 : },
157 1 : });
158 1 : }
159 :
160 : /// Export a session as a shareable link that carries the session ZIP inline.
161 1 : export async function exportSessionAsUrl(
162 1 : backend: StorageBackend,
163 1 : sessionId: string,
164 1 : connectionParams: any,
165 1 : target: SessionLinkTarget,
166 1 : optionsInput: SharedSessionExportOptionsInput = {},
167 1 : ): Promise<URL> {
168 1 : const options = typeof optionsInput === 'boolean' ? { withLoginHint: optionsInput } : optionsInput;
169 1 : const zipBlob = await exportSessionAsSharedZip(
170 1 : backend,
171 1 : sessionId,
172 1 : connectionParams,
173 1 : { ...options, withCatalog: options.withCatalog ?? false },
174 1 : );
175 1 : return await encodeSessionZipAsUrl(zipBlob, target);
176 1 : }
177 :
178 : /// Export a connection as a shareable link: the link carries an empty session that holds only the connection.
179 : /// The params are sanitized, the session and the connection receive fresh ids.
180 1 : export async function exportConnectionAsUrl(
181 1 : params: ConnectionParams,
182 1 : name: string | null,
183 1 : target: SessionLinkTarget,
184 1 : options: Pick<SharedSessionExportOptions, 'withLoginHint'> = {},
185 1 : ): Promise<URL> {
186 : const session: SessionData = {
187 1 : formatVersion: SESSION_FORMAT_VERSION,
188 1 : sessionId: crypto.randomUUID(),
189 1 : connections: [{
190 1 : connectionId: crypto.randomUUID(),
191 1 : params: sanitizeConnectionParamsForSharing(params, options.withLoginHint ?? true),
192 1 : ...(name == null ? {} : { name }),
193 1 : }],
194 1 : scripts: [],
195 1 : attachments: [],
196 1 : metadata: { createdAt: new Date().toISOString() },
197 : };
198 1 : return await encodeSessionZipAsUrl(await createSessionZip(session, []), target);
199 1 : }
200 :
201 : /// Encode a session ZIP as a link that carries it inline.
202 1 : async function encodeSessionZipAsUrl(zipBlob: Blob, target: SessionLinkTarget): Promise<URL> {
203 1 : const zipBytes = new Uint8Array(await zipBlob.arrayBuffer());
204 :
205 : // Wrap the zip in AppEventData - convert to base64 string as required by JSON schema
206 1 : const eventData: app_event.AppEventData = {
207 1 : session: BASE64URL_CODEC.encode(zipBytes.buffer)
208 1 : };
209 :
210 : // Encode the JSON to base64
211 1 : const eventDataJson = JSON.stringify(eventData);
212 1 : const eventDataBytes = new TextEncoder().encode(eventDataJson);
213 1 : const eventDataBase64 = BASE64URL_CODEC.encode(eventDataBytes.buffer);
214 :
215 1 : switch (target) {
216 1 : case SessionLinkTarget.WEB:
217 1 : return new URL(`${process.env.DASHQL_APP_URL!}?data=${eventDataBase64}`);
218 0 : case SessionLinkTarget.NATIVE:
219 0 : return new URL(`dashql://localhost?data=${eventDataBase64}`);
220 1 : }
221 1 : }
|