streamby-core 0.37.0 → 0.38.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/CHANGELOG.md +63 -0
- package/README.md +62 -1
- package/dist/adapters/storage/s3.d.ts +3 -2
- package/dist/adapters/storage/s3.js +18 -5
- package/dist/mcp/server.js +12 -0
- package/dist/mcp/tools.d.ts +2 -0
- package/dist/mcp/tools.js +107 -14
- package/dist/mcp/workflowNodes.d.ts +24 -0
- package/dist/mcp/workflowNodes.js +48 -0
- package/dist/middleware/createRouter.js +2 -0
- package/dist/middleware/routes/deliverable.d.ts +3 -0
- package/dist/middleware/routes/deliverable.js +131 -0
- package/dist/middleware/routes/export.js +2 -0
- package/dist/middleware/routes/production.js +60 -67
- package/dist/middleware/routes/project.js +8 -2
- package/dist/middleware/routes/storage.js +115 -24
- package/dist/services/apiTokenPermissions.js +5 -1
- package/dist/services/assetVersions.d.ts +48 -0
- package/dist/services/assetVersions.js +84 -0
- package/dist/services/deliverables.d.ts +26 -0
- package/dist/services/deliverables.js +55 -0
- package/dist/services/mediaProcessor.js +3 -1
- package/dist/services/production.d.ts +2 -0
- package/dist/services/production.js +13 -0
- package/dist/services/productionFields.d.ts +16 -0
- package/dist/services/productionFields.js +80 -0
- package/dist/services/signedUrls.d.ts +6 -0
- package/dist/services/signedUrls.js +9 -0
- package/dist/types/index.d.ts +7 -2
- package/dist/utils/nosqlDb.d.ts +2 -0
- package/dist/utils/nosqlDb.js +11 -0
- package/package.json +1 -1
|
@@ -15,6 +15,9 @@ const builtinAccess_1 = require("../../utils/builtinAccess");
|
|
|
15
15
|
const connectionResolver_1 = require("../../services/connectionResolver");
|
|
16
16
|
const mongodb_1 = require("mongodb");
|
|
17
17
|
const crypto_1 = __importDefault(require("crypto"));
|
|
18
|
+
const signedUrls_1 = require("../../services/signedUrls");
|
|
19
|
+
const assetVersions_1 = require("../../services/assetVersions");
|
|
20
|
+
const deliverables_1 = require("../../services/deliverables");
|
|
18
21
|
const VALID_CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
|
|
19
22
|
const VALID_3D_EXTENSIONS = new Set(['.glb', '.gltf', '.obj', '.fbx', '.stl', '.ply']);
|
|
20
23
|
function validateContentType(contentType, filename, category) {
|
|
@@ -135,7 +138,7 @@ function storageRouter(config) {
|
|
|
135
138
|
const storageKey = `${projectId}/${category}/${fileId}${ext}`;
|
|
136
139
|
const collection = getRawFilesCollection();
|
|
137
140
|
if (collection) {
|
|
138
|
-
|
|
141
|
+
const fileDoc = {
|
|
139
142
|
_id: new mongodb_1.ObjectId(),
|
|
140
143
|
fileId,
|
|
141
144
|
projectId,
|
|
@@ -145,10 +148,13 @@ function storageRouter(config) {
|
|
|
145
148
|
contentType,
|
|
146
149
|
uploadedBy: auth.userId,
|
|
147
150
|
createdAt: new Date(),
|
|
148
|
-
}
|
|
151
|
+
};
|
|
152
|
+
await collection.insertOne(fileDoc);
|
|
153
|
+
await (0, assetVersions_1.recordFirstVersion)(fileDoc);
|
|
149
154
|
}
|
|
150
|
-
const
|
|
151
|
-
|
|
155
|
+
const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
|
|
156
|
+
const url = await adapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
|
|
157
|
+
res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
|
|
152
158
|
}
|
|
153
159
|
catch (err) {
|
|
154
160
|
res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
|
|
@@ -165,7 +171,7 @@ function storageRouter(config) {
|
|
|
165
171
|
const project = await Project.findOne({ _id: projectId });
|
|
166
172
|
if (!project)
|
|
167
173
|
return res.status(404).json({ message: 'Project not found' });
|
|
168
|
-
if (!(0, auth_1.
|
|
174
|
+
if (!(0, auth_1.canEditProject)(project, auth.userId))
|
|
169
175
|
return res.status(403).json({ message: 'Unauthorized access' });
|
|
170
176
|
const collection = getRawFilesCollection();
|
|
171
177
|
if (!collection)
|
|
@@ -179,9 +185,21 @@ function storageRouter(config) {
|
|
|
179
185
|
if (!adapter.getPresignedUploadUrl) {
|
|
180
186
|
return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
|
|
181
187
|
}
|
|
182
|
-
|
|
188
|
+
// The new version goes to the asset's own key, so its URL doesn't change; the current
|
|
189
|
+
// one is archived first, and without a way to archive it nothing is replaced.
|
|
190
|
+
if (!adapter.copyFile)
|
|
191
|
+
return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
|
|
192
|
+
let version;
|
|
193
|
+
try {
|
|
194
|
+
version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, adapter.copyFile.bind(adapter));
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
|
|
198
|
+
}
|
|
199
|
+
const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
|
|
200
|
+
const url = await adapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
|
|
183
201
|
await collection.updateOne({ fileId, projectId }, { $set: { contentType, updatedAt: new Date() } });
|
|
184
|
-
res.json({ url, storageKey: fileDoc.storageKey, fileId });
|
|
202
|
+
res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
|
|
185
203
|
}
|
|
186
204
|
catch (err) {
|
|
187
205
|
res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
|
|
@@ -227,8 +245,7 @@ function storageRouter(config) {
|
|
|
227
245
|
const fileDoc = await collection.findOne({ fileId, projectId });
|
|
228
246
|
if (!fileDoc)
|
|
229
247
|
return res.status(404).json({ message: 'File not found' });
|
|
230
|
-
|
|
231
|
-
await adapter.deleteFile(fileDoc.storageKey);
|
|
248
|
+
await deleteAsset(fileDoc, adapter);
|
|
232
249
|
await collection.deleteOne({ fileId, projectId });
|
|
233
250
|
res.json({ message: 'File deleted successfully' });
|
|
234
251
|
}
|
|
@@ -249,7 +266,7 @@ function storageRouter(config) {
|
|
|
249
266
|
if (!collection)
|
|
250
267
|
return res.status(500).json({ message: 'Database not available' });
|
|
251
268
|
const files = await collection.find({ projectId }).sort({ createdAt: -1 }).limit(12).toArray();
|
|
252
|
-
const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
|
|
269
|
+
const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
|
|
253
270
|
res.json({ data });
|
|
254
271
|
}
|
|
255
272
|
catch (err) {
|
|
@@ -272,13 +289,61 @@ function storageRouter(config) {
|
|
|
272
289
|
if (!collection)
|
|
273
290
|
return res.status(500).json({ message: 'Database not available' });
|
|
274
291
|
const files = await collection.find({ projectId, category }).sort({ createdAt: -1 }).toArray();
|
|
275
|
-
const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
|
|
292
|
+
const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
|
|
276
293
|
res.json({ data });
|
|
277
294
|
}
|
|
278
295
|
catch (err) {
|
|
279
296
|
res.status(500).json({ message: 'Failed to list files', details: err.message });
|
|
280
297
|
}
|
|
281
298
|
});
|
|
299
|
+
// A short-lived URL to download an asset, or one of its versions, wherever it's stored
|
|
300
|
+
// (the built-in storage or one of the project's storage connections). It's for that file
|
|
301
|
+
// only, and downloads it with its name.
|
|
302
|
+
router.get('/projects/:projectId/assets/:fileId/download-url', async (req, res) => {
|
|
303
|
+
try {
|
|
304
|
+
const auth = req.auth;
|
|
305
|
+
const { projectId, fileId } = req.params;
|
|
306
|
+
const { versionId } = req.query;
|
|
307
|
+
const project = await Project.findOne({ _id: projectId });
|
|
308
|
+
if (!project)
|
|
309
|
+
return res.status(404).json({ message: 'Project not found' });
|
|
310
|
+
if (!(0, auth_1.isProjectMember)(project, auth.userId))
|
|
311
|
+
return res.status(403).json({ message: 'Unauthorized access' });
|
|
312
|
+
const collection = getRawFilesCollection();
|
|
313
|
+
if (!collection)
|
|
314
|
+
return res.status(500).json({ message: 'Database not available' });
|
|
315
|
+
const fileDoc = await collection.findOne({ fileId, projectId });
|
|
316
|
+
if (!fileDoc)
|
|
317
|
+
return res.status(404).json({ message: 'File not found' });
|
|
318
|
+
let storageKey = fileDoc.storageKey;
|
|
319
|
+
let fileName = fileDoc.displayName;
|
|
320
|
+
let contentType = fileDoc.contentType;
|
|
321
|
+
let version;
|
|
322
|
+
if (versionId) {
|
|
323
|
+
const found = await (0, assetVersions_1.findVersion)(projectId, fileId, versionId);
|
|
324
|
+
if (!found)
|
|
325
|
+
return res.status(404).json({ message: 'Version not found' });
|
|
326
|
+
storageKey = found.storageKey;
|
|
327
|
+
fileName = found.fileName ?? fileName;
|
|
328
|
+
contentType = found.contentType ?? contentType;
|
|
329
|
+
version = found.version;
|
|
330
|
+
}
|
|
331
|
+
// Its storage connection's adapter; files stored before connections had their own row
|
|
332
|
+
// (no connection, or the built-in provider's id) are in the default storage.
|
|
333
|
+
const connId = fileDoc.storageConnectionId;
|
|
334
|
+
const fileAdapter = connId && findStorageConn(project, connId) ? await resolveConnAdapter(connId, project, auth) : adapter;
|
|
335
|
+
if ('error' in fileAdapter)
|
|
336
|
+
return res.status(fileAdapter.status).json({ message: fileAdapter.error });
|
|
337
|
+
if (!fileAdapter.getPresignedGetUrl)
|
|
338
|
+
return res.status(501).json({ message: 'Storage adapter does not support download URLs' });
|
|
339
|
+
const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'download') ?? 3600;
|
|
340
|
+
const url = await fileAdapter.getPresignedGetUrl(storageKey, expiresIn, fileName);
|
|
341
|
+
res.json({ url, fileId, fileName, contentType: contentType ?? null, ...(version ? { versionId, version } : {}), ...expiry(expiresIn) });
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
res.status(500).json({ message: 'Failed to generate download URL', details: err.message });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
282
347
|
// ─── Per-connection file routes ───────────────────────────────────────────
|
|
283
348
|
router.get('/projects/:projectId/connections/storage/:connId/upload-url', async (req, res) => {
|
|
284
349
|
try {
|
|
@@ -309,7 +374,7 @@ function storageRouter(config) {
|
|
|
309
374
|
const storageKey = `${projectId}/${category}/${fileId}${ext}`;
|
|
310
375
|
const collection = getRawFilesCollection();
|
|
311
376
|
if (collection) {
|
|
312
|
-
|
|
377
|
+
const fileDoc = {
|
|
313
378
|
_id: new mongodb_1.ObjectId(),
|
|
314
379
|
fileId,
|
|
315
380
|
projectId,
|
|
@@ -320,10 +385,13 @@ function storageRouter(config) {
|
|
|
320
385
|
contentType,
|
|
321
386
|
uploadedBy: auth.userId,
|
|
322
387
|
createdAt: new Date(),
|
|
323
|
-
}
|
|
388
|
+
};
|
|
389
|
+
await collection.insertOne(fileDoc);
|
|
390
|
+
await (0, assetVersions_1.recordFirstVersion)(fileDoc);
|
|
324
391
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
392
|
+
const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
|
|
393
|
+
const url = await connAdapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
|
|
394
|
+
res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
|
|
327
395
|
}
|
|
328
396
|
catch (err) {
|
|
329
397
|
res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
|
|
@@ -345,7 +413,7 @@ function storageRouter(config) {
|
|
|
345
413
|
if (!collection)
|
|
346
414
|
return res.status(500).json({ message: 'Database not available' });
|
|
347
415
|
const files = await collection.find(connFilter(projectId, connId, project)).sort({ createdAt: -1 }).limit(12).toArray();
|
|
348
|
-
const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
|
|
416
|
+
const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
|
|
349
417
|
res.json({ data });
|
|
350
418
|
}
|
|
351
419
|
catch (err) {
|
|
@@ -371,7 +439,7 @@ function storageRouter(config) {
|
|
|
371
439
|
if (!collection)
|
|
372
440
|
return res.status(500).json({ message: 'Database not available' });
|
|
373
441
|
const files = await collection.find(connFilter(projectId, connId, project, { category })).sort({ createdAt: -1 }).toArray();
|
|
374
|
-
const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
|
|
442
|
+
const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
|
|
375
443
|
res.json({ data });
|
|
376
444
|
}
|
|
377
445
|
catch (err) {
|
|
@@ -389,7 +457,7 @@ function storageRouter(config) {
|
|
|
389
457
|
const project = await Project.findOne({ _id: projectId });
|
|
390
458
|
if (!project)
|
|
391
459
|
return res.status(404).json({ message: 'Project not found' });
|
|
392
|
-
if (!(0, auth_1.
|
|
460
|
+
if (!(0, auth_1.canEditProject)(project, auth.userId))
|
|
393
461
|
return res.status(403).json({ message: 'Unauthorized access' });
|
|
394
462
|
const collection = getRawFilesCollection();
|
|
395
463
|
if (!collection)
|
|
@@ -406,9 +474,21 @@ function storageRouter(config) {
|
|
|
406
474
|
if (!connAdapter.getPresignedUploadUrl) {
|
|
407
475
|
return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
|
|
408
476
|
}
|
|
409
|
-
|
|
477
|
+
// The new version goes to the asset's own key, so its URL doesn't change; the current
|
|
478
|
+
// one is archived first, and without a way to archive it nothing is replaced.
|
|
479
|
+
if (!connAdapter.copyFile)
|
|
480
|
+
return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
|
|
481
|
+
let version;
|
|
482
|
+
try {
|
|
483
|
+
version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, connAdapter.copyFile.bind(connAdapter));
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
|
|
487
|
+
}
|
|
488
|
+
const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
|
|
489
|
+
const url = await connAdapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
|
|
410
490
|
await collection.updateOne(connFilter(projectId, connId, project, { fileId }), { $set: { contentType, updatedAt: new Date() } });
|
|
411
|
-
res.json({ url, storageKey: fileDoc.storageKey, fileId });
|
|
491
|
+
res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
|
|
412
492
|
}
|
|
413
493
|
catch (err) {
|
|
414
494
|
res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
|
|
@@ -457,8 +537,7 @@ function storageRouter(config) {
|
|
|
457
537
|
const fileDoc = await collection.findOne(connFilter(projectId, connId, project, { fileId }));
|
|
458
538
|
if (!fileDoc)
|
|
459
539
|
return res.status(404).json({ message: 'File not found' });
|
|
460
|
-
|
|
461
|
-
await connAdapter.deleteFile(fileDoc.storageKey);
|
|
540
|
+
await deleteAsset(fileDoc, connAdapter);
|
|
462
541
|
await collection.deleteOne(connFilter(projectId, connId, project, { fileId }));
|
|
463
542
|
res.json({ message: 'File deleted successfully' });
|
|
464
543
|
}
|
|
@@ -731,11 +810,11 @@ function storageRouter(config) {
|
|
|
731
810
|
});
|
|
732
811
|
return router;
|
|
733
812
|
}
|
|
734
|
-
async function resolveFileUrl(file, adapter) {
|
|
813
|
+
async function resolveFileUrl(file, adapter, expiresIn) {
|
|
735
814
|
let url = null;
|
|
736
815
|
if (adapter.getPresignedGetUrl) {
|
|
737
816
|
try {
|
|
738
|
-
url = await adapter.getPresignedGetUrl(file.storageKey);
|
|
817
|
+
url = await adapter.getPresignedGetUrl(file.storageKey, expiresIn);
|
|
739
818
|
}
|
|
740
819
|
catch { /* leave null */ }
|
|
741
820
|
}
|
|
@@ -751,3 +830,15 @@ async function resolveFileUrl(file, adapter) {
|
|
|
751
830
|
url,
|
|
752
831
|
};
|
|
753
832
|
}
|
|
833
|
+
// When a signed URL stops working, for a client to know (only when it's short-lived).
|
|
834
|
+
const expiry = (expiresIn) => expiresIn ? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() } : {};
|
|
835
|
+
// An asset's files (every version), its versions and its deliverables. Its record is the
|
|
836
|
+
// caller's to delete.
|
|
837
|
+
async function deleteAsset(fileDoc, adapter) {
|
|
838
|
+
const keys = await (0, assetVersions_1.versionStorageKeys)(fileDoc.projectId, fileDoc.fileId, fileDoc.storageKey);
|
|
839
|
+
if (adapter.deleteFile)
|
|
840
|
+
for (const key of keys)
|
|
841
|
+
await adapter.deleteFile(key);
|
|
842
|
+
await (0, assetVersions_1.deleteVersions)(fileDoc.projectId, fileDoc.fileId);
|
|
843
|
+
await (0, deliverables_1.deleteDeliverablesOf)(fileDoc.projectId, { assetId: fileDoc.fileId });
|
|
844
|
+
}
|
|
@@ -62,11 +62,15 @@ const RULES = [
|
|
|
62
62
|
rule('read', `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:read'),
|
|
63
63
|
rule(['POST', 'PATCH'], `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:write'),
|
|
64
64
|
rule('delete', `${P}/connections/storage/[^/]+/(files|folders)/[^/]+`, 'assets:delete'),
|
|
65
|
-
rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph)`, 'assets:read'),
|
|
65
|
+
rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph|download-url)`, 'assets:read'),
|
|
66
66
|
rule(['PATCH'], `${P}/assets/[^/]+/metadata`, 'assets:write'),
|
|
67
67
|
rule(['POST'], `${P}/assets/[^/]+/qc-report`, 'assets:write'),
|
|
68
68
|
rule('read', `${P}/(reviews(/[^/]+)?|assets/[^/]+/versions/[^/]+/annotations)`, 'assets:read'),
|
|
69
69
|
rule(['POST', 'PATCH'], `${P}/(reviews(/[^/]+/decision)?|assets/[^/]+/versions/[^/]+/annotations(/[^/]+/resolve)?)`, 'assets:write'),
|
|
70
|
+
// Deliverables: an asset (or a version) delivered for a shot or an export.
|
|
71
|
+
rule('read', `${P}/deliverables`, 'assets:read'),
|
|
72
|
+
rule(['POST'], `${P}/deliverables`, 'assets:write'),
|
|
73
|
+
rule('delete', `${P}/deliverables/[^/]+`, 'assets:write'),
|
|
70
74
|
// Rights.
|
|
71
75
|
rule('read', `${P}/assets/[^/]+/rights`, 'rights:read'),
|
|
72
76
|
rule(['PUT'], `${P}/assets/[^/]+/rights`, 'rights:write'),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface AssetVersion {
|
|
2
|
+
versionId: string;
|
|
3
|
+
assetId: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
version: number;
|
|
6
|
+
storageKey: string;
|
|
7
|
+
contentType: string | null;
|
|
8
|
+
fileName: string | null;
|
|
9
|
+
createdBy: string | null;
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
}
|
|
12
|
+
export declare const versionsCollection: () => import("mongodb").Collection<import("bson").Document> | null;
|
|
13
|
+
/** The key an asset's version `n` is archived in, next to the asset's own key. */
|
|
14
|
+
export declare const archivedVersionKey: (assetKey: string, version: number) => string;
|
|
15
|
+
/** Records the first version of an asset just made (or one made before versions were kept). */
|
|
16
|
+
export declare function recordFirstVersion(file: {
|
|
17
|
+
fileId: string;
|
|
18
|
+
projectId: string;
|
|
19
|
+
storageKey: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
displayName?: string;
|
|
22
|
+
uploadedBy?: string;
|
|
23
|
+
createdAt?: Date;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Makes room for a new version of an asset, to be uploaded to its own key: its current version
|
|
27
|
+
* is copied to an archive key first (`copyFile`) and recorded there. If the copy fails nothing
|
|
28
|
+
* changes, and it throws: the current version is never lost.
|
|
29
|
+
*/
|
|
30
|
+
export declare function archiveCurrentVersion(file: {
|
|
31
|
+
fileId: string;
|
|
32
|
+
projectId: string;
|
|
33
|
+
storageKey: string;
|
|
34
|
+
contentType?: string;
|
|
35
|
+
displayName?: string;
|
|
36
|
+
uploadedBy?: string;
|
|
37
|
+
createdAt?: Date;
|
|
38
|
+
}, upload: {
|
|
39
|
+
fileName: string;
|
|
40
|
+
contentType: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
}, copyFile: (from: string, to: string) => Promise<void>): Promise<AssetVersion>;
|
|
43
|
+
/** One of an asset's versions, or null. */
|
|
44
|
+
export declare function findVersion(projectId: string, assetId: string, versionId: string): Promise<AssetVersion | null>;
|
|
45
|
+
/** Every key an asset's versions are stored in (its current one included). */
|
|
46
|
+
export declare function versionStorageKeys(projectId: string, assetId: string, currentKey?: string): Promise<string[]>;
|
|
47
|
+
/** Forgets an asset's versions (once their files are deleted). */
|
|
48
|
+
export declare function deleteVersions(projectId: string, assetId: string): Promise<void>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.archivedVersionKey = exports.versionsCollection = void 0;
|
|
7
|
+
exports.recordFirstVersion = recordFirstVersion;
|
|
8
|
+
exports.archiveCurrentVersion = archiveCurrentVersion;
|
|
9
|
+
exports.findVersion = findVersion;
|
|
10
|
+
exports.versionStorageKeys = versionStorageKeys;
|
|
11
|
+
exports.deleteVersions = deleteVersions;
|
|
12
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
13
|
+
const mongodb_1 = require("mongodb");
|
|
14
|
+
const nosqlDb_1 = require("../utils/nosqlDb");
|
|
15
|
+
const versionsCollection = () => (0, nosqlDb_1.nosqlDb)()?.collection('asset_versions') ?? null;
|
|
16
|
+
exports.versionsCollection = versionsCollection;
|
|
17
|
+
/** The key an asset's version `n` is archived in, next to the asset's own key. */
|
|
18
|
+
const archivedVersionKey = (assetKey, version) => {
|
|
19
|
+
const extension = /(\.[^./]*)$/.exec(assetKey)?.[1] ?? '';
|
|
20
|
+
return `${assetKey.slice(0, assetKey.length - extension.length)}.v${version}${extension}`;
|
|
21
|
+
};
|
|
22
|
+
exports.archivedVersionKey = archivedVersionKey;
|
|
23
|
+
/** Records the first version of an asset just made (or one made before versions were kept). */
|
|
24
|
+
async function recordFirstVersion(file) {
|
|
25
|
+
const col = (0, exports.versionsCollection)();
|
|
26
|
+
if (!col)
|
|
27
|
+
return;
|
|
28
|
+
if (await col.findOne({ assetId: file.fileId, projectId: file.projectId }))
|
|
29
|
+
return;
|
|
30
|
+
await col.insertOne({
|
|
31
|
+
_id: new mongodb_1.ObjectId(),
|
|
32
|
+
versionId: crypto_1.default.randomUUID(),
|
|
33
|
+
assetId: file.fileId,
|
|
34
|
+
projectId: file.projectId,
|
|
35
|
+
version: 1,
|
|
36
|
+
storageKey: file.storageKey,
|
|
37
|
+
contentType: file.contentType ?? null,
|
|
38
|
+
fileName: file.displayName ?? null,
|
|
39
|
+
createdBy: file.uploadedBy ?? null,
|
|
40
|
+
createdAt: file.createdAt ?? new Date(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Makes room for a new version of an asset, to be uploaded to its own key: its current version
|
|
45
|
+
* is copied to an archive key first (`copyFile`) and recorded there. If the copy fails nothing
|
|
46
|
+
* changes, and it throws: the current version is never lost.
|
|
47
|
+
*/
|
|
48
|
+
async function archiveCurrentVersion(file, upload, copyFile) {
|
|
49
|
+
const col = (0, exports.versionsCollection)();
|
|
50
|
+
if (!col)
|
|
51
|
+
throw new Error('Database not available');
|
|
52
|
+
await recordFirstVersion(file);
|
|
53
|
+
const [current] = await col.find({ assetId: file.fileId, projectId: file.projectId }).sort({ version: -1 }).limit(1).toArray();
|
|
54
|
+
const currentVersion = current?.version ?? 1;
|
|
55
|
+
const archiveKey = (0, exports.archivedVersionKey)(file.storageKey, currentVersion);
|
|
56
|
+
await copyFile(file.storageKey, archiveKey);
|
|
57
|
+
await col.updateOne({ assetId: file.fileId, projectId: file.projectId, version: currentVersion }, { $set: { storageKey: archiveKey } });
|
|
58
|
+
const record = {
|
|
59
|
+
versionId: crypto_1.default.randomUUID(),
|
|
60
|
+
assetId: file.fileId,
|
|
61
|
+
projectId: file.projectId,
|
|
62
|
+
version: currentVersion + 1,
|
|
63
|
+
storageKey: file.storageKey,
|
|
64
|
+
contentType: upload.contentType,
|
|
65
|
+
fileName: upload.fileName,
|
|
66
|
+
createdBy: upload.userId,
|
|
67
|
+
createdAt: new Date(),
|
|
68
|
+
};
|
|
69
|
+
await col.insertOne({ _id: new mongodb_1.ObjectId(), ...record });
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
/** One of an asset's versions, or null. */
|
|
73
|
+
async function findVersion(projectId, assetId, versionId) {
|
|
74
|
+
return (await (0, exports.versionsCollection)()?.findOne({ projectId, assetId, versionId })) ?? null;
|
|
75
|
+
}
|
|
76
|
+
/** Every key an asset's versions are stored in (its current one included). */
|
|
77
|
+
async function versionStorageKeys(projectId, assetId, currentKey) {
|
|
78
|
+
const versions = ((await (0, exports.versionsCollection)()?.find({ projectId, assetId }).toArray()) ?? []);
|
|
79
|
+
return [...new Set([...(currentKey ? [currentKey] : []), ...versions.map(v => v.storageKey)])];
|
|
80
|
+
}
|
|
81
|
+
/** Forgets an asset's versions (once their files are deleted). */
|
|
82
|
+
async function deleteVersions(projectId, assetId) {
|
|
83
|
+
await (0, exports.versionsCollection)()?.deleteMany({ projectId, assetId });
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface Deliverable {
|
|
2
|
+
id: string;
|
|
3
|
+
projectId: string;
|
|
4
|
+
assetId: string;
|
|
5
|
+
versionId: string | null;
|
|
6
|
+
shotId: string | null;
|
|
7
|
+
exportId: string | null;
|
|
8
|
+
note: string | null;
|
|
9
|
+
createdBy: string;
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
}
|
|
12
|
+
export declare const deliverablesCollection: () => import("mongodb").Collection<import("bson").Document> | null;
|
|
13
|
+
export declare function createDeliverable(fields: Omit<Deliverable, 'id' | 'createdAt'>): Promise<Deliverable>;
|
|
14
|
+
/** A project's deliverables, of one shot or export if given, newest first. */
|
|
15
|
+
export declare function listDeliverables(projectId: string, of?: {
|
|
16
|
+
shotId?: string;
|
|
17
|
+
exportId?: string;
|
|
18
|
+
}): Promise<Deliverable[]>;
|
|
19
|
+
/** Removes a deliverable (never its asset). Whether there was one. */
|
|
20
|
+
export declare function deleteDeliverable(projectId: string, id: string): Promise<boolean>;
|
|
21
|
+
/** Forgets the deliverables of what's gone: an asset, shots, or an export. */
|
|
22
|
+
export declare function deleteDeliverablesOf(projectId: string, of: {
|
|
23
|
+
assetId?: string;
|
|
24
|
+
shotIds?: string[];
|
|
25
|
+
exportId?: string;
|
|
26
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.deliverablesCollection = void 0;
|
|
7
|
+
exports.createDeliverable = createDeliverable;
|
|
8
|
+
exports.listDeliverables = listDeliverables;
|
|
9
|
+
exports.deleteDeliverable = deleteDeliverable;
|
|
10
|
+
exports.deleteDeliverablesOf = deleteDeliverablesOf;
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const mongodb_1 = require("mongodb");
|
|
13
|
+
const nosqlDb_1 = require("../utils/nosqlDb");
|
|
14
|
+
const deliverablesCollection = () => (0, nosqlDb_1.nosqlDb)()?.collection('deliverables') ?? null;
|
|
15
|
+
exports.deliverablesCollection = deliverablesCollection;
|
|
16
|
+
const strip = ({ _id, ...deliverable }) => deliverable;
|
|
17
|
+
async function createDeliverable(fields) {
|
|
18
|
+
const col = (0, exports.deliverablesCollection)();
|
|
19
|
+
if (!col)
|
|
20
|
+
throw new Error('Database not available');
|
|
21
|
+
const deliverable = { id: crypto_1.default.randomUUID(), ...fields, createdAt: new Date() };
|
|
22
|
+
await col.insertOne({ _id: new mongodb_1.ObjectId(), ...deliverable });
|
|
23
|
+
return deliverable;
|
|
24
|
+
}
|
|
25
|
+
/** A project's deliverables, of one shot or export if given, newest first. */
|
|
26
|
+
async function listDeliverables(projectId, of = {}) {
|
|
27
|
+
const col = (0, exports.deliverablesCollection)();
|
|
28
|
+
if (!col)
|
|
29
|
+
throw new Error('Database not available');
|
|
30
|
+
const filter = { projectId };
|
|
31
|
+
if (of.shotId)
|
|
32
|
+
filter.shotId = of.shotId;
|
|
33
|
+
if (of.exportId)
|
|
34
|
+
filter.exportId = of.exportId;
|
|
35
|
+
return (await col.find(filter).sort({ createdAt: -1 }).toArray()).map(strip);
|
|
36
|
+
}
|
|
37
|
+
/** Removes a deliverable (never its asset). Whether there was one. */
|
|
38
|
+
async function deleteDeliverable(projectId, id) {
|
|
39
|
+
const col = (0, exports.deliverablesCollection)();
|
|
40
|
+
if (!col)
|
|
41
|
+
throw new Error('Database not available');
|
|
42
|
+
return (await col.deleteOne({ projectId, id })).deletedCount > 0;
|
|
43
|
+
}
|
|
44
|
+
/** Forgets the deliverables of what's gone: an asset, shots, or an export. */
|
|
45
|
+
async function deleteDeliverablesOf(projectId, of) {
|
|
46
|
+
const col = (0, exports.deliverablesCollection)();
|
|
47
|
+
if (!col)
|
|
48
|
+
return;
|
|
49
|
+
if (of.assetId)
|
|
50
|
+
await col.deleteMany({ projectId, assetId: of.assetId });
|
|
51
|
+
if (of.shotIds?.length)
|
|
52
|
+
await col.deleteMany({ projectId, shotId: { $in: of.shotIds } });
|
|
53
|
+
if (of.exportId)
|
|
54
|
+
await col.deleteMany({ projectId, exportId: of.exportId });
|
|
55
|
+
}
|
|
@@ -69,7 +69,9 @@ async function runIngestJob(jobId, fileId, projectId) {
|
|
|
69
69
|
}
|
|
70
70
|
(0, jobQueue_1.updateJob)(jobId, { stage: 'creating-version', progress: 70 });
|
|
71
71
|
const versionsCol = getVersionsCollection();
|
|
72
|
-
|
|
72
|
+
// Its upload already recorded the version it's in; only an asset from before versions
|
|
73
|
+
// were kept gets one here.
|
|
74
|
+
if (versionsCol && !(await versionsCol.findOne({ assetId: fileId, projectId, storageKey: fileDoc.storageKey }))) {
|
|
73
75
|
const existing = await versionsCol.countDocuments({ assetId: fileId, projectId });
|
|
74
76
|
await versionsCol.insertOne({
|
|
75
77
|
_id: new mongodb_1.ObjectId(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deleteProductionData = deleteProductionData;
|
|
4
|
+
const nosqlDb_1 = require("../utils/nosqlDb");
|
|
5
|
+
/** A deleted project's sequences, shots, tasks and deliverables: only that project's. */
|
|
6
|
+
async function deleteProductionData(projectId) {
|
|
7
|
+
const db = (0, nosqlDb_1.nosqlDb)();
|
|
8
|
+
if (!db || !projectId)
|
|
9
|
+
return;
|
|
10
|
+
for (const name of ['production_tasks', 'production_shots', 'production_sequences', 'deliverables']) {
|
|
11
|
+
await db.collection(name).deleteMany({ projectId });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const PRODUCTION_STATUSES: readonly ["todo", "in_progress", "review", "done"];
|
|
2
|
+
export declare const TASK_PRIORITIES: readonly ["low", "medium", "high"];
|
|
3
|
+
type Field = 'name' | 'description' | 'notes' | 'status' | 'priority' | 'assignees' | 'assignee' | 'dueDate' | 'order' | 'assetId' | 'exportId';
|
|
4
|
+
export type FieldsResult = {
|
|
5
|
+
values: Record<string, any>;
|
|
6
|
+
} | {
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The fields of `body` that `allowed` lists, checked and ready to save (`assignees` and
|
|
11
|
+
* `assignee` are saved as `assignedTo`). With `creating`, a name is required.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkProductionFields(body: any, allowed: readonly Field[], project: any, { creating }?: {
|
|
14
|
+
creating?: boolean | undefined;
|
|
15
|
+
}): FieldsResult;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TASK_PRIORITIES = exports.PRODUCTION_STATUSES = void 0;
|
|
4
|
+
exports.checkProductionFields = checkProductionFields;
|
|
5
|
+
const auth_1 = require("../utils/auth");
|
|
6
|
+
// What a production sequence, shot or task may be given, checked before it's saved: a name
|
|
7
|
+
// that isn't blank, a known status and priority, assignees who are members of the project, a
|
|
8
|
+
// due date that is a date.
|
|
9
|
+
exports.PRODUCTION_STATUSES = ['todo', 'in_progress', 'review', 'done'];
|
|
10
|
+
exports.TASK_PRIORITIES = ['low', 'medium', 'high'];
|
|
11
|
+
const MAX_NAME = 200;
|
|
12
|
+
const MAX_TEXT = 5000;
|
|
13
|
+
/**
|
|
14
|
+
* The fields of `body` that `allowed` lists, checked and ready to save (`assignees` and
|
|
15
|
+
* `assignee` are saved as `assignedTo`). With `creating`, a name is required.
|
|
16
|
+
*/
|
|
17
|
+
function checkProductionFields(body, allowed, project, { creating = false } = {}) {
|
|
18
|
+
const values = {};
|
|
19
|
+
const given = (field) => body?.[field] !== undefined;
|
|
20
|
+
const has = (field) => allowed.includes(field);
|
|
21
|
+
if (creating && !given('name'))
|
|
22
|
+
return { error: 'name is required' };
|
|
23
|
+
if (has('name') && given('name')) {
|
|
24
|
+
if (typeof body.name !== 'string' || !body.name.trim())
|
|
25
|
+
return { error: 'name must not be blank' };
|
|
26
|
+
if (body.name.trim().length > MAX_NAME)
|
|
27
|
+
return { error: `name must be at most ${MAX_NAME} characters` };
|
|
28
|
+
values.name = body.name.trim();
|
|
29
|
+
}
|
|
30
|
+
for (const field of ['description', 'notes']) {
|
|
31
|
+
if (!has(field) || !given(field))
|
|
32
|
+
continue;
|
|
33
|
+
const value = body[field];
|
|
34
|
+
if (value !== null && (typeof value !== 'string' || value.length > MAX_TEXT)) {
|
|
35
|
+
return { error: `${field} must be text of at most ${MAX_TEXT} characters, or null` };
|
|
36
|
+
}
|
|
37
|
+
values[field] = value === null || !value.trim() ? null : value;
|
|
38
|
+
}
|
|
39
|
+
if (has('status') && given('status')) {
|
|
40
|
+
if (!exports.PRODUCTION_STATUSES.includes(body.status))
|
|
41
|
+
return { error: `status must be one of ${exports.PRODUCTION_STATUSES.join(', ')}` };
|
|
42
|
+
values.status = body.status;
|
|
43
|
+
}
|
|
44
|
+
if (has('priority') && given('priority')) {
|
|
45
|
+
if (!exports.TASK_PRIORITIES.includes(body.priority))
|
|
46
|
+
return { error: `priority must be one of ${exports.TASK_PRIORITIES.join(', ')}` };
|
|
47
|
+
values.priority = body.priority;
|
|
48
|
+
}
|
|
49
|
+
const isMember = (id) => typeof id === 'string' && !!(0, auth_1.activeMemberOf)(project, id);
|
|
50
|
+
if (has('assignees') && given('assignedTo')) {
|
|
51
|
+
const ids = body.assignedTo;
|
|
52
|
+
if (!Array.isArray(ids) || !ids.every(isMember))
|
|
53
|
+
return { error: 'assignedTo must be a list of members of the project' };
|
|
54
|
+
values.assignedTo = [...new Set(ids)];
|
|
55
|
+
}
|
|
56
|
+
if (has('assignee') && given('assignedTo')) {
|
|
57
|
+
if (body.assignedTo !== null && !isMember(body.assignedTo))
|
|
58
|
+
return { error: 'assignedTo must be a member of the project, or null' };
|
|
59
|
+
values.assignedTo = body.assignedTo;
|
|
60
|
+
}
|
|
61
|
+
if (has('dueDate') && given('dueDate')) {
|
|
62
|
+
const date = body.dueDate === null || body.dueDate === '' ? null : new Date(body.dueDate);
|
|
63
|
+
if (date && Number.isNaN(date.getTime()))
|
|
64
|
+
return { error: 'dueDate must be a date, or null' };
|
|
65
|
+
values.dueDate = date;
|
|
66
|
+
}
|
|
67
|
+
if (has('order') && given('order')) {
|
|
68
|
+
if (typeof body.order !== 'number' || !Number.isFinite(body.order))
|
|
69
|
+
return { error: 'order must be a number' };
|
|
70
|
+
values.order = body.order;
|
|
71
|
+
}
|
|
72
|
+
for (const field of ['assetId', 'exportId']) {
|
|
73
|
+
if (!has(field) || !given(field))
|
|
74
|
+
continue;
|
|
75
|
+
if (body[field] !== null && typeof body[field] !== 'string')
|
|
76
|
+
return { error: `${field} must be an id, or null` };
|
|
77
|
+
values[field] = body[field] || null;
|
|
78
|
+
}
|
|
79
|
+
return { values };
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Auth, StreamByConfig } from '../types';
|
|
2
|
+
export declare const DEFAULT_TOKEN_SIGNED_URL_TTL: {
|
|
3
|
+
upload: number;
|
|
4
|
+
download: number;
|
|
5
|
+
};
|
|
6
|
+
export declare const signedUrlTtl: (auth: Auth | undefined, config: StreamByConfig, kind: "upload" | "download") => number | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signedUrlTtl = exports.DEFAULT_TOKEN_SIGNED_URL_TTL = void 0;
|
|
4
|
+
// How long a signed storage URL works. A request made with an API token (an agent, the MCP
|
|
5
|
+
// server) gets short-lived ones; a browser session, the adapter's own (an hour to upload, a
|
|
6
|
+
// day to read).
|
|
7
|
+
exports.DEFAULT_TOKEN_SIGNED_URL_TTL = { upload: 15 * 60, download: 5 * 60 };
|
|
8
|
+
const signedUrlTtl = (auth, config, kind) => auth?.apiToken ? config.mcp?.signedUrlTtl?.[kind] ?? exports.DEFAULT_TOKEN_SIGNED_URL_TTL[kind] : undefined;
|
|
9
|
+
exports.signedUrlTtl = signedUrlTtl;
|