@opengeni/storage 0.2.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/dist/index.d.ts +54 -0
- package/dist/index.js +330 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/index.ts +386 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Settings } from '@opengeni/config';
|
|
2
|
+
import { FileAsset } from '@opengeni/contracts';
|
|
3
|
+
|
|
4
|
+
declare const MAX_SINGLE_PUT_SIZE_BYTES = 5000000000;
|
|
5
|
+
declare const UPLOAD_URL_TTL_SECONDS: number;
|
|
6
|
+
declare const DOWNLOAD_URL_TTL_SECONDS: number;
|
|
7
|
+
type ObjectHead = {
|
|
8
|
+
ContentLength?: number;
|
|
9
|
+
ContentType?: string;
|
|
10
|
+
Metadata?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
type ObjectStorage = {
|
|
13
|
+
bucket: string;
|
|
14
|
+
backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
|
|
15
|
+
maxSinglePutSizeBytes: number;
|
|
16
|
+
createPutUrl: (args: {
|
|
17
|
+
key: string;
|
|
18
|
+
contentType: string;
|
|
19
|
+
sha256?: string | null;
|
|
20
|
+
expiresInSeconds?: number;
|
|
21
|
+
}) => Promise<{
|
|
22
|
+
url: string;
|
|
23
|
+
requiredHeaders: Record<string, string>;
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
}>;
|
|
26
|
+
createGetUrl: (args: {
|
|
27
|
+
key: string;
|
|
28
|
+
expiresInSeconds?: number;
|
|
29
|
+
}) => Promise<{
|
|
30
|
+
url: string;
|
|
31
|
+
expiresAt: Date;
|
|
32
|
+
}>;
|
|
33
|
+
headFile: (file: FileAsset) => Promise<ObjectHead>;
|
|
34
|
+
getFileBytes: (file: FileAsset) => Promise<Uint8Array>;
|
|
35
|
+
/**
|
|
36
|
+
* SERVER-SIDE authenticated direct PUT (no presign + browser fetch). For an
|
|
37
|
+
* in-process upload from a trusted holder of the storage credentials (e.g. the
|
|
38
|
+
* worker writing a recording), this sends the bytes straight to the storage
|
|
39
|
+
* backend over the configured endpoint with the in-process SDK client — bypassing
|
|
40
|
+
* the presigned-URL round-trip, which on split public/internal topologies (a
|
|
41
|
+
* public `objectStorageEndpoint` with no in-cluster route) would otherwise 401.
|
|
42
|
+
* Browser uploads keep using `createPutUrl`; this is the trusted-server twin.
|
|
43
|
+
*/
|
|
44
|
+
putObject: (args: {
|
|
45
|
+
key: string;
|
|
46
|
+
contentType: string;
|
|
47
|
+
body: Uint8Array;
|
|
48
|
+
sha256?: string | null;
|
|
49
|
+
}) => Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
declare function createObjectStorage(settings: Settings): ObjectStorage | null;
|
|
52
|
+
declare function bytesToDataUrl(bytes: Uint8Array, contentType: string): string;
|
|
53
|
+
|
|
54
|
+
export { DOWNLOAD_URL_TTL_SECONDS, MAX_SINGLE_PUT_SIZE_BYTES, type ObjectHead, type ObjectStorage, UPLOAD_URL_TTL_SECONDS, bytesToDataUrl, createObjectStorage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
BlobSASPermissions,
|
|
4
|
+
BlobServiceClient,
|
|
5
|
+
generateBlobSASQueryParameters,
|
|
6
|
+
StorageSharedKeyCredential
|
|
7
|
+
} from "@azure/storage-blob";
|
|
8
|
+
import {
|
|
9
|
+
GetObjectCommand,
|
|
10
|
+
HeadObjectCommand,
|
|
11
|
+
PutObjectCommand,
|
|
12
|
+
S3Client
|
|
13
|
+
} from "@aws-sdk/client-s3";
|
|
14
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
15
|
+
import { Storage as GcsClient } from "@google-cloud/storage";
|
|
16
|
+
var MAX_SINGLE_PUT_SIZE_BYTES = 5e9;
|
|
17
|
+
var UPLOAD_URL_TTL_SECONDS = 15 * 60;
|
|
18
|
+
var DOWNLOAD_URL_TTL_SECONDS = 5 * 60;
|
|
19
|
+
function createObjectStorage(settings) {
|
|
20
|
+
if (settings.objectStorageBackend === "azure-blob") {
|
|
21
|
+
return createAzureBlobObjectStorage(settings);
|
|
22
|
+
}
|
|
23
|
+
if (settings.objectStorageBackend === "gcs") {
|
|
24
|
+
return createGcsObjectStorage(settings);
|
|
25
|
+
}
|
|
26
|
+
return createS3CompatibleObjectStorage(settings);
|
|
27
|
+
}
|
|
28
|
+
function createS3CompatibleObjectStorage(settings) {
|
|
29
|
+
if (settings.objectStorageBackend === "s3-compatible" && (!settings.objectStorageEndpoint || !settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const clientConfig = {
|
|
33
|
+
region: settings.objectStorageRegion,
|
|
34
|
+
forcePathStyle: settings.objectStorageForcePathStyle,
|
|
35
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
36
|
+
...settings.objectStorageEndpoint ? { endpoint: settings.objectStorageEndpoint } : {},
|
|
37
|
+
...settings.objectStorageAccessKeyId && settings.objectStorageSecretAccessKey ? { credentials: {
|
|
38
|
+
accessKeyId: settings.objectStorageAccessKeyId,
|
|
39
|
+
secretAccessKey: settings.objectStorageSecretAccessKey
|
|
40
|
+
} } : {}
|
|
41
|
+
};
|
|
42
|
+
const client = new S3Client(clientConfig);
|
|
43
|
+
return {
|
|
44
|
+
bucket: settings.objectStorageBucket,
|
|
45
|
+
backend: settings.objectStorageBackend === "aws-s3" ? "aws-s3" : "s3-compatible",
|
|
46
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
47
|
+
async createPutUrl(args) {
|
|
48
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
49
|
+
const requiredHeaders = {
|
|
50
|
+
"content-type": args.contentType
|
|
51
|
+
};
|
|
52
|
+
const command = new PutObjectCommand({
|
|
53
|
+
Bucket: settings.objectStorageBucket,
|
|
54
|
+
Key: args.key,
|
|
55
|
+
ContentType: args.contentType,
|
|
56
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : void 0
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
url: await getSignedUrl(client, command, { expiresIn }),
|
|
60
|
+
requiredHeaders,
|
|
61
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3)
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
async createGetUrl(args) {
|
|
65
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
66
|
+
return {
|
|
67
|
+
url: await getSignedUrl(client, new GetObjectCommand({
|
|
68
|
+
Bucket: settings.objectStorageBucket,
|
|
69
|
+
Key: args.key
|
|
70
|
+
}), { expiresIn }),
|
|
71
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3)
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
async putObject(args) {
|
|
75
|
+
await client.send(new PutObjectCommand({
|
|
76
|
+
Bucket: settings.objectStorageBucket,
|
|
77
|
+
Key: args.key,
|
|
78
|
+
ContentType: args.contentType,
|
|
79
|
+
Body: args.body,
|
|
80
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : void 0
|
|
81
|
+
}));
|
|
82
|
+
},
|
|
83
|
+
async headFile(file) {
|
|
84
|
+
const head = await client.send(new HeadObjectCommand({
|
|
85
|
+
Bucket: file.bucket,
|
|
86
|
+
Key: file.objectKey
|
|
87
|
+
}));
|
|
88
|
+
return objectHead({
|
|
89
|
+
contentLength: head.ContentLength,
|
|
90
|
+
contentType: head.ContentType,
|
|
91
|
+
metadata: head.Metadata
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
async getFileBytes(file) {
|
|
95
|
+
const result = await client.send(new GetObjectCommand({
|
|
96
|
+
Bucket: file.bucket,
|
|
97
|
+
Key: file.objectKey
|
|
98
|
+
}));
|
|
99
|
+
if (!result.Body) {
|
|
100
|
+
throw new Error(`Object body is empty: ${file.objectKey}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof result.Body.transformToByteArray === "function") {
|
|
103
|
+
return await result.Body.transformToByteArray();
|
|
104
|
+
}
|
|
105
|
+
const chunks = [];
|
|
106
|
+
for await (const chunk of result.Body) {
|
|
107
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
108
|
+
}
|
|
109
|
+
return Buffer.concat(chunks);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function createGcsObjectStorage(settings) {
|
|
114
|
+
const client = new GcsClient(gcsClientOptions(settings));
|
|
115
|
+
const bucket = client.bucket(settings.objectStorageBucket);
|
|
116
|
+
return {
|
|
117
|
+
bucket: settings.objectStorageBucket,
|
|
118
|
+
backend: "gcs",
|
|
119
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
120
|
+
async createPutUrl(args) {
|
|
121
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
122
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
123
|
+
const config = {
|
|
124
|
+
version: "v4",
|
|
125
|
+
action: "write",
|
|
126
|
+
expires: expiresAt,
|
|
127
|
+
contentType: args.contentType
|
|
128
|
+
};
|
|
129
|
+
if (args.sha256) {
|
|
130
|
+
config.extensionHeaders = { "x-goog-meta-sha256": args.sha256 };
|
|
131
|
+
}
|
|
132
|
+
const [url] = await bucket.file(args.key).getSignedUrl(config);
|
|
133
|
+
return {
|
|
134
|
+
url,
|
|
135
|
+
requiredHeaders: {
|
|
136
|
+
"content-type": args.contentType,
|
|
137
|
+
...args.sha256 ? { "x-goog-meta-sha256": args.sha256 } : {}
|
|
138
|
+
},
|
|
139
|
+
expiresAt
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
async createGetUrl(args) {
|
|
143
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
144
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
145
|
+
const [url] = await bucket.file(args.key).getSignedUrl({
|
|
146
|
+
version: "v4",
|
|
147
|
+
action: "read",
|
|
148
|
+
expires: expiresAt
|
|
149
|
+
});
|
|
150
|
+
return { url, expiresAt };
|
|
151
|
+
},
|
|
152
|
+
async putObject(args) {
|
|
153
|
+
await bucket.file(args.key).save(Buffer.from(args.body), {
|
|
154
|
+
contentType: args.contentType,
|
|
155
|
+
...args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
async headFile(file) {
|
|
159
|
+
const [metadata] = await bucket.file(file.objectKey).getMetadata();
|
|
160
|
+
return objectHead({
|
|
161
|
+
contentLength: parseContentLength(metadata.size),
|
|
162
|
+
contentType: metadata.contentType,
|
|
163
|
+
metadata: stringMetadata(metadata.metadata)
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
async getFileBytes(file) {
|
|
167
|
+
const [bytes] = await bucket.file(file.objectKey).download();
|
|
168
|
+
return bytes;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function createAzureBlobObjectStorage(settings) {
|
|
173
|
+
const sharedKey = azureSharedKeyCredential(settings);
|
|
174
|
+
const serviceClient = settings.objectStorageAzureConnectionString ? BlobServiceClient.fromConnectionString(settings.objectStorageAzureConnectionString) : new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey);
|
|
175
|
+
const containerClient = serviceClient.getContainerClient(settings.objectStorageBucket);
|
|
176
|
+
return {
|
|
177
|
+
bucket: settings.objectStorageBucket,
|
|
178
|
+
backend: "azure-blob",
|
|
179
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
180
|
+
async createPutUrl(args) {
|
|
181
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
182
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
183
|
+
const blobClient = containerClient.getBlockBlobClient(args.key);
|
|
184
|
+
const sas = generateBlobSASQueryParameters({
|
|
185
|
+
containerName: settings.objectStorageBucket,
|
|
186
|
+
blobName: args.key,
|
|
187
|
+
permissions: BlobSASPermissions.parse("cw"),
|
|
188
|
+
expiresOn: expiresAt,
|
|
189
|
+
contentType: args.contentType
|
|
190
|
+
}, sharedKey).toString();
|
|
191
|
+
return {
|
|
192
|
+
url: `${blobClient.url}?${sas}`,
|
|
193
|
+
requiredHeaders: {
|
|
194
|
+
"content-type": args.contentType,
|
|
195
|
+
"x-ms-blob-type": "BlockBlob",
|
|
196
|
+
...args.sha256 ? { "x-ms-meta-sha256": args.sha256 } : {}
|
|
197
|
+
},
|
|
198
|
+
expiresAt
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
async createGetUrl(args) {
|
|
202
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
203
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
204
|
+
const blobClient = containerClient.getBlobClient(args.key);
|
|
205
|
+
const sas = generateBlobSASQueryParameters({
|
|
206
|
+
containerName: settings.objectStorageBucket,
|
|
207
|
+
blobName: args.key,
|
|
208
|
+
permissions: BlobSASPermissions.parse("r"),
|
|
209
|
+
expiresOn: expiresAt
|
|
210
|
+
}, sharedKey).toString();
|
|
211
|
+
return {
|
|
212
|
+
url: `${blobClient.url}?${sas}`,
|
|
213
|
+
expiresAt
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
async putObject(args) {
|
|
217
|
+
const blobClient = containerClient.getBlockBlobClient(args.key);
|
|
218
|
+
const body = Buffer.from(args.body);
|
|
219
|
+
await blobClient.upload(body, body.byteLength, {
|
|
220
|
+
blobHTTPHeaders: { blobContentType: args.contentType },
|
|
221
|
+
...args.sha256 ? { metadata: { sha256: args.sha256 } } : {}
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
async headFile(file) {
|
|
225
|
+
return azureHeadToObjectHead(await containerClient.getBlobClient(file.objectKey).getProperties());
|
|
226
|
+
},
|
|
227
|
+
async getFileBytes(file) {
|
|
228
|
+
return await azureDownloadToBytes(await containerClient.getBlobClient(file.objectKey).download());
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function azureSharedKeyCredential(settings) {
|
|
233
|
+
if (settings.objectStorageAzureConnectionString) {
|
|
234
|
+
const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
|
|
235
|
+
if (parsed.AccountName && parsed.AccountKey) {
|
|
236
|
+
return new StorageSharedKeyCredential(parsed.AccountName, parsed.AccountKey);
|
|
237
|
+
}
|
|
238
|
+
throw new Error("Azure Blob connection string must include AccountName and AccountKey to create presigned URLs");
|
|
239
|
+
}
|
|
240
|
+
if (!settings.objectStorageAzureAccountName || !settings.objectStorageAzureAccountKey) {
|
|
241
|
+
throw new Error("Azure Blob storage requires account name and account key");
|
|
242
|
+
}
|
|
243
|
+
return new StorageSharedKeyCredential(settings.objectStorageAzureAccountName, settings.objectStorageAzureAccountKey);
|
|
244
|
+
}
|
|
245
|
+
function azureBlobServiceUrl(settings) {
|
|
246
|
+
if (settings.objectStorageAzureEndpoint) {
|
|
247
|
+
return settings.objectStorageAzureEndpoint.replace(/\/+$/, "");
|
|
248
|
+
}
|
|
249
|
+
if (!settings.objectStorageAzureAccountName) {
|
|
250
|
+
throw new Error("Azure Blob storage requires account name");
|
|
251
|
+
}
|
|
252
|
+
return `https://${settings.objectStorageAzureAccountName}.blob.core.windows.net`;
|
|
253
|
+
}
|
|
254
|
+
function parseConnectionString(value) {
|
|
255
|
+
return Object.fromEntries(value.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
256
|
+
const index = part.indexOf("=");
|
|
257
|
+
return index === -1 ? [part, ""] : [part.slice(0, index), part.slice(index + 1)];
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
function gcsClientOptions(settings) {
|
|
261
|
+
const options = {
|
|
262
|
+
...settings.objectStorageGcsProjectId ? { projectId: settings.objectStorageGcsProjectId } : {},
|
|
263
|
+
...settings.objectStorageGcsKeyFilename ? { keyFilename: settings.objectStorageGcsKeyFilename } : {},
|
|
264
|
+
...settings.objectStorageGcsApiEndpoint ? { apiEndpoint: settings.objectStorageGcsApiEndpoint } : {}
|
|
265
|
+
};
|
|
266
|
+
if (settings.objectStorageGcsCredentialsJson) {
|
|
267
|
+
options.credentials = parseGcsCredentials(settings.objectStorageGcsCredentialsJson);
|
|
268
|
+
}
|
|
269
|
+
return options;
|
|
270
|
+
}
|
|
271
|
+
function parseGcsCredentials(raw) {
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
274
|
+
throw new Error("GCS credentials JSON must be an object");
|
|
275
|
+
}
|
|
276
|
+
return parsed;
|
|
277
|
+
}
|
|
278
|
+
function parseContentLength(value) {
|
|
279
|
+
if (typeof value === "number") {
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
283
|
+
return void 0;
|
|
284
|
+
}
|
|
285
|
+
const parsed = Number(value);
|
|
286
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
287
|
+
}
|
|
288
|
+
function stringMetadata(value) {
|
|
289
|
+
if (!value) {
|
|
290
|
+
return void 0;
|
|
291
|
+
}
|
|
292
|
+
return Object.fromEntries(
|
|
293
|
+
Object.entries(value).filter((entry) => typeof entry[1] === "string")
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
function azureHeadToObjectHead(head) {
|
|
297
|
+
return objectHead({
|
|
298
|
+
contentLength: head.contentLength,
|
|
299
|
+
contentType: head.contentType,
|
|
300
|
+
metadata: head.metadata
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async function azureDownloadToBytes(download) {
|
|
304
|
+
if (!download.readableStreamBody) {
|
|
305
|
+
throw new Error("Azure Blob download response did not include a readable body");
|
|
306
|
+
}
|
|
307
|
+
const chunks = [];
|
|
308
|
+
for await (const chunk of download.readableStreamBody) {
|
|
309
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
310
|
+
}
|
|
311
|
+
return Buffer.concat(chunks);
|
|
312
|
+
}
|
|
313
|
+
function objectHead(input) {
|
|
314
|
+
return {
|
|
315
|
+
...input.contentLength !== void 0 ? { ContentLength: input.contentLength } : {},
|
|
316
|
+
...input.contentType !== void 0 ? { ContentType: input.contentType } : {},
|
|
317
|
+
...input.metadata !== void 0 ? { Metadata: input.metadata } : {}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function bytesToDataUrl(bytes, contentType) {
|
|
321
|
+
return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
322
|
+
}
|
|
323
|
+
export {
|
|
324
|
+
DOWNLOAD_URL_TTL_SECONDS,
|
|
325
|
+
MAX_SINGLE_PUT_SIZE_BYTES,
|
|
326
|
+
UPLOAD_URL_TTL_SECONDS,
|
|
327
|
+
bytesToDataUrl,
|
|
328
|
+
createObjectStorage
|
|
329
|
+
};
|
|
330
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Settings } from \"@opengeni/config\";\nimport type { FileAsset } from \"@opengeni/contracts\";\nimport {\n BlobSASPermissions,\n BlobServiceClient,\n BlockBlobClient,\n generateBlobSASQueryParameters,\n StorageSharedKeyCredential,\n type BlobDownloadResponseParsed,\n type BlobGetPropertiesResponse,\n} from \"@azure/storage-blob\";\nimport {\n GetObjectCommand,\n HeadObjectCommand,\n PutObjectCommand,\n S3Client,\n type S3ClientConfig,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { Storage as GcsClient, type GetSignedUrlConfig, type StorageOptions } from \"@google-cloud/storage\";\n\nexport const MAX_SINGLE_PUT_SIZE_BYTES = 5_000_000_000;\nexport const UPLOAD_URL_TTL_SECONDS = 15 * 60;\nexport const DOWNLOAD_URL_TTL_SECONDS = 5 * 60;\n\nexport type ObjectHead = {\n ContentLength?: number;\n ContentType?: string;\n Metadata?: Record<string, string>;\n};\n\nexport type ObjectStorage = {\n bucket: string;\n backend: \"s3-compatible\" | \"aws-s3\" | \"azure-blob\" | \"gcs\";\n maxSinglePutSizeBytes: number;\n createPutUrl: (args: { key: string; contentType: string; sha256?: string | null; expiresInSeconds?: number }) => Promise<{ url: string; requiredHeaders: Record<string, string>; expiresAt: Date }>;\n createGetUrl: (args: { key: string; expiresInSeconds?: number }) => Promise<{ url: string; expiresAt: Date }>;\n headFile: (file: FileAsset) => Promise<ObjectHead>;\n getFileBytes: (file: FileAsset) => Promise<Uint8Array>;\n /**\n * SERVER-SIDE authenticated direct PUT (no presign + browser fetch). For an\n * in-process upload from a trusted holder of the storage credentials (e.g. the\n * worker writing a recording), this sends the bytes straight to the storage\n * backend over the configured endpoint with the in-process SDK client — bypassing\n * the presigned-URL round-trip, which on split public/internal topologies (a\n * public `objectStorageEndpoint` with no in-cluster route) would otherwise 401.\n * Browser uploads keep using `createPutUrl`; this is the trusted-server twin.\n */\n putObject: (args: { key: string; contentType: string; body: Uint8Array; sha256?: string | null }) => Promise<void>;\n};\n\nexport function createObjectStorage(settings: Settings): ObjectStorage | null {\n if (settings.objectStorageBackend === \"azure-blob\") {\n return createAzureBlobObjectStorage(settings);\n }\n if (settings.objectStorageBackend === \"gcs\") {\n return createGcsObjectStorage(settings);\n }\n return createS3CompatibleObjectStorage(settings);\n}\n\nfunction createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | null {\n if (settings.objectStorageBackend === \"s3-compatible\" && (!settings.objectStorageEndpoint || !settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)) {\n return null;\n }\n const clientConfig: S3ClientConfig = {\n region: settings.objectStorageRegion,\n forcePathStyle: settings.objectStorageForcePathStyle,\n requestChecksumCalculation: \"WHEN_REQUIRED\",\n ...(settings.objectStorageEndpoint ? { endpoint: settings.objectStorageEndpoint } : {}),\n ...(settings.objectStorageAccessKeyId && settings.objectStorageSecretAccessKey ? { credentials: {\n accessKeyId: settings.objectStorageAccessKeyId,\n secretAccessKey: settings.objectStorageSecretAccessKey,\n } } : {}),\n };\n const client = new S3Client(clientConfig);\n return {\n bucket: settings.objectStorageBucket,\n backend: settings.objectStorageBackend === \"aws-s3\" ? \"aws-s3\" : \"s3-compatible\",\n maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,\n async createPutUrl(args) {\n const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;\n const requiredHeaders: Record<string, string> = {\n \"content-type\": args.contentType,\n };\n const command = new PutObjectCommand({\n Bucket: settings.objectStorageBucket,\n Key: args.key,\n ContentType: args.contentType,\n Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,\n });\n return {\n url: await getSignedUrl(client, command, { expiresIn }),\n requiredHeaders,\n expiresAt: new Date(Date.now() + expiresIn * 1000),\n };\n },\n async createGetUrl(args) {\n const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;\n return {\n url: await getSignedUrl(client, new GetObjectCommand({\n Bucket: settings.objectStorageBucket,\n Key: args.key,\n }), { expiresIn }),\n expiresAt: new Date(Date.now() + expiresIn * 1000),\n };\n },\n async putObject(args) {\n // Authenticated in-process PUT against the configured (in-cluster) endpoint.\n // A presigned URL buys nothing here — the worker already holds the creds — and\n // on a split public/internal endpoint topology the presigned URL points at the\n // PUBLIC host (no MinIO route → 401). This sends bytes straight to the backend.\n await client.send(new PutObjectCommand({\n Bucket: settings.objectStorageBucket,\n Key: args.key,\n ContentType: args.contentType,\n Body: args.body,\n Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,\n }));\n },\n async headFile(file) {\n const head = await client.send(new HeadObjectCommand({\n Bucket: file.bucket,\n Key: file.objectKey,\n }));\n return objectHead({\n contentLength: head.ContentLength,\n contentType: head.ContentType,\n metadata: head.Metadata,\n });\n },\n async getFileBytes(file) {\n const result = await client.send(new GetObjectCommand({\n Bucket: file.bucket,\n Key: file.objectKey,\n }));\n if (!result.Body) {\n throw new Error(`Object body is empty: ${file.objectKey}`);\n }\n if (typeof result.Body.transformToByteArray === \"function\") {\n return await result.Body.transformToByteArray();\n }\n const chunks: Uint8Array[] = [];\n for await (const chunk of result.Body as AsyncIterable<Uint8Array | Buffer | string>) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n return Buffer.concat(chunks);\n },\n };\n}\n\nfunction createGcsObjectStorage(settings: Settings): ObjectStorage {\n const client = new GcsClient(gcsClientOptions(settings));\n const bucket = client.bucket(settings.objectStorageBucket);\n return {\n bucket: settings.objectStorageBucket,\n backend: \"gcs\",\n maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,\n async createPutUrl(args) {\n const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;\n const expiresAt = new Date(Date.now() + expiresIn * 1000);\n const config: GetSignedUrlConfig = {\n version: \"v4\",\n action: \"write\",\n expires: expiresAt,\n contentType: args.contentType,\n };\n if (args.sha256) {\n config.extensionHeaders = { \"x-goog-meta-sha256\": args.sha256 };\n }\n const [url] = await bucket.file(args.key).getSignedUrl(config);\n return {\n url,\n requiredHeaders: {\n \"content-type\": args.contentType,\n ...(args.sha256 ? { \"x-goog-meta-sha256\": args.sha256 } : {}),\n },\n expiresAt,\n };\n },\n async createGetUrl(args) {\n const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;\n const expiresAt = new Date(Date.now() + expiresIn * 1000);\n const [url] = await bucket.file(args.key).getSignedUrl({\n version: \"v4\",\n action: \"read\",\n expires: expiresAt,\n });\n return { url, expiresAt };\n },\n async putObject(args) {\n // Authenticated in-process PUT via the GCS SDK (the server holds the creds).\n await bucket.file(args.key).save(Buffer.from(args.body), {\n contentType: args.contentType,\n ...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),\n });\n },\n async headFile(file) {\n const [metadata] = await bucket.file(file.objectKey).getMetadata();\n return objectHead({\n contentLength: parseContentLength(metadata.size),\n contentType: metadata.contentType,\n metadata: stringMetadata(metadata.metadata),\n });\n },\n async getFileBytes(file) {\n const [bytes] = await bucket.file(file.objectKey).download();\n return bytes;\n },\n };\n}\n\nfunction createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null {\n const sharedKey = azureSharedKeyCredential(settings);\n const serviceClient = settings.objectStorageAzureConnectionString\n ? BlobServiceClient.fromConnectionString(settings.objectStorageAzureConnectionString)\n : new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey);\n const containerClient = serviceClient.getContainerClient(settings.objectStorageBucket);\n\n return {\n bucket: settings.objectStorageBucket,\n backend: \"azure-blob\",\n maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,\n async createPutUrl(args) {\n const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;\n const expiresAt = new Date(Date.now() + expiresIn * 1000);\n const blobClient = containerClient.getBlockBlobClient(args.key);\n const sas = generateBlobSASQueryParameters({\n containerName: settings.objectStorageBucket,\n blobName: args.key,\n permissions: BlobSASPermissions.parse(\"cw\"),\n expiresOn: expiresAt,\n contentType: args.contentType,\n }, sharedKey).toString();\n return {\n url: `${blobClient.url}?${sas}`,\n requiredHeaders: {\n \"content-type\": args.contentType,\n \"x-ms-blob-type\": \"BlockBlob\",\n ...(args.sha256 ? { \"x-ms-meta-sha256\": args.sha256 } : {}),\n },\n expiresAt,\n };\n },\n async createGetUrl(args) {\n const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;\n const expiresAt = new Date(Date.now() + expiresIn * 1000);\n const blobClient = containerClient.getBlobClient(args.key);\n const sas = generateBlobSASQueryParameters({\n containerName: settings.objectStorageBucket,\n blobName: args.key,\n permissions: BlobSASPermissions.parse(\"r\"),\n expiresOn: expiresAt,\n }, sharedKey).toString();\n return {\n url: `${blobClient.url}?${sas}`,\n expiresAt,\n };\n },\n async putObject(args) {\n // Authenticated in-process upload via the shared-key Azure client (no SAS).\n const blobClient = containerClient.getBlockBlobClient(args.key);\n const body = Buffer.from(args.body);\n await blobClient.upload(body, body.byteLength, {\n blobHTTPHeaders: { blobContentType: args.contentType },\n ...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),\n });\n },\n async headFile(file) {\n return azureHeadToObjectHead(await containerClient.getBlobClient(file.objectKey).getProperties());\n },\n async getFileBytes(file) {\n return await azureDownloadToBytes(await containerClient.getBlobClient(file.objectKey).download());\n },\n };\n}\n\nfunction azureSharedKeyCredential(settings: Settings): StorageSharedKeyCredential {\n if (settings.objectStorageAzureConnectionString) {\n const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);\n if (parsed.AccountName && parsed.AccountKey) {\n return new StorageSharedKeyCredential(parsed.AccountName, parsed.AccountKey);\n }\n throw new Error(\"Azure Blob connection string must include AccountName and AccountKey to create presigned URLs\");\n }\n if (!settings.objectStorageAzureAccountName || !settings.objectStorageAzureAccountKey) {\n throw new Error(\"Azure Blob storage requires account name and account key\");\n }\n return new StorageSharedKeyCredential(settings.objectStorageAzureAccountName, settings.objectStorageAzureAccountKey);\n}\n\nfunction azureBlobServiceUrl(settings: Settings): string {\n if (settings.objectStorageAzureEndpoint) {\n return settings.objectStorageAzureEndpoint.replace(/\\/+$/, \"\");\n }\n if (!settings.objectStorageAzureAccountName) {\n throw new Error(\"Azure Blob storage requires account name\");\n }\n return `https://${settings.objectStorageAzureAccountName}.blob.core.windows.net`;\n}\n\nfunction parseConnectionString(value: string): Record<string, string> {\n return Object.fromEntries(value.split(\";\")\n .map((part) => part.trim())\n .filter(Boolean)\n .map((part) => {\n const index = part.indexOf(\"=\");\n return index === -1 ? [part, \"\"] : [part.slice(0, index), part.slice(index + 1)];\n }));\n}\n\nfunction gcsClientOptions(settings: Settings): StorageOptions {\n const options: StorageOptions = {\n ...(settings.objectStorageGcsProjectId ? { projectId: settings.objectStorageGcsProjectId } : {}),\n ...(settings.objectStorageGcsKeyFilename ? { keyFilename: settings.objectStorageGcsKeyFilename } : {}),\n ...(settings.objectStorageGcsApiEndpoint ? { apiEndpoint: settings.objectStorageGcsApiEndpoint } : {}),\n };\n if (settings.objectStorageGcsCredentialsJson) {\n options.credentials = parseGcsCredentials(settings.objectStorageGcsCredentialsJson);\n }\n return options;\n}\n\nfunction parseGcsCredentials(raw: string): Record<string, string> {\n const parsed = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(\"GCS credentials JSON must be an object\");\n }\n return parsed as Record<string, string>;\n}\n\nfunction parseContentLength(value: string | number | undefined): number | undefined {\n if (typeof value === \"number\") {\n return value;\n }\n if (typeof value !== \"string\" || value.length === 0) {\n return undefined;\n }\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : undefined;\n}\n\nfunction stringMetadata(value: Record<string, string | number | boolean | null> | undefined): Record<string, string> | undefined {\n if (!value) {\n return undefined;\n }\n return Object.fromEntries(\n Object.entries(value)\n .filter((entry): entry is [string, string] => typeof entry[1] === \"string\"),\n );\n}\n\nfunction azureHeadToObjectHead(head: BlobGetPropertiesResponse): ObjectHead {\n return objectHead({\n contentLength: head.contentLength,\n contentType: head.contentType,\n metadata: head.metadata,\n });\n}\n\nasync function azureDownloadToBytes(download: BlobDownloadResponseParsed): Promise<Uint8Array> {\n if (!download.readableStreamBody) {\n throw new Error(\"Azure Blob download response did not include a readable body\");\n }\n const chunks: Uint8Array[] = [];\n for await (const chunk of download.readableStreamBody as AsyncIterable<Uint8Array | Buffer | string>) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n return Buffer.concat(chunks);\n}\n\nfunction objectHead(input: {\n contentLength?: number | undefined;\n contentType?: string | undefined;\n metadata?: Record<string, string> | undefined;\n}): ObjectHead {\n return {\n ...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),\n ...(input.contentType !== undefined ? { ContentType: input.contentType } : {}),\n ...(input.metadata !== undefined ? { Metadata: input.metadata } : {}),\n };\n}\n\nexport function bytesToDataUrl(bytes: Uint8Array, contentType: string): string {\n return `data:${contentType};base64,${Buffer.from(bytes).toString(\"base64\")}`;\n}\n"],"mappings":";AAEA;AAAA,EACE;AAAA,EACA;AAAA,EAEA;AAAA,EACA;AAAA,OAGK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,WAAW,iBAA+D;AAE5E,IAAM,4BAA4B;AAClC,IAAM,yBAAyB,KAAK;AACpC,IAAM,2BAA2B,IAAI;AA4BrC,SAAS,oBAAoB,UAA0C;AAC5E,MAAI,SAAS,yBAAyB,cAAc;AAClD,WAAO,6BAA6B,QAAQ;AAAA,EAC9C;AACA,MAAI,SAAS,yBAAyB,OAAO;AAC3C,WAAO,uBAAuB,QAAQ;AAAA,EACxC;AACA,SAAO,gCAAgC,QAAQ;AACjD;AAEA,SAAS,gCAAgC,UAA0C;AACjF,MAAI,SAAS,yBAAyB,oBAAoB,CAAC,SAAS,yBAAyB,CAAC,SAAS,4BAA4B,CAAC,SAAS,+BAA+B;AAC1K,WAAO;AAAA,EACT;AACA,QAAM,eAA+B;AAAA,IACnC,QAAQ,SAAS;AAAA,IACjB,gBAAgB,SAAS;AAAA,IACzB,4BAA4B;AAAA,IAC5B,GAAI,SAAS,wBAAwB,EAAE,UAAU,SAAS,sBAAsB,IAAI,CAAC;AAAA,IACrF,GAAI,SAAS,4BAA4B,SAAS,+BAA+B,EAAE,aAAa;AAAA,MAC9F,aAAa,SAAS;AAAA,MACtB,iBAAiB,SAAS;AAAA,IAC5B,EAAE,IAAI,CAAC;AAAA,EACT;AACA,QAAM,SAAS,IAAI,SAAS,YAAY;AACxC,SAAO;AAAA,IACL,QAAQ,SAAS;AAAA,IACjB,SAAS,SAAS,yBAAyB,WAAW,WAAW;AAAA,IACjE,uBAAuB;AAAA,IACvB,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,YAAM,kBAA0C;AAAA,QAC9C,gBAAgB,KAAK;AAAA,MACvB;AACA,YAAM,UAAU,IAAI,iBAAiB;AAAA,QACnC,QAAQ,SAAS;AAAA,QACjB,KAAK,KAAK;AAAA,QACV,aAAa,KAAK;AAAA,QAClB,UAAU,KAAK,SAAS,EAAE,QAAQ,KAAK,OAAO,IAAI;AAAA,MACpD,CAAC;AACD,aAAO;AAAA,QACL,KAAK,MAAM,aAAa,QAAQ,SAAS,EAAE,UAAU,CAAC;AAAA,QACtD;AAAA,QACA,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AAAA,MACnD;AAAA,IACF;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,aAAO;AAAA,QACL,KAAK,MAAM,aAAa,QAAQ,IAAI,iBAAiB;AAAA,UACnD,QAAQ,SAAS;AAAA,UACjB,KAAK,KAAK;AAAA,QACZ,CAAC,GAAG,EAAE,UAAU,CAAC;AAAA,QACjB,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AAAA,MACnD;AAAA,IACF;AAAA,IACA,MAAM,UAAU,MAAM;AAKpB,YAAM,OAAO,KAAK,IAAI,iBAAiB;AAAA,QACrC,QAAQ,SAAS;AAAA,QACjB,KAAK,KAAK;AAAA,QACV,aAAa,KAAK;AAAA,QAClB,MAAM,KAAK;AAAA,QACX,UAAU,KAAK,SAAS,EAAE,QAAQ,KAAK,OAAO,IAAI;AAAA,MACpD,CAAC,CAAC;AAAA,IACJ;AAAA,IACA,MAAM,SAAS,MAAM;AACnB,YAAM,OAAO,MAAM,OAAO,KAAK,IAAI,kBAAkB;AAAA,QACnD,QAAQ,KAAK;AAAA,QACb,KAAK,KAAK;AAAA,MACZ,CAAC,CAAC;AACF,aAAO,WAAW;AAAA,QAChB,eAAe,KAAK;AAAA,QACpB,aAAa,KAAK;AAAA,QAClB,UAAU,KAAK;AAAA,MACjB,CAAC;AAAA,IACH;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,YAAM,SAAS,MAAM,OAAO,KAAK,IAAI,iBAAiB;AAAA,QACpD,QAAQ,KAAK;AAAA,QACb,KAAK,KAAK;AAAA,MACZ,CAAC,CAAC;AACF,UAAI,CAAC,OAAO,MAAM;AAChB,cAAM,IAAI,MAAM,yBAAyB,KAAK,SAAS,EAAE;AAAA,MAC3D;AACA,UAAI,OAAO,OAAO,KAAK,yBAAyB,YAAY;AAC1D,eAAO,MAAM,OAAO,KAAK,qBAAqB;AAAA,MAChD;AACA,YAAM,SAAuB,CAAC;AAC9B,uBAAiB,SAAS,OAAO,MAAqD;AACpF,eAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,MACpE;AACA,aAAO,OAAO,OAAO,MAAM;AAAA,IAC7B;AAAA,EACF;AACF;AAEA,SAAS,uBAAuB,UAAmC;AACjE,QAAM,SAAS,IAAI,UAAU,iBAAiB,QAAQ,CAAC;AACvD,QAAM,SAAS,OAAO,OAAO,SAAS,mBAAmB;AACzD,SAAO;AAAA,IACL,QAAQ,SAAS;AAAA,IACjB,SAAS;AAAA,IACT,uBAAuB;AAAA,IACvB,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,YAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AACxD,YAAM,SAA6B;AAAA,QACjC,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,aAAa,KAAK;AAAA,MACpB;AACA,UAAI,KAAK,QAAQ;AACf,eAAO,mBAAmB,EAAE,sBAAsB,KAAK,OAAO;AAAA,MAChE;AACA,YAAM,CAAC,GAAG,IAAI,MAAM,OAAO,KAAK,KAAK,GAAG,EAAE,aAAa,MAAM;AAC7D,aAAO;AAAA,QACL;AAAA,QACA,iBAAiB;AAAA,UACf,gBAAgB,KAAK;AAAA,UACrB,GAAI,KAAK,SAAS,EAAE,sBAAsB,KAAK,OAAO,IAAI,CAAC;AAAA,QAC7D;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,YAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AACxD,YAAM,CAAC,GAAG,IAAI,MAAM,OAAO,KAAK,KAAK,GAAG,EAAE,aAAa;AAAA,QACrD,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,SAAS;AAAA,MACX,CAAC;AACD,aAAO,EAAE,KAAK,UAAU;AAAA,IAC1B;AAAA,IACA,MAAM,UAAU,MAAM;AAEpB,YAAM,OAAO,KAAK,KAAK,GAAG,EAAE,KAAK,OAAO,KAAK,KAAK,IAAI,GAAG;AAAA,QACvD,aAAa,KAAK;AAAA,QAClB,GAAI,KAAK,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,KAAK,OAAO,EAAE,EAAE,IAAI,CAAC;AAAA,MAC3E,CAAC;AAAA,IACH;AAAA,IACA,MAAM,SAAS,MAAM;AACnB,YAAM,CAAC,QAAQ,IAAI,MAAM,OAAO,KAAK,KAAK,SAAS,EAAE,YAAY;AACjE,aAAO,WAAW;AAAA,QAChB,eAAe,mBAAmB,SAAS,IAAI;AAAA,QAC/C,aAAa,SAAS;AAAA,QACtB,UAAU,eAAe,SAAS,QAAQ;AAAA,MAC5C,CAAC;AAAA,IACH;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,YAAM,CAAC,KAAK,IAAI,MAAM,OAAO,KAAK,KAAK,SAAS,EAAE,SAAS;AAC3D,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEA,SAAS,6BAA6B,UAA0C;AAC9E,QAAM,YAAY,yBAAyB,QAAQ;AACnD,QAAM,gBAAgB,SAAS,qCAC3B,kBAAkB,qBAAqB,SAAS,kCAAkC,IAClF,IAAI,kBAAkB,oBAAoB,QAAQ,GAAG,SAAS;AAClE,QAAM,kBAAkB,cAAc,mBAAmB,SAAS,mBAAmB;AAErF,SAAO;AAAA,IACL,QAAQ,SAAS;AAAA,IACjB,SAAS;AAAA,IACT,uBAAuB;AAAA,IACvB,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,YAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AACxD,YAAM,aAAa,gBAAgB,mBAAmB,KAAK,GAAG;AAC9D,YAAM,MAAM,+BAA+B;AAAA,QACzC,eAAe,SAAS;AAAA,QACxB,UAAU,KAAK;AAAA,QACf,aAAa,mBAAmB,MAAM,IAAI;AAAA,QAC1C,WAAW;AAAA,QACX,aAAa,KAAK;AAAA,MACpB,GAAG,SAAS,EAAE,SAAS;AACvB,aAAO;AAAA,QACL,KAAK,GAAG,WAAW,GAAG,IAAI,GAAG;AAAA,QAC7B,iBAAiB;AAAA,UACf,gBAAgB,KAAK;AAAA,UACrB,kBAAkB;AAAA,UAClB,GAAI,KAAK,SAAS,EAAE,oBAAoB,KAAK,OAAO,IAAI,CAAC;AAAA,QAC3D;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,YAAM,YAAY,KAAK,oBAAoB;AAC3C,YAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;AACxD,YAAM,aAAa,gBAAgB,cAAc,KAAK,GAAG;AACzD,YAAM,MAAM,+BAA+B;AAAA,QACzC,eAAe,SAAS;AAAA,QACxB,UAAU,KAAK;AAAA,QACf,aAAa,mBAAmB,MAAM,GAAG;AAAA,QACzC,WAAW;AAAA,MACb,GAAG,SAAS,EAAE,SAAS;AACvB,aAAO;AAAA,QACL,KAAK,GAAG,WAAW,GAAG,IAAI,GAAG;AAAA,QAC7B;AAAA,MACF;AAAA,IACF;AAAA,IACA,MAAM,UAAU,MAAM;AAEpB,YAAM,aAAa,gBAAgB,mBAAmB,KAAK,GAAG;AAC9D,YAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAClC,YAAM,WAAW,OAAO,MAAM,KAAK,YAAY;AAAA,QAC7C,iBAAiB,EAAE,iBAAiB,KAAK,YAAY;AAAA,QACrD,GAAI,KAAK,SAAS,EAAE,UAAU,EAAE,QAAQ,KAAK,OAAO,EAAE,IAAI,CAAC;AAAA,MAC7D,CAAC;AAAA,IACH;AAAA,IACA,MAAM,SAAS,MAAM;AACnB,aAAO,sBAAsB,MAAM,gBAAgB,cAAc,KAAK,SAAS,EAAE,cAAc,CAAC;AAAA,IAClG;AAAA,IACA,MAAM,aAAa,MAAM;AACvB,aAAO,MAAM,qBAAqB,MAAM,gBAAgB,cAAc,KAAK,SAAS,EAAE,SAAS,CAAC;AAAA,IAClG;AAAA,EACF;AACF;AAEA,SAAS,yBAAyB,UAAgD;AAChF,MAAI,SAAS,oCAAoC;AAC/C,UAAM,SAAS,sBAAsB,SAAS,kCAAkC;AAChF,QAAI,OAAO,eAAe,OAAO,YAAY;AAC3C,aAAO,IAAI,2BAA2B,OAAO,aAAa,OAAO,UAAU;AAAA,IAC7E;AACA,UAAM,IAAI,MAAM,+FAA+F;AAAA,EACjH;AACA,MAAI,CAAC,SAAS,iCAAiC,CAAC,SAAS,8BAA8B;AACrF,UAAM,IAAI,MAAM,0DAA0D;AAAA,EAC5E;AACA,SAAO,IAAI,2BAA2B,SAAS,+BAA+B,SAAS,4BAA4B;AACrH;AAEA,SAAS,oBAAoB,UAA4B;AACvD,MAAI,SAAS,4BAA4B;AACvC,WAAO,SAAS,2BAA2B,QAAQ,QAAQ,EAAE;AAAA,EAC/D;AACA,MAAI,CAAC,SAAS,+BAA+B;AAC3C,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,SAAO,WAAW,SAAS,6BAA6B;AAC1D;AAEA,SAAS,sBAAsB,OAAuC;AACpE,SAAO,OAAO,YAAY,MAAM,MAAM,GAAG,EACtC,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,OAAO,OAAO,EACd,IAAI,CAAC,SAAS;AACb,UAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,WAAO,UAAU,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,MAAM,GAAG,KAAK,GAAG,KAAK,MAAM,QAAQ,CAAC,CAAC;AAAA,EACjF,CAAC,CAAC;AACN;AAEA,SAAS,iBAAiB,UAAoC;AAC5D,QAAM,UAA0B;AAAA,IAC9B,GAAI,SAAS,4BAA4B,EAAE,WAAW,SAAS,0BAA0B,IAAI,CAAC;AAAA,IAC9F,GAAI,SAAS,8BAA8B,EAAE,aAAa,SAAS,4BAA4B,IAAI,CAAC;AAAA,IACpG,GAAI,SAAS,8BAA8B,EAAE,aAAa,SAAS,4BAA4B,IAAI,CAAC;AAAA,EACtG;AACA,MAAI,SAAS,iCAAiC;AAC5C,YAAQ,cAAc,oBAAoB,SAAS,+BAA+B;AAAA,EACpF;AACA,SAAO;AACT;AAEA,SAAS,oBAAoB,KAAqC;AAChE,QAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,MAAI,CAAC,UAAU,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,GAAG;AAClE,UAAM,IAAI,MAAM,wCAAwC;AAAA,EAC1D;AACA,SAAO;AACT;AAEA,SAAS,mBAAmB,OAAwD;AAClF,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO;AAAA,EACT;AACA,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GAAG;AACnD,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,KAAK;AAC3B,SAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC5C;AAEA,SAAS,eAAe,OAAyG;AAC/H,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,EACT;AACA,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,KAAK,EACjB,OAAO,CAAC,UAAqC,OAAO,MAAM,CAAC,MAAM,QAAQ;AAAA,EAC9E;AACF;AAEA,SAAS,sBAAsB,MAA6C;AAC1E,SAAO,WAAW;AAAA,IAChB,eAAe,KAAK;AAAA,IACpB,aAAa,KAAK;AAAA,IAClB,UAAU,KAAK;AAAA,EACjB,CAAC;AACH;AAEA,eAAe,qBAAqB,UAA2D;AAC7F,MAAI,CAAC,SAAS,oBAAoB;AAChC,UAAM,IAAI,MAAM,8DAA8D;AAAA,EAChF;AACA,QAAM,SAAuB,CAAC;AAC9B,mBAAiB,SAAS,SAAS,oBAAmE;AACpG,WAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,EACpE;AACA,SAAO,OAAO,OAAO,MAAM;AAC7B;AAEA,SAAS,WAAW,OAIL;AACb,SAAO;AAAA,IACL,GAAI,MAAM,kBAAkB,SAAY,EAAE,eAAe,MAAM,cAAc,IAAI,CAAC;AAAA,IAClF,GAAI,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,YAAY,IAAI,CAAC;AAAA,IAC5E,GAAI,MAAM,aAAa,SAAY,EAAE,UAAU,MAAM,SAAS,IAAI,CAAC;AAAA,EACrE;AACF;AAEO,SAAS,eAAe,OAAmB,aAA6B;AAC7E,SAAO,QAAQ,WAAW,WAAW,OAAO,KAAK,KAAK,EAAE,SAAS,QAAQ,CAAC;AAC5E;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opengeni/storage",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"provenance": true
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@aws-sdk/client-s3": "^3.1044.0",
|
|
28
|
+
"@aws-sdk/s3-request-presigner": "^3.1044.0",
|
|
29
|
+
"@azure/storage-blob": "^12.29.1",
|
|
30
|
+
"@google-cloud/storage": "^7.19.0",
|
|
31
|
+
"@opengeni/config": "^0.2.0",
|
|
32
|
+
"@opengeni/contracts": "^0.3.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"tsup": "^8.5.0",
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
|
|
41
|
+
"directory": "packages/storage"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { FileAsset } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
BlobSASPermissions,
|
|
5
|
+
BlobServiceClient,
|
|
6
|
+
BlockBlobClient,
|
|
7
|
+
generateBlobSASQueryParameters,
|
|
8
|
+
StorageSharedKeyCredential,
|
|
9
|
+
type BlobDownloadResponseParsed,
|
|
10
|
+
type BlobGetPropertiesResponse,
|
|
11
|
+
} from "@azure/storage-blob";
|
|
12
|
+
import {
|
|
13
|
+
GetObjectCommand,
|
|
14
|
+
HeadObjectCommand,
|
|
15
|
+
PutObjectCommand,
|
|
16
|
+
S3Client,
|
|
17
|
+
type S3ClientConfig,
|
|
18
|
+
} from "@aws-sdk/client-s3";
|
|
19
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
20
|
+
import { Storage as GcsClient, type GetSignedUrlConfig, type StorageOptions } from "@google-cloud/storage";
|
|
21
|
+
|
|
22
|
+
export const MAX_SINGLE_PUT_SIZE_BYTES = 5_000_000_000;
|
|
23
|
+
export const UPLOAD_URL_TTL_SECONDS = 15 * 60;
|
|
24
|
+
export const DOWNLOAD_URL_TTL_SECONDS = 5 * 60;
|
|
25
|
+
|
|
26
|
+
export type ObjectHead = {
|
|
27
|
+
ContentLength?: number;
|
|
28
|
+
ContentType?: string;
|
|
29
|
+
Metadata?: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ObjectStorage = {
|
|
33
|
+
bucket: string;
|
|
34
|
+
backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
|
|
35
|
+
maxSinglePutSizeBytes: number;
|
|
36
|
+
createPutUrl: (args: { key: string; contentType: string; sha256?: string | null; expiresInSeconds?: number }) => Promise<{ url: string; requiredHeaders: Record<string, string>; expiresAt: Date }>;
|
|
37
|
+
createGetUrl: (args: { key: string; expiresInSeconds?: number }) => Promise<{ url: string; expiresAt: Date }>;
|
|
38
|
+
headFile: (file: FileAsset) => Promise<ObjectHead>;
|
|
39
|
+
getFileBytes: (file: FileAsset) => Promise<Uint8Array>;
|
|
40
|
+
/**
|
|
41
|
+
* SERVER-SIDE authenticated direct PUT (no presign + browser fetch). For an
|
|
42
|
+
* in-process upload from a trusted holder of the storage credentials (e.g. the
|
|
43
|
+
* worker writing a recording), this sends the bytes straight to the storage
|
|
44
|
+
* backend over the configured endpoint with the in-process SDK client — bypassing
|
|
45
|
+
* the presigned-URL round-trip, which on split public/internal topologies (a
|
|
46
|
+
* public `objectStorageEndpoint` with no in-cluster route) would otherwise 401.
|
|
47
|
+
* Browser uploads keep using `createPutUrl`; this is the trusted-server twin.
|
|
48
|
+
*/
|
|
49
|
+
putObject: (args: { key: string; contentType: string; body: Uint8Array; sha256?: string | null }) => Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function createObjectStorage(settings: Settings): ObjectStorage | null {
|
|
53
|
+
if (settings.objectStorageBackend === "azure-blob") {
|
|
54
|
+
return createAzureBlobObjectStorage(settings);
|
|
55
|
+
}
|
|
56
|
+
if (settings.objectStorageBackend === "gcs") {
|
|
57
|
+
return createGcsObjectStorage(settings);
|
|
58
|
+
}
|
|
59
|
+
return createS3CompatibleObjectStorage(settings);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | null {
|
|
63
|
+
if (settings.objectStorageBackend === "s3-compatible" && (!settings.objectStorageEndpoint || !settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const clientConfig: S3ClientConfig = {
|
|
67
|
+
region: settings.objectStorageRegion,
|
|
68
|
+
forcePathStyle: settings.objectStorageForcePathStyle,
|
|
69
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
70
|
+
...(settings.objectStorageEndpoint ? { endpoint: settings.objectStorageEndpoint } : {}),
|
|
71
|
+
...(settings.objectStorageAccessKeyId && settings.objectStorageSecretAccessKey ? { credentials: {
|
|
72
|
+
accessKeyId: settings.objectStorageAccessKeyId,
|
|
73
|
+
secretAccessKey: settings.objectStorageSecretAccessKey,
|
|
74
|
+
} } : {}),
|
|
75
|
+
};
|
|
76
|
+
const client = new S3Client(clientConfig);
|
|
77
|
+
return {
|
|
78
|
+
bucket: settings.objectStorageBucket,
|
|
79
|
+
backend: settings.objectStorageBackend === "aws-s3" ? "aws-s3" : "s3-compatible",
|
|
80
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
81
|
+
async createPutUrl(args) {
|
|
82
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
83
|
+
const requiredHeaders: Record<string, string> = {
|
|
84
|
+
"content-type": args.contentType,
|
|
85
|
+
};
|
|
86
|
+
const command = new PutObjectCommand({
|
|
87
|
+
Bucket: settings.objectStorageBucket,
|
|
88
|
+
Key: args.key,
|
|
89
|
+
ContentType: args.contentType,
|
|
90
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
url: await getSignedUrl(client, command, { expiresIn }),
|
|
94
|
+
requiredHeaders,
|
|
95
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async createGetUrl(args) {
|
|
99
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
100
|
+
return {
|
|
101
|
+
url: await getSignedUrl(client, new GetObjectCommand({
|
|
102
|
+
Bucket: settings.objectStorageBucket,
|
|
103
|
+
Key: args.key,
|
|
104
|
+
}), { expiresIn }),
|
|
105
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
async putObject(args) {
|
|
109
|
+
// Authenticated in-process PUT against the configured (in-cluster) endpoint.
|
|
110
|
+
// A presigned URL buys nothing here — the worker already holds the creds — and
|
|
111
|
+
// on a split public/internal endpoint topology the presigned URL points at the
|
|
112
|
+
// PUBLIC host (no MinIO route → 401). This sends bytes straight to the backend.
|
|
113
|
+
await client.send(new PutObjectCommand({
|
|
114
|
+
Bucket: settings.objectStorageBucket,
|
|
115
|
+
Key: args.key,
|
|
116
|
+
ContentType: args.contentType,
|
|
117
|
+
Body: args.body,
|
|
118
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,
|
|
119
|
+
}));
|
|
120
|
+
},
|
|
121
|
+
async headFile(file) {
|
|
122
|
+
const head = await client.send(new HeadObjectCommand({
|
|
123
|
+
Bucket: file.bucket,
|
|
124
|
+
Key: file.objectKey,
|
|
125
|
+
}));
|
|
126
|
+
return objectHead({
|
|
127
|
+
contentLength: head.ContentLength,
|
|
128
|
+
contentType: head.ContentType,
|
|
129
|
+
metadata: head.Metadata,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
async getFileBytes(file) {
|
|
133
|
+
const result = await client.send(new GetObjectCommand({
|
|
134
|
+
Bucket: file.bucket,
|
|
135
|
+
Key: file.objectKey,
|
|
136
|
+
}));
|
|
137
|
+
if (!result.Body) {
|
|
138
|
+
throw new Error(`Object body is empty: ${file.objectKey}`);
|
|
139
|
+
}
|
|
140
|
+
if (typeof result.Body.transformToByteArray === "function") {
|
|
141
|
+
return await result.Body.transformToByteArray();
|
|
142
|
+
}
|
|
143
|
+
const chunks: Uint8Array[] = [];
|
|
144
|
+
for await (const chunk of result.Body as AsyncIterable<Uint8Array | Buffer | string>) {
|
|
145
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createGcsObjectStorage(settings: Settings): ObjectStorage {
|
|
153
|
+
const client = new GcsClient(gcsClientOptions(settings));
|
|
154
|
+
const bucket = client.bucket(settings.objectStorageBucket);
|
|
155
|
+
return {
|
|
156
|
+
bucket: settings.objectStorageBucket,
|
|
157
|
+
backend: "gcs",
|
|
158
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
159
|
+
async createPutUrl(args) {
|
|
160
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
161
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
|
162
|
+
const config: GetSignedUrlConfig = {
|
|
163
|
+
version: "v4",
|
|
164
|
+
action: "write",
|
|
165
|
+
expires: expiresAt,
|
|
166
|
+
contentType: args.contentType,
|
|
167
|
+
};
|
|
168
|
+
if (args.sha256) {
|
|
169
|
+
config.extensionHeaders = { "x-goog-meta-sha256": args.sha256 };
|
|
170
|
+
}
|
|
171
|
+
const [url] = await bucket.file(args.key).getSignedUrl(config);
|
|
172
|
+
return {
|
|
173
|
+
url,
|
|
174
|
+
requiredHeaders: {
|
|
175
|
+
"content-type": args.contentType,
|
|
176
|
+
...(args.sha256 ? { "x-goog-meta-sha256": args.sha256 } : {}),
|
|
177
|
+
},
|
|
178
|
+
expiresAt,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
async createGetUrl(args) {
|
|
182
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
183
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
|
184
|
+
const [url] = await bucket.file(args.key).getSignedUrl({
|
|
185
|
+
version: "v4",
|
|
186
|
+
action: "read",
|
|
187
|
+
expires: expiresAt,
|
|
188
|
+
});
|
|
189
|
+
return { url, expiresAt };
|
|
190
|
+
},
|
|
191
|
+
async putObject(args) {
|
|
192
|
+
// Authenticated in-process PUT via the GCS SDK (the server holds the creds).
|
|
193
|
+
await bucket.file(args.key).save(Buffer.from(args.body), {
|
|
194
|
+
contentType: args.contentType,
|
|
195
|
+
...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
async headFile(file) {
|
|
199
|
+
const [metadata] = await bucket.file(file.objectKey).getMetadata();
|
|
200
|
+
return objectHead({
|
|
201
|
+
contentLength: parseContentLength(metadata.size),
|
|
202
|
+
contentType: metadata.contentType,
|
|
203
|
+
metadata: stringMetadata(metadata.metadata),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
async getFileBytes(file) {
|
|
207
|
+
const [bytes] = await bucket.file(file.objectKey).download();
|
|
208
|
+
return bytes;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null {
|
|
214
|
+
const sharedKey = azureSharedKeyCredential(settings);
|
|
215
|
+
const serviceClient = settings.objectStorageAzureConnectionString
|
|
216
|
+
? BlobServiceClient.fromConnectionString(settings.objectStorageAzureConnectionString)
|
|
217
|
+
: new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey);
|
|
218
|
+
const containerClient = serviceClient.getContainerClient(settings.objectStorageBucket);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
bucket: settings.objectStorageBucket,
|
|
222
|
+
backend: "azure-blob",
|
|
223
|
+
maxSinglePutSizeBytes: MAX_SINGLE_PUT_SIZE_BYTES,
|
|
224
|
+
async createPutUrl(args) {
|
|
225
|
+
const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
|
|
226
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
|
227
|
+
const blobClient = containerClient.getBlockBlobClient(args.key);
|
|
228
|
+
const sas = generateBlobSASQueryParameters({
|
|
229
|
+
containerName: settings.objectStorageBucket,
|
|
230
|
+
blobName: args.key,
|
|
231
|
+
permissions: BlobSASPermissions.parse("cw"),
|
|
232
|
+
expiresOn: expiresAt,
|
|
233
|
+
contentType: args.contentType,
|
|
234
|
+
}, sharedKey).toString();
|
|
235
|
+
return {
|
|
236
|
+
url: `${blobClient.url}?${sas}`,
|
|
237
|
+
requiredHeaders: {
|
|
238
|
+
"content-type": args.contentType,
|
|
239
|
+
"x-ms-blob-type": "BlockBlob",
|
|
240
|
+
...(args.sha256 ? { "x-ms-meta-sha256": args.sha256 } : {}),
|
|
241
|
+
},
|
|
242
|
+
expiresAt,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
async createGetUrl(args) {
|
|
246
|
+
const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
|
|
247
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
|
248
|
+
const blobClient = containerClient.getBlobClient(args.key);
|
|
249
|
+
const sas = generateBlobSASQueryParameters({
|
|
250
|
+
containerName: settings.objectStorageBucket,
|
|
251
|
+
blobName: args.key,
|
|
252
|
+
permissions: BlobSASPermissions.parse("r"),
|
|
253
|
+
expiresOn: expiresAt,
|
|
254
|
+
}, sharedKey).toString();
|
|
255
|
+
return {
|
|
256
|
+
url: `${blobClient.url}?${sas}`,
|
|
257
|
+
expiresAt,
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
async putObject(args) {
|
|
261
|
+
// Authenticated in-process upload via the shared-key Azure client (no SAS).
|
|
262
|
+
const blobClient = containerClient.getBlockBlobClient(args.key);
|
|
263
|
+
const body = Buffer.from(args.body);
|
|
264
|
+
await blobClient.upload(body, body.byteLength, {
|
|
265
|
+
blobHTTPHeaders: { blobContentType: args.contentType },
|
|
266
|
+
...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
async headFile(file) {
|
|
270
|
+
return azureHeadToObjectHead(await containerClient.getBlobClient(file.objectKey).getProperties());
|
|
271
|
+
},
|
|
272
|
+
async getFileBytes(file) {
|
|
273
|
+
return await azureDownloadToBytes(await containerClient.getBlobClient(file.objectKey).download());
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function azureSharedKeyCredential(settings: Settings): StorageSharedKeyCredential {
|
|
279
|
+
if (settings.objectStorageAzureConnectionString) {
|
|
280
|
+
const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
|
|
281
|
+
if (parsed.AccountName && parsed.AccountKey) {
|
|
282
|
+
return new StorageSharedKeyCredential(parsed.AccountName, parsed.AccountKey);
|
|
283
|
+
}
|
|
284
|
+
throw new Error("Azure Blob connection string must include AccountName and AccountKey to create presigned URLs");
|
|
285
|
+
}
|
|
286
|
+
if (!settings.objectStorageAzureAccountName || !settings.objectStorageAzureAccountKey) {
|
|
287
|
+
throw new Error("Azure Blob storage requires account name and account key");
|
|
288
|
+
}
|
|
289
|
+
return new StorageSharedKeyCredential(settings.objectStorageAzureAccountName, settings.objectStorageAzureAccountKey);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function azureBlobServiceUrl(settings: Settings): string {
|
|
293
|
+
if (settings.objectStorageAzureEndpoint) {
|
|
294
|
+
return settings.objectStorageAzureEndpoint.replace(/\/+$/, "");
|
|
295
|
+
}
|
|
296
|
+
if (!settings.objectStorageAzureAccountName) {
|
|
297
|
+
throw new Error("Azure Blob storage requires account name");
|
|
298
|
+
}
|
|
299
|
+
return `https://${settings.objectStorageAzureAccountName}.blob.core.windows.net`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseConnectionString(value: string): Record<string, string> {
|
|
303
|
+
return Object.fromEntries(value.split(";")
|
|
304
|
+
.map((part) => part.trim())
|
|
305
|
+
.filter(Boolean)
|
|
306
|
+
.map((part) => {
|
|
307
|
+
const index = part.indexOf("=");
|
|
308
|
+
return index === -1 ? [part, ""] : [part.slice(0, index), part.slice(index + 1)];
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function gcsClientOptions(settings: Settings): StorageOptions {
|
|
313
|
+
const options: StorageOptions = {
|
|
314
|
+
...(settings.objectStorageGcsProjectId ? { projectId: settings.objectStorageGcsProjectId } : {}),
|
|
315
|
+
...(settings.objectStorageGcsKeyFilename ? { keyFilename: settings.objectStorageGcsKeyFilename } : {}),
|
|
316
|
+
...(settings.objectStorageGcsApiEndpoint ? { apiEndpoint: settings.objectStorageGcsApiEndpoint } : {}),
|
|
317
|
+
};
|
|
318
|
+
if (settings.objectStorageGcsCredentialsJson) {
|
|
319
|
+
options.credentials = parseGcsCredentials(settings.objectStorageGcsCredentialsJson);
|
|
320
|
+
}
|
|
321
|
+
return options;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function parseGcsCredentials(raw: string): Record<string, string> {
|
|
325
|
+
const parsed = JSON.parse(raw);
|
|
326
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
327
|
+
throw new Error("GCS credentials JSON must be an object");
|
|
328
|
+
}
|
|
329
|
+
return parsed as Record<string, string>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseContentLength(value: string | number | undefined): number | undefined {
|
|
333
|
+
if (typeof value === "number") {
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const parsed = Number(value);
|
|
340
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stringMetadata(value: Record<string, string | number | boolean | null> | undefined): Record<string, string> | undefined {
|
|
344
|
+
if (!value) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return Object.fromEntries(
|
|
348
|
+
Object.entries(value)
|
|
349
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function azureHeadToObjectHead(head: BlobGetPropertiesResponse): ObjectHead {
|
|
354
|
+
return objectHead({
|
|
355
|
+
contentLength: head.contentLength,
|
|
356
|
+
contentType: head.contentType,
|
|
357
|
+
metadata: head.metadata,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function azureDownloadToBytes(download: BlobDownloadResponseParsed): Promise<Uint8Array> {
|
|
362
|
+
if (!download.readableStreamBody) {
|
|
363
|
+
throw new Error("Azure Blob download response did not include a readable body");
|
|
364
|
+
}
|
|
365
|
+
const chunks: Uint8Array[] = [];
|
|
366
|
+
for await (const chunk of download.readableStreamBody as AsyncIterable<Uint8Array | Buffer | string>) {
|
|
367
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
368
|
+
}
|
|
369
|
+
return Buffer.concat(chunks);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function objectHead(input: {
|
|
373
|
+
contentLength?: number | undefined;
|
|
374
|
+
contentType?: string | undefined;
|
|
375
|
+
metadata?: Record<string, string> | undefined;
|
|
376
|
+
}): ObjectHead {
|
|
377
|
+
return {
|
|
378
|
+
...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
|
|
379
|
+
...(input.contentType !== undefined ? { ContentType: input.contentType } : {}),
|
|
380
|
+
...(input.metadata !== undefined ? { Metadata: input.metadata } : {}),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function bytesToDataUrl(bytes: Uint8Array, contentType: string): string {
|
|
385
|
+
return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
386
|
+
}
|