@databricks/appkit 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +2 -2
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/plugin/create/scaffold.js +2 -8
- package/dist/cli/commands/plugin/create/scaffold.js.map +1 -1
- package/dist/connectors/files/client.js +223 -0
- package/dist/connectors/files/client.js.map +1 -0
- package/dist/connectors/files/defaults.js +131 -0
- package/dist/connectors/files/defaults.js.map +1 -0
- package/dist/connectors/files/index.js +4 -0
- package/dist/connectors/genie/client.js.map +1 -1
- package/dist/connectors/genie/types.d.ts +1 -1
- package/dist/connectors/genie/types.d.ts.map +1 -1
- package/dist/connectors/index.js +3 -0
- package/dist/context/execution-context.js +7 -1
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +24 -4
- package/dist/core/appkit.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin/plugin.d.ts +24 -5
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +43 -10
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugin/to-plugin.d.ts +5 -2
- package/dist/plugin/to-plugin.d.ts.map +1 -1
- package/dist/plugin/to-plugin.js +5 -2
- package/dist/plugin/to-plugin.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts +1 -2
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js +1 -2
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/files/defaults.d.ts +1 -0
- package/dist/plugins/files/defaults.js +56 -0
- package/dist/plugins/files/defaults.js.map +1 -0
- package/dist/plugins/files/helpers.js +30 -0
- package/dist/plugins/files/helpers.js.map +1 -0
- package/dist/plugins/files/index.d.ts +3 -0
- package/dist/plugins/files/index.js +5 -0
- package/dist/plugins/files/manifest.js +40 -0
- package/dist/plugins/files/manifest.js.map +1 -0
- package/dist/plugins/files/plugin.d.ts +105 -0
- package/dist/plugins/files/plugin.d.ts.map +1 -0
- package/dist/plugins/files/plugin.js +714 -0
- package/dist/plugins/files/plugin.js.map +1 -0
- package/dist/plugins/files/types.d.ts +105 -0
- package/dist/plugins/files/types.d.ts.map +1 -0
- package/dist/plugins/genie/genie.d.ts +1 -2
- package/dist/plugins/genie/genie.d.ts.map +1 -1
- package/dist/plugins/genie/genie.js +1 -2
- package/dist/plugins/genie/genie.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/lakebase/lakebase.d.ts +1 -2
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js +1 -2
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/server/index.d.ts +2 -2
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js +9 -4
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/registry/manifest-loader.js +1 -1
- package/dist/registry/manifest-loader.js.map +1 -1
- package/dist/registry/types.d.ts +3 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry/types.js.map +1 -1
- package/dist/shared/src/genie.d.ts +16 -2
- package/dist/shared/src/genie.d.ts.map +1 -1
- package/dist/shared/src/index.d.ts +1 -1
- package/dist/shared/src/plugin.d.ts +12 -4
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/docs/api/appkit/Class.Plugin.md +60 -12
- package/docs/api/appkit/Class.ResourceRegistry.md +3 -3
- package/docs/api/appkit/Function.createApp.md +3 -3
- package/docs/api/appkit/Interface.PluginManifest.md +9 -3
- package/docs/api/appkit/TypeAlias.PluginData.md +45 -0
- package/docs/api/appkit/TypeAlias.ToPlugin.md +1 -1
- package/docs/api/appkit-ui/files/DirectoryList.md +36 -0
- package/docs/api/appkit-ui/files/FileBreadcrumb.md +27 -0
- package/docs/api/appkit-ui/files/FileEntry.md +27 -0
- package/docs/api/appkit-ui/files/FilePreviewPanel.md +32 -0
- package/docs/api/appkit-ui/files/NewFolderInput.md +30 -0
- package/docs/api/appkit-ui/genie/GenieQueryVisualization.md +29 -0
- package/docs/api/appkit.md +1 -0
- package/docs/configuration.md +15 -0
- package/docs/plugins/custom-plugins.md +4 -13
- package/docs/plugins/files.md +350 -0
- package/docs/plugins.md +2 -1
- package/llms.txt +9 -1
- package/package.json +1 -1
- package/dist/plugins/server/remote-tunnel/denied.html/denied.html +0 -68
- package/dist/plugins/server/remote-tunnel/index.html/index.html +0 -165
- package/dist/plugins/server/remote-tunnel/wait.html/wait.html +0 -158
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { createLogger } from "../../logging/logger.js";
|
|
2
|
+
import { AuthenticationError } from "../../errors/authentication.js";
|
|
3
|
+
import { init_errors } from "../../errors/index.js";
|
|
4
|
+
import { getWorkspaceClient, isInUserContext } from "../../context/execution-context.js";
|
|
5
|
+
import { init_context } from "../../context/index.js";
|
|
6
|
+
import { ResourceType } from "../../registry/types.generated.js";
|
|
7
|
+
import "../../registry/index.js";
|
|
8
|
+
import { Plugin } from "../../plugin/plugin.js";
|
|
9
|
+
import { toPlugin } from "../../plugin/to-plugin.js";
|
|
10
|
+
import "../../plugin/index.js";
|
|
11
|
+
import { contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
|
|
12
|
+
import { FilesConnector } from "../../connectors/files/client.js";
|
|
13
|
+
import "../../connectors/files/index.js";
|
|
14
|
+
import { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, FILES_READ_DEFAULTS, FILES_WRITE_DEFAULTS } from "./defaults.js";
|
|
15
|
+
import { parentDirectory, sanitizeFilename } from "./helpers.js";
|
|
16
|
+
import manifest_default from "./manifest.js";
|
|
17
|
+
import { ApiError } from "@databricks/sdk-experimental";
|
|
18
|
+
import { Readable } from "node:stream";
|
|
19
|
+
|
|
20
|
+
//#region src/plugins/files/plugin.ts
|
|
21
|
+
init_context();
|
|
22
|
+
init_errors();
|
|
23
|
+
const logger = createLogger("files");
|
|
24
|
+
var FilesPlugin = class FilesPlugin extends Plugin {
|
|
25
|
+
name = "files";
|
|
26
|
+
/** Plugin manifest declaring metadata and resource requirements. */
|
|
27
|
+
static manifest = manifest_default;
|
|
28
|
+
static description = "Files plugin for Databricks file operations";
|
|
29
|
+
volumeConnectors = {};
|
|
30
|
+
volumeConfigs = {};
|
|
31
|
+
volumeKeys = [];
|
|
32
|
+
/**
|
|
33
|
+
* Scans `process.env` for `DATABRICKS_VOLUME_*` keys and merges them with
|
|
34
|
+
* any explicitly configured volumes. Explicit config wins for per-volume
|
|
35
|
+
* overrides; auto-discovered volumes get default `{}` config.
|
|
36
|
+
*/
|
|
37
|
+
static discoverVolumes(config) {
|
|
38
|
+
const explicit = config.volumes ?? {};
|
|
39
|
+
const discovered = {};
|
|
40
|
+
const prefix = "DATABRICKS_VOLUME_";
|
|
41
|
+
for (const key of Object.keys(process.env)) {
|
|
42
|
+
if (!key.startsWith(prefix)) continue;
|
|
43
|
+
const suffix = key.slice(18);
|
|
44
|
+
if (!suffix) continue;
|
|
45
|
+
if (!process.env[key]) continue;
|
|
46
|
+
const volumeKey = suffix.toLowerCase();
|
|
47
|
+
if (!(volumeKey in explicit)) discovered[volumeKey] = {};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...discovered,
|
|
51
|
+
...explicit
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generates resource requirements dynamically from discovered + configured volumes.
|
|
56
|
+
* Each volume key maps to a `DATABRICKS_VOLUME_{KEY_UPPERCASE}` env var.
|
|
57
|
+
*/
|
|
58
|
+
static getResourceRequirements(config) {
|
|
59
|
+
const volumes = FilesPlugin.discoverVolumes(config);
|
|
60
|
+
return Object.keys(volumes).map((key) => ({
|
|
61
|
+
type: ResourceType.VOLUME,
|
|
62
|
+
alias: `volume-${key}`,
|
|
63
|
+
resourceKey: `volume-${key}`,
|
|
64
|
+
description: `Unity Catalog Volume for "${key}" file storage`,
|
|
65
|
+
permission: "WRITE_VOLUME",
|
|
66
|
+
fields: { path: {
|
|
67
|
+
env: `DATABRICKS_VOLUME_${key.toUpperCase()}`,
|
|
68
|
+
description: `Volume path for "${key}" (e.g. /Volumes/catalog/schema/volume_name)`
|
|
69
|
+
} },
|
|
70
|
+
required: true
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Warns when a method is called without a user context (i.e. as service principal).
|
|
75
|
+
* OBO access via `asUser(req)` is strongly recommended.
|
|
76
|
+
*/
|
|
77
|
+
warnIfNoUserContext(volumeKey, method) {
|
|
78
|
+
if (!isInUserContext()) logger.warn(`app.files("${volumeKey}").${method}() called without user context (service principal). Please use OBO instead: app.files("${volumeKey}").asUser(req).${method}()`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Throws when a method is called without a user context (i.e. as service principal).
|
|
82
|
+
* OBO access via `asUser(req)` is enforced for now.
|
|
83
|
+
*/
|
|
84
|
+
throwIfNoUserContext(volumeKey, method) {
|
|
85
|
+
if (!isInUserContext()) throw new Error(`app.files("${volumeKey}").${method}() called without user context (service principal). Use OBO instead: app.files("${volumeKey}").asUser(req).${method}()`);
|
|
86
|
+
}
|
|
87
|
+
constructor(config) {
|
|
88
|
+
super(config);
|
|
89
|
+
this.config = config;
|
|
90
|
+
if (config.customContentTypes) validateCustomContentTypes(config.customContentTypes);
|
|
91
|
+
const volumes = FilesPlugin.discoverVolumes(config);
|
|
92
|
+
this.volumeKeys = Object.keys(volumes);
|
|
93
|
+
for (const key of this.volumeKeys) {
|
|
94
|
+
const volumeCfg = volumes[key];
|
|
95
|
+
const envVar = `DATABRICKS_VOLUME_${key.toUpperCase()}`;
|
|
96
|
+
const volumePath = process.env[envVar];
|
|
97
|
+
const mergedConfig = {
|
|
98
|
+
maxUploadSize: volumeCfg.maxUploadSize ?? config.maxUploadSize,
|
|
99
|
+
customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes
|
|
100
|
+
};
|
|
101
|
+
this.volumeConfigs[key] = mergedConfig;
|
|
102
|
+
this.volumeConnectors[key] = new FilesConnector({
|
|
103
|
+
defaultVolume: volumePath,
|
|
104
|
+
timeout: config.timeout,
|
|
105
|
+
telemetry: config.telemetry,
|
|
106
|
+
customContentTypes: mergedConfig.customContentTypes
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Creates a VolumeAPI for a specific volume key.
|
|
112
|
+
* Each method warns if called outside a user context (service principal).
|
|
113
|
+
*/
|
|
114
|
+
createVolumeAPI(volumeKey) {
|
|
115
|
+
const connector = this.volumeConnectors[volumeKey];
|
|
116
|
+
return {
|
|
117
|
+
list: (directoryPath) => {
|
|
118
|
+
this.throwIfNoUserContext(volumeKey, `list`);
|
|
119
|
+
return connector.list(getWorkspaceClient(), directoryPath);
|
|
120
|
+
},
|
|
121
|
+
read: (filePath, options) => {
|
|
122
|
+
this.throwIfNoUserContext(volumeKey, `read`);
|
|
123
|
+
return connector.read(getWorkspaceClient(), filePath, options);
|
|
124
|
+
},
|
|
125
|
+
download: (filePath) => {
|
|
126
|
+
this.throwIfNoUserContext(volumeKey, `download`);
|
|
127
|
+
return connector.download(getWorkspaceClient(), filePath);
|
|
128
|
+
},
|
|
129
|
+
exists: (filePath) => {
|
|
130
|
+
this.throwIfNoUserContext(volumeKey, `exists`);
|
|
131
|
+
return connector.exists(getWorkspaceClient(), filePath);
|
|
132
|
+
},
|
|
133
|
+
metadata: (filePath) => {
|
|
134
|
+
this.throwIfNoUserContext(volumeKey, `metadata`);
|
|
135
|
+
return connector.metadata(getWorkspaceClient(), filePath);
|
|
136
|
+
},
|
|
137
|
+
upload: (filePath, contents, options) => {
|
|
138
|
+
this.throwIfNoUserContext(volumeKey, `upload`);
|
|
139
|
+
return connector.upload(getWorkspaceClient(), filePath, contents, options);
|
|
140
|
+
},
|
|
141
|
+
createDirectory: (directoryPath) => {
|
|
142
|
+
this.throwIfNoUserContext(volumeKey, `createDirectory`);
|
|
143
|
+
return connector.createDirectory(getWorkspaceClient(), directoryPath);
|
|
144
|
+
},
|
|
145
|
+
delete: (filePath) => {
|
|
146
|
+
this.throwIfNoUserContext(volumeKey, `delete`);
|
|
147
|
+
return connector.delete(getWorkspaceClient(), filePath);
|
|
148
|
+
},
|
|
149
|
+
preview: (filePath) => {
|
|
150
|
+
this.throwIfNoUserContext(volumeKey, `preview`);
|
|
151
|
+
return connector.preview(getWorkspaceClient(), filePath);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
injectRoutes(router) {
|
|
156
|
+
this.route(router, {
|
|
157
|
+
name: "volumes",
|
|
158
|
+
method: "get",
|
|
159
|
+
path: "/volumes",
|
|
160
|
+
handler: async (_req, res) => {
|
|
161
|
+
res.json({ volumes: this.volumeKeys });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
this.route(router, {
|
|
165
|
+
name: "list",
|
|
166
|
+
method: "get",
|
|
167
|
+
path: "/:volumeKey/list",
|
|
168
|
+
handler: async (req, res) => {
|
|
169
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
170
|
+
if (!connector) return;
|
|
171
|
+
await this._handleList(req, res, connector, volumeKey);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
this.route(router, {
|
|
175
|
+
name: "read",
|
|
176
|
+
method: "get",
|
|
177
|
+
path: "/:volumeKey/read",
|
|
178
|
+
handler: async (req, res) => {
|
|
179
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
180
|
+
if (!connector) return;
|
|
181
|
+
await this._handleRead(req, res, connector, volumeKey);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
this.route(router, {
|
|
185
|
+
name: "download",
|
|
186
|
+
method: "get",
|
|
187
|
+
path: "/:volumeKey/download",
|
|
188
|
+
handler: async (req, res) => {
|
|
189
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
190
|
+
if (!connector) return;
|
|
191
|
+
await this._handleDownload(req, res, connector, volumeKey);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
this.route(router, {
|
|
195
|
+
name: "raw",
|
|
196
|
+
method: "get",
|
|
197
|
+
path: "/:volumeKey/raw",
|
|
198
|
+
handler: async (req, res) => {
|
|
199
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
200
|
+
if (!connector) return;
|
|
201
|
+
await this._handleRaw(req, res, connector, volumeKey);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
this.route(router, {
|
|
205
|
+
name: "exists",
|
|
206
|
+
method: "get",
|
|
207
|
+
path: "/:volumeKey/exists",
|
|
208
|
+
handler: async (req, res) => {
|
|
209
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
210
|
+
if (!connector) return;
|
|
211
|
+
await this._handleExists(req, res, connector, volumeKey);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
this.route(router, {
|
|
215
|
+
name: "metadata",
|
|
216
|
+
method: "get",
|
|
217
|
+
path: "/:volumeKey/metadata",
|
|
218
|
+
handler: async (req, res) => {
|
|
219
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
220
|
+
if (!connector) return;
|
|
221
|
+
await this._handleMetadata(req, res, connector, volumeKey);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
this.route(router, {
|
|
225
|
+
name: "preview",
|
|
226
|
+
method: "get",
|
|
227
|
+
path: "/:volumeKey/preview",
|
|
228
|
+
handler: async (req, res) => {
|
|
229
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
230
|
+
if (!connector) return;
|
|
231
|
+
await this._handlePreview(req, res, connector, volumeKey);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
this.route(router, {
|
|
235
|
+
name: "upload",
|
|
236
|
+
method: "post",
|
|
237
|
+
path: "/:volumeKey/upload",
|
|
238
|
+
skipBodyParsing: true,
|
|
239
|
+
handler: async (req, res) => {
|
|
240
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
241
|
+
if (!connector) return;
|
|
242
|
+
await this._handleUpload(req, res, connector, volumeKey);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
this.route(router, {
|
|
246
|
+
name: "mkdir",
|
|
247
|
+
method: "post",
|
|
248
|
+
path: "/:volumeKey/mkdir",
|
|
249
|
+
handler: async (req, res) => {
|
|
250
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
251
|
+
if (!connector) return;
|
|
252
|
+
await this._handleMkdir(req, res, connector, volumeKey);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
this.route(router, {
|
|
256
|
+
name: "delete",
|
|
257
|
+
method: "delete",
|
|
258
|
+
path: "/:volumeKey",
|
|
259
|
+
handler: async (req, res) => {
|
|
260
|
+
const { connector, volumeKey } = this._resolveVolume(req, res);
|
|
261
|
+
if (!connector) return;
|
|
262
|
+
await this._handleDelete(req, res, connector, volumeKey);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Resolve `:volumeKey` from the request. Returns the connector and key,
|
|
268
|
+
* or sends a 404 and returns `{ connector: undefined }`.
|
|
269
|
+
*/
|
|
270
|
+
_resolveVolume(req, res) {
|
|
271
|
+
const volumeKey = req.params.volumeKey;
|
|
272
|
+
const connector = this.volumeConnectors[volumeKey];
|
|
273
|
+
if (!connector) {
|
|
274
|
+
const safeKey = volumeKey.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
275
|
+
res.status(404).json({
|
|
276
|
+
error: `Unknown volume "${safeKey}"`,
|
|
277
|
+
plugin: this.name
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
connector: void 0,
|
|
281
|
+
volumeKey: void 0
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
connector,
|
|
286
|
+
volumeKey
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Validate a file/directory path from user input.
|
|
291
|
+
* Returns `true` if valid, or an error message string if invalid.
|
|
292
|
+
*/
|
|
293
|
+
_isValidPath(path) {
|
|
294
|
+
if (!path) return "path is required";
|
|
295
|
+
if (path.length > 4096) return `path exceeds maximum length of 4096 characters (got ${path.length})`;
|
|
296
|
+
if (path.includes("\0")) return "path must not contain null bytes";
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
_readSettings(cacheKey) {
|
|
300
|
+
return { default: {
|
|
301
|
+
...FILES_READ_DEFAULTS,
|
|
302
|
+
cache: {
|
|
303
|
+
...FILES_READ_DEFAULTS.cache,
|
|
304
|
+
cacheKey
|
|
305
|
+
}
|
|
306
|
+
} };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Invalidate cached list entries for a directory after a write operation.
|
|
310
|
+
* Uses the same cache-key format as `_handleList`: resolved path for
|
|
311
|
+
* subdirectories, `"__root__"` for the volume root.
|
|
312
|
+
*/
|
|
313
|
+
_invalidateListCache(volumeKey, parentPath, userId, connector) {
|
|
314
|
+
const parent = parentDirectory(parentPath);
|
|
315
|
+
const cachePathSegment = parent ? connector.resolvePath(parent) : "__root__";
|
|
316
|
+
const listKey = this.cache.generateKey([`files:${volumeKey}:list`, cachePathSegment], userId);
|
|
317
|
+
this.cache.delete(listKey);
|
|
318
|
+
}
|
|
319
|
+
_handleApiError(res, error, fallbackMessage) {
|
|
320
|
+
if (error instanceof AuthenticationError) {
|
|
321
|
+
res.status(401).json({
|
|
322
|
+
error: error.message,
|
|
323
|
+
plugin: this.name
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (error instanceof ApiError) {
|
|
328
|
+
const status = error.statusCode ?? 500;
|
|
329
|
+
if (status >= 400 && status < 500) {
|
|
330
|
+
res.status(status).json({
|
|
331
|
+
error: error.message,
|
|
332
|
+
statusCode: status,
|
|
333
|
+
plugin: this.name
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
logger.error("Upstream server error in %s: %O", this.name, error);
|
|
338
|
+
res.status(500).json({
|
|
339
|
+
error: fallbackMessage,
|
|
340
|
+
plugin: this.name
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
logger.error("Unhandled error in %s: %O", this.name, error);
|
|
345
|
+
res.status(500).json({
|
|
346
|
+
error: fallbackMessage,
|
|
347
|
+
plugin: this.name
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async _handleList(req, res, connector, volumeKey) {
|
|
351
|
+
const path = req.query.path;
|
|
352
|
+
try {
|
|
353
|
+
const result = await this.asUser(req).execute(async () => {
|
|
354
|
+
this.warnIfNoUserContext(volumeKey, `list`);
|
|
355
|
+
return connector.list(getWorkspaceClient(), path);
|
|
356
|
+
}, this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"]));
|
|
357
|
+
if (result === void 0) {
|
|
358
|
+
res.status(500).json({
|
|
359
|
+
error: "List failed",
|
|
360
|
+
plugin: this.name
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
res.json(result);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
this._handleApiError(res, error, "List failed");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async _handleRead(req, res, connector, volumeKey) {
|
|
370
|
+
const path = req.query.path;
|
|
371
|
+
const valid = this._isValidPath(path);
|
|
372
|
+
if (valid !== true) {
|
|
373
|
+
res.status(400).json({
|
|
374
|
+
error: valid,
|
|
375
|
+
plugin: this.name
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const result = await this.asUser(req).execute(async () => {
|
|
381
|
+
this.warnIfNoUserContext(volumeKey, `read`);
|
|
382
|
+
return connector.read(getWorkspaceClient(), path);
|
|
383
|
+
}, this._readSettings([`files:${volumeKey}:read`, connector.resolvePath(path)]));
|
|
384
|
+
if (result === void 0) {
|
|
385
|
+
res.status(500).json({
|
|
386
|
+
error: "Read failed",
|
|
387
|
+
plugin: this.name
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
res.type("text/plain").send(result);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
this._handleApiError(res, error, "Read failed");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async _handleDownload(req, res, connector, volumeKey) {
|
|
397
|
+
return this._serveFile(req, res, connector, volumeKey, { mode: "download" });
|
|
398
|
+
}
|
|
399
|
+
async _handleRaw(req, res, connector, volumeKey) {
|
|
400
|
+
return this._serveFile(req, res, connector, volumeKey, { mode: "raw" });
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Shared handler for `/download` and `/raw` endpoints.
|
|
404
|
+
* - `download`: always forces `Content-Disposition: attachment`.
|
|
405
|
+
* - `raw`: adds CSP sandbox; forces attachment only for unsafe content types.
|
|
406
|
+
*/
|
|
407
|
+
async _serveFile(req, res, connector, volumeKey, opts) {
|
|
408
|
+
const path = req.query.path;
|
|
409
|
+
const valid = this._isValidPath(path);
|
|
410
|
+
if (valid !== true) {
|
|
411
|
+
res.status(400).json({
|
|
412
|
+
error: valid,
|
|
413
|
+
plugin: this.name
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const label = opts.mode === "download" ? "Download" : "Raw fetch";
|
|
418
|
+
const volumeCfg = this.volumeConfigs[volumeKey];
|
|
419
|
+
try {
|
|
420
|
+
const userPlugin = this.asUser(req);
|
|
421
|
+
const settings = { default: FILES_DOWNLOAD_DEFAULTS };
|
|
422
|
+
const response = await userPlugin.execute(async () => {
|
|
423
|
+
this.warnIfNoUserContext(volumeKey, `download`);
|
|
424
|
+
return connector.download(getWorkspaceClient(), path);
|
|
425
|
+
}, settings);
|
|
426
|
+
if (response === void 0) {
|
|
427
|
+
res.status(500).json({
|
|
428
|
+
error: `${label} failed`,
|
|
429
|
+
plugin: this.name
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const resolvedType = contentTypeFromPath(path, void 0, volumeCfg.customContentTypes);
|
|
434
|
+
const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
|
|
435
|
+
res.setHeader("Content-Type", resolvedType);
|
|
436
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
437
|
+
if (opts.mode === "raw") {
|
|
438
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
439
|
+
if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
440
|
+
} else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
441
|
+
if (response.contents) {
|
|
442
|
+
const nodeStream = Readable.fromWeb(response.contents);
|
|
443
|
+
nodeStream.on("error", (err) => {
|
|
444
|
+
logger.error("Stream error during %s: %O", opts.mode, err);
|
|
445
|
+
if (!res.headersSent) res.status(500).json({
|
|
446
|
+
error: `${label} failed`,
|
|
447
|
+
plugin: this.name
|
|
448
|
+
});
|
|
449
|
+
else res.destroy();
|
|
450
|
+
});
|
|
451
|
+
nodeStream.pipe(res);
|
|
452
|
+
} else res.end();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
this._handleApiError(res, error, `${label} failed`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async _handleExists(req, res, connector, volumeKey) {
|
|
458
|
+
const path = req.query.path;
|
|
459
|
+
const valid = this._isValidPath(path);
|
|
460
|
+
if (valid !== true) {
|
|
461
|
+
res.status(400).json({
|
|
462
|
+
error: valid,
|
|
463
|
+
plugin: this.name
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const result = await this.asUser(req).execute(async () => {
|
|
469
|
+
this.warnIfNoUserContext(volumeKey, `exists`);
|
|
470
|
+
return connector.exists(getWorkspaceClient(), path);
|
|
471
|
+
}, this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)]));
|
|
472
|
+
if (result === void 0) {
|
|
473
|
+
res.status(500).json({
|
|
474
|
+
error: "Exists check failed",
|
|
475
|
+
plugin: this.name
|
|
476
|
+
});
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
res.json({ exists: result });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this._handleApiError(res, error, "Exists check failed");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async _handleMetadata(req, res, connector, volumeKey) {
|
|
485
|
+
const path = req.query.path;
|
|
486
|
+
const valid = this._isValidPath(path);
|
|
487
|
+
if (valid !== true) {
|
|
488
|
+
res.status(400).json({
|
|
489
|
+
error: valid,
|
|
490
|
+
plugin: this.name
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const result = await this.asUser(req).execute(async () => {
|
|
496
|
+
this.warnIfNoUserContext(volumeKey, `metadata`);
|
|
497
|
+
return connector.metadata(getWorkspaceClient(), path);
|
|
498
|
+
}, this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)]));
|
|
499
|
+
if (result === void 0) {
|
|
500
|
+
res.status(500).json({
|
|
501
|
+
error: "Metadata fetch failed",
|
|
502
|
+
plugin: this.name
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
res.json(result);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
this._handleApiError(res, error, "Metadata fetch failed");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async _handlePreview(req, res, connector, volumeKey) {
|
|
512
|
+
const path = req.query.path;
|
|
513
|
+
const valid = this._isValidPath(path);
|
|
514
|
+
if (valid !== true) {
|
|
515
|
+
res.status(400).json({
|
|
516
|
+
error: valid,
|
|
517
|
+
plugin: this.name
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const result = await this.asUser(req).execute(async () => {
|
|
523
|
+
this.warnIfNoUserContext(volumeKey, `preview`);
|
|
524
|
+
return connector.preview(getWorkspaceClient(), path);
|
|
525
|
+
}, this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)]));
|
|
526
|
+
if (result === void 0) {
|
|
527
|
+
res.status(500).json({
|
|
528
|
+
error: "Preview failed",
|
|
529
|
+
plugin: this.name
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
res.json(result);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
this._handleApiError(res, error, "Preview failed");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async _handleUpload(req, res, connector, volumeKey) {
|
|
539
|
+
const path = req.query.path;
|
|
540
|
+
const valid = this._isValidPath(path);
|
|
541
|
+
if (valid !== true) {
|
|
542
|
+
res.status(400).json({
|
|
543
|
+
error: valid,
|
|
544
|
+
plugin: this.name
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const maxSize = this.volumeConfigs[volumeKey].maxUploadSize ?? FILES_MAX_UPLOAD_SIZE;
|
|
549
|
+
const rawContentLength = req.headers["content-length"];
|
|
550
|
+
const contentLength = rawContentLength ? parseInt(rawContentLength, 10) : void 0;
|
|
551
|
+
if (contentLength !== void 0 && !Number.isNaN(contentLength) && contentLength > maxSize) {
|
|
552
|
+
res.status(413).json({
|
|
553
|
+
error: `File size (${contentLength} bytes) exceeds maximum allowed size (${maxSize} bytes).`,
|
|
554
|
+
plugin: this.name
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
logger.debug(req, "Upload started: volume=%s path=%s", volumeKey, path);
|
|
559
|
+
try {
|
|
560
|
+
const rawStream = Readable.toWeb(req);
|
|
561
|
+
let bytesReceived = 0;
|
|
562
|
+
const webStream = rawStream.pipeThrough(new TransformStream({ transform(chunk, controller) {
|
|
563
|
+
bytesReceived += chunk.byteLength;
|
|
564
|
+
if (bytesReceived > maxSize) {
|
|
565
|
+
controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds maximum allowed size (${maxSize} bytes)`));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
controller.enqueue(chunk);
|
|
569
|
+
} }));
|
|
570
|
+
logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
571
|
+
const userPlugin = this.asUser(req);
|
|
572
|
+
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
573
|
+
const result = await this.trackWrite(() => userPlugin.execute(async () => {
|
|
574
|
+
this.warnIfNoUserContext(volumeKey, `upload`);
|
|
575
|
+
await connector.upload(getWorkspaceClient(), path, webStream);
|
|
576
|
+
return { success: true };
|
|
577
|
+
}, settings));
|
|
578
|
+
this._invalidateListCache(volumeKey, path, this.resolveUserId(req), connector);
|
|
579
|
+
if (result === void 0) {
|
|
580
|
+
logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
581
|
+
res.status(500).json({
|
|
582
|
+
error: "Upload failed",
|
|
583
|
+
plugin: this.name
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
|
|
588
|
+
res.json(result);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error instanceof Error && error.message.includes("exceeds maximum allowed size")) {
|
|
591
|
+
res.status(413).json({
|
|
592
|
+
error: error.message,
|
|
593
|
+
plugin: this.name
|
|
594
|
+
});
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
this._handleApiError(res, error, "Upload failed");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async _handleMkdir(req, res, connector, volumeKey) {
|
|
601
|
+
const dirPath = typeof req.body?.path === "string" ? req.body.path : void 0;
|
|
602
|
+
const valid = this._isValidPath(dirPath);
|
|
603
|
+
if (valid !== true) {
|
|
604
|
+
res.status(400).json({
|
|
605
|
+
error: valid,
|
|
606
|
+
plugin: this.name
|
|
607
|
+
});
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const userPlugin = this.asUser(req);
|
|
612
|
+
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
613
|
+
const result = await this.trackWrite(() => userPlugin.execute(async () => {
|
|
614
|
+
this.warnIfNoUserContext(volumeKey, `createDirectory`);
|
|
615
|
+
await connector.createDirectory(getWorkspaceClient(), dirPath);
|
|
616
|
+
return { success: true };
|
|
617
|
+
}, settings));
|
|
618
|
+
this._invalidateListCache(volumeKey, dirPath, this.resolveUserId(req), connector);
|
|
619
|
+
if (result === void 0) {
|
|
620
|
+
res.status(500).json({
|
|
621
|
+
error: "Create directory failed",
|
|
622
|
+
plugin: this.name
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
res.json(result);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
this._handleApiError(res, error, "Create directory failed");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async _handleDelete(req, res, connector, volumeKey) {
|
|
632
|
+
const rawPath = req.query.path;
|
|
633
|
+
const valid = this._isValidPath(rawPath);
|
|
634
|
+
if (valid !== true) {
|
|
635
|
+
res.status(400).json({
|
|
636
|
+
error: valid,
|
|
637
|
+
plugin: this.name
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const path = rawPath;
|
|
642
|
+
try {
|
|
643
|
+
const userPlugin = this.asUser(req);
|
|
644
|
+
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
645
|
+
const result = await this.trackWrite(() => userPlugin.execute(async () => {
|
|
646
|
+
this.warnIfNoUserContext(volumeKey, `delete`);
|
|
647
|
+
await connector.delete(getWorkspaceClient(), path);
|
|
648
|
+
return { success: true };
|
|
649
|
+
}, settings));
|
|
650
|
+
this._invalidateListCache(volumeKey, path, this.resolveUserId(req), connector);
|
|
651
|
+
if (result === void 0) {
|
|
652
|
+
res.status(500).json({
|
|
653
|
+
error: "Delete failed",
|
|
654
|
+
plugin: this.name
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
res.json(result);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this._handleApiError(res, error, "Delete failed");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
inflightWrites = 0;
|
|
664
|
+
trackWrite(fn) {
|
|
665
|
+
this.inflightWrites++;
|
|
666
|
+
return fn().finally(() => {
|
|
667
|
+
this.inflightWrites--;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async shutdown() {
|
|
671
|
+
const deadline = Date.now() + 1e4;
|
|
672
|
+
while (this.inflightWrites > 0 && Date.now() < deadline) {
|
|
673
|
+
logger.info("Waiting for %d in-flight write(s) to complete before shutdown…", this.inflightWrites);
|
|
674
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
675
|
+
}
|
|
676
|
+
if (this.inflightWrites > 0) logger.warn("Shutdown deadline reached with %d in-flight write(s) still pending.", this.inflightWrites);
|
|
677
|
+
this.streamManager.abortAll();
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Returns the programmatic API for the Files plugin.
|
|
681
|
+
* Callable with a volume key to get a volume-scoped handle.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```ts
|
|
685
|
+
* // OBO access (recommended)
|
|
686
|
+
* appKit.files("uploads").asUser(req).list()
|
|
687
|
+
*
|
|
688
|
+
* // Service principal access (logs a warning)
|
|
689
|
+
* appKit.files("uploads").list()
|
|
690
|
+
* ```
|
|
691
|
+
*/
|
|
692
|
+
exports() {
|
|
693
|
+
const resolveVolume = (volumeKey) => {
|
|
694
|
+
if (!this.volumeKeys.includes(volumeKey)) throw new Error(`Unknown volume "${volumeKey}". Available volumes: ${this.volumeKeys.join(", ")}`);
|
|
695
|
+
return {
|
|
696
|
+
...this.createVolumeAPI(volumeKey),
|
|
697
|
+
asUser: (req) => {
|
|
698
|
+
return this.asUser(req).createVolumeAPI(volumeKey);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
};
|
|
702
|
+
const filesExport = ((volumeKey) => resolveVolume(volumeKey));
|
|
703
|
+
filesExport.volume = resolveVolume;
|
|
704
|
+
return filesExport;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
/**
|
|
708
|
+
* @internal
|
|
709
|
+
*/
|
|
710
|
+
const files$1 = toPlugin(FilesPlugin);
|
|
711
|
+
|
|
712
|
+
//#endregion
|
|
713
|
+
export { FilesPlugin, files$1 as files };
|
|
714
|
+
//# sourceMappingURL=plugin.js.map
|