@objectstack/service-storage 4.0.4 → 4.0.5
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/README.md +76 -401
- package/dist/index.cjs +1117 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7032 -22
- package/dist/index.d.ts +7032 -22
- package/dist/index.js +1110 -69
- package/dist/index.js.map +1 -1
- package/package.json +43 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -177
- package/src/index.ts +0 -8
- package/src/local-storage-adapter.test.ts +0 -91
- package/src/local-storage-adapter.ts +0 -100
- package/src/s3-storage-adapter.ts +0 -88
- package/src/storage-service-plugin.ts +0 -66
- package/tsconfig.json +0 -17
package/dist/index.js
CHANGED
|
@@ -1,37 +1,332 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/s3-storage-adapter.ts
|
|
12
|
+
var s3_storage_adapter_exports = {};
|
|
13
|
+
__export(s3_storage_adapter_exports, {
|
|
14
|
+
S3StorageAdapter: () => S3StorageAdapter
|
|
15
|
+
});
|
|
16
|
+
async function streamToBuffer(stream) {
|
|
17
|
+
if (Buffer.isBuffer(stream)) return stream;
|
|
18
|
+
if (stream instanceof Uint8Array) return Buffer.from(stream);
|
|
19
|
+
const chunks = [];
|
|
20
|
+
if (typeof stream[Symbol.asyncIterator] === "function") {
|
|
21
|
+
for await (const chunk of stream) {
|
|
22
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
23
|
+
}
|
|
24
|
+
} else if (stream.getReader) {
|
|
25
|
+
const reader = stream.getReader();
|
|
26
|
+
let done = false;
|
|
27
|
+
while (!done) {
|
|
28
|
+
const result = await reader.read();
|
|
29
|
+
done = result.done;
|
|
30
|
+
if (result.value) chunks.push(result.value);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
throw new Error("Cannot convert stream to buffer");
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks);
|
|
36
|
+
}
|
|
37
|
+
var S3StorageAdapter;
|
|
38
|
+
var init_s3_storage_adapter = __esm({
|
|
39
|
+
"src/s3-storage-adapter.ts"() {
|
|
40
|
+
"use strict";
|
|
41
|
+
S3StorageAdapter = class {
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.clientPromise = null;
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Internal upload key tracking
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
this._uploadKeys = /* @__PURE__ */ new Map();
|
|
49
|
+
this.bucket = options.bucket;
|
|
50
|
+
this.region = options.region;
|
|
51
|
+
this.endpoint = options.endpoint;
|
|
52
|
+
this.forcePathStyle = options.forcePathStyle ?? false;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Lazily resolve the AWS S3 client to avoid crashing at import time when
|
|
56
|
+
* `@aws-sdk/client-s3` isn't installed.
|
|
57
|
+
*/
|
|
58
|
+
async getClient() {
|
|
59
|
+
if (!this.clientPromise) {
|
|
60
|
+
this.clientPromise = (async () => {
|
|
61
|
+
let s3Mod;
|
|
62
|
+
try {
|
|
63
|
+
s3Mod = await import("@aws-sdk/client-s3");
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"S3StorageAdapter requires @aws-sdk/client-s3. Install it with: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const { S3Client } = s3Mod;
|
|
70
|
+
const clientOpts = { region: this.region };
|
|
71
|
+
if (this.endpoint) clientOpts.endpoint = this.endpoint;
|
|
72
|
+
if (this.forcePathStyle) clientOpts.forcePathStyle = true;
|
|
73
|
+
if (this.options.accessKeyId && this.options.secretAccessKey) {
|
|
74
|
+
clientOpts.credentials = {
|
|
75
|
+
accessKeyId: this.options.accessKeyId,
|
|
76
|
+
secretAccessKey: this.options.secretAccessKey
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return new S3Client(clientOpts);
|
|
80
|
+
})();
|
|
81
|
+
}
|
|
82
|
+
return this.clientPromise;
|
|
83
|
+
}
|
|
84
|
+
async s3Mod() {
|
|
85
|
+
try {
|
|
86
|
+
return await import("@aws-sdk/client-s3");
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error("S3StorageAdapter requires @aws-sdk/client-s3");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async presignerMod() {
|
|
92
|
+
try {
|
|
93
|
+
return await import("@aws-sdk/s3-request-presigner");
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("S3StorageAdapter requires @aws-sdk/s3-request-presigner");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Basic operations
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
async upload(key, data, options) {
|
|
102
|
+
const client = await this.getClient();
|
|
103
|
+
const s3 = await this.s3Mod();
|
|
104
|
+
const body = data instanceof Buffer ? data : await streamToBuffer(data);
|
|
105
|
+
const cmd = new s3.PutObjectCommand({
|
|
106
|
+
Bucket: this.bucket,
|
|
107
|
+
Key: key,
|
|
108
|
+
Body: body,
|
|
109
|
+
ContentType: options?.contentType,
|
|
110
|
+
Metadata: options?.metadata,
|
|
111
|
+
ACL: options?.acl === "public-read" ? "public-read" : void 0
|
|
112
|
+
});
|
|
113
|
+
await client.send(cmd);
|
|
114
|
+
}
|
|
115
|
+
async download(key) {
|
|
116
|
+
const client = await this.getClient();
|
|
117
|
+
const s3 = await this.s3Mod();
|
|
118
|
+
const cmd = new s3.GetObjectCommand({ Bucket: this.bucket, Key: key });
|
|
119
|
+
const res = await client.send(cmd);
|
|
120
|
+
return streamToBuffer(res.Body);
|
|
121
|
+
}
|
|
122
|
+
async delete(key) {
|
|
123
|
+
const client = await this.getClient();
|
|
124
|
+
const s3 = await this.s3Mod();
|
|
125
|
+
const cmd = new s3.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
|
|
126
|
+
await client.send(cmd);
|
|
127
|
+
}
|
|
128
|
+
async exists(key) {
|
|
129
|
+
const client = await this.getClient();
|
|
130
|
+
const s3 = await this.s3Mod();
|
|
131
|
+
try {
|
|
132
|
+
const cmd = new s3.HeadObjectCommand({ Bucket: this.bucket, Key: key });
|
|
133
|
+
await client.send(cmd);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) return false;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async getInfo(key) {
|
|
141
|
+
const client = await this.getClient();
|
|
142
|
+
const s3 = await this.s3Mod();
|
|
143
|
+
const cmd = new s3.HeadObjectCommand({ Bucket: this.bucket, Key: key });
|
|
144
|
+
const res = await client.send(cmd);
|
|
145
|
+
return {
|
|
146
|
+
key,
|
|
147
|
+
size: res.ContentLength ?? 0,
|
|
148
|
+
contentType: res.ContentType,
|
|
149
|
+
lastModified: res.LastModified ?? /* @__PURE__ */ new Date(),
|
|
150
|
+
metadata: res.Metadata
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async list(prefix) {
|
|
154
|
+
const client = await this.getClient();
|
|
155
|
+
const s3 = await this.s3Mod();
|
|
156
|
+
const cmd = new s3.ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
|
|
157
|
+
const res = await client.send(cmd);
|
|
158
|
+
return (res.Contents ?? []).map((item) => ({
|
|
159
|
+
key: item.Key,
|
|
160
|
+
size: item.Size ?? 0,
|
|
161
|
+
lastModified: item.LastModified ?? /* @__PURE__ */ new Date()
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Presigned URLs
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
async getSignedUrl(key, expiresIn) {
|
|
168
|
+
const desc = await this.getPresignedDownload(key, expiresIn);
|
|
169
|
+
return desc.downloadUrl;
|
|
170
|
+
}
|
|
171
|
+
async getPresignedUpload(key, expiresIn, options) {
|
|
172
|
+
const client = await this.getClient();
|
|
173
|
+
const s3 = await this.s3Mod();
|
|
174
|
+
const { getSignedUrl } = await this.presignerMod();
|
|
175
|
+
const cmd = new s3.PutObjectCommand({
|
|
176
|
+
Bucket: this.bucket,
|
|
177
|
+
Key: key,
|
|
178
|
+
ContentType: options?.contentType,
|
|
179
|
+
Metadata: options?.metadata,
|
|
180
|
+
ACL: options?.acl === "public-read" ? "public-read" : void 0
|
|
181
|
+
});
|
|
182
|
+
const url = await getSignedUrl(client, cmd, { expiresIn });
|
|
183
|
+
return {
|
|
184
|
+
uploadUrl: url,
|
|
185
|
+
method: "PUT",
|
|
186
|
+
headers: options?.contentType ? { "content-type": options.contentType } : void 0,
|
|
187
|
+
expiresIn
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async getPresignedDownload(key, expiresIn) {
|
|
191
|
+
const client = await this.getClient();
|
|
192
|
+
const s3 = await this.s3Mod();
|
|
193
|
+
const { getSignedUrl } = await this.presignerMod();
|
|
194
|
+
const cmd = new s3.GetObjectCommand({ Bucket: this.bucket, Key: key });
|
|
195
|
+
const url = await getSignedUrl(client, cmd, { expiresIn });
|
|
196
|
+
return { downloadUrl: url, expiresIn };
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Chunked / multipart upload
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
async initiateChunkedUpload(key, options) {
|
|
202
|
+
const client = await this.getClient();
|
|
203
|
+
const s3 = await this.s3Mod();
|
|
204
|
+
const cmd = new s3.CreateMultipartUploadCommand({
|
|
205
|
+
Bucket: this.bucket,
|
|
206
|
+
Key: key,
|
|
207
|
+
ContentType: options?.contentType,
|
|
208
|
+
Metadata: options?.metadata
|
|
209
|
+
});
|
|
210
|
+
const res = await client.send(cmd);
|
|
211
|
+
return res.UploadId;
|
|
212
|
+
}
|
|
213
|
+
async uploadChunk(uploadId, partNumber, data) {
|
|
214
|
+
const client = await this.getClient();
|
|
215
|
+
const s3 = await this.s3Mod();
|
|
216
|
+
const key = this._uploadKeys?.get(uploadId);
|
|
217
|
+
if (!key) {
|
|
218
|
+
throw new Error("S3StorageAdapter: key not found for uploadId. Call setUploadKey() before uploadChunk().");
|
|
219
|
+
}
|
|
220
|
+
const cmd = new s3.UploadPartCommand({
|
|
221
|
+
Bucket: this.bucket,
|
|
222
|
+
Key: key,
|
|
223
|
+
UploadId: uploadId,
|
|
224
|
+
PartNumber: partNumber,
|
|
225
|
+
Body: data
|
|
226
|
+
});
|
|
227
|
+
const res = await client.send(cmd);
|
|
228
|
+
return res.ETag;
|
|
229
|
+
}
|
|
230
|
+
async completeChunkedUpload(uploadId, parts) {
|
|
231
|
+
const client = await this.getClient();
|
|
232
|
+
const s3 = await this.s3Mod();
|
|
233
|
+
const key = this._uploadKeys?.get(uploadId);
|
|
234
|
+
if (!key) {
|
|
235
|
+
throw new Error("S3StorageAdapter: key not found for uploadId.");
|
|
236
|
+
}
|
|
237
|
+
const cmd = new s3.CompleteMultipartUploadCommand({
|
|
238
|
+
Bucket: this.bucket,
|
|
239
|
+
Key: key,
|
|
240
|
+
UploadId: uploadId,
|
|
241
|
+
MultipartUpload: {
|
|
242
|
+
Parts: parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.eTag }))
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
await client.send(cmd);
|
|
246
|
+
this._uploadKeys?.delete(uploadId);
|
|
247
|
+
return key;
|
|
248
|
+
}
|
|
249
|
+
async abortChunkedUpload(uploadId) {
|
|
250
|
+
const client = await this.getClient();
|
|
251
|
+
const s3 = await this.s3Mod();
|
|
252
|
+
const key = this._uploadKeys?.get(uploadId);
|
|
253
|
+
if (!key) return;
|
|
254
|
+
const cmd = new s3.AbortMultipartUploadCommand({
|
|
255
|
+
Bucket: this.bucket,
|
|
256
|
+
Key: key,
|
|
257
|
+
UploadId: uploadId
|
|
258
|
+
});
|
|
259
|
+
await client.send(cmd);
|
|
260
|
+
this._uploadKeys?.delete(uploadId);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Register the storage key for a multipart upload session. Must be called
|
|
264
|
+
* by the StorageServicePlugin after `initiateChunkedUpload()` returns so
|
|
265
|
+
* that subsequent `uploadChunk` / `completeChunkedUpload` calls can resolve
|
|
266
|
+
* the S3 key without it being part of the IStorageService contract signature.
|
|
267
|
+
*/
|
|
268
|
+
setUploadKey(uploadId, key) {
|
|
269
|
+
this._uploadKeys.set(uploadId, key);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
1
275
|
// src/local-storage-adapter.ts
|
|
2
|
-
import { promises as fs } from "fs";
|
|
276
|
+
import { promises as fs, createReadStream, createWriteStream } from "fs";
|
|
3
277
|
import { join, dirname } from "path";
|
|
278
|
+
import { createHmac, randomUUID } from "crypto";
|
|
4
279
|
var LocalStorageAdapter = class {
|
|
5
280
|
constructor(options) {
|
|
6
281
|
this.rootDir = options.rootDir;
|
|
282
|
+
this.partsDir = join(this.rootDir, ".parts");
|
|
283
|
+
this.baseUrl = options.baseUrl ?? "";
|
|
284
|
+
this.basePath = options.basePath ?? "/api/v1/storage";
|
|
285
|
+
this.signingSecret = options.signingSecret ?? randomUUID();
|
|
7
286
|
}
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Path helpers
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
8
290
|
resolvePath(key) {
|
|
291
|
+
if (key.includes("..")) {
|
|
292
|
+
throw new Error(`LocalStorageAdapter: path traversal not allowed (key="${key}")`);
|
|
293
|
+
}
|
|
9
294
|
return join(this.rootDir, key);
|
|
10
295
|
}
|
|
296
|
+
resolvePartPath(uploadId, partNumber) {
|
|
297
|
+
if (!/^[A-Za-z0-9_-]+$/.test(uploadId)) {
|
|
298
|
+
throw new Error(`LocalStorageAdapter: invalid uploadId "${uploadId}"`);
|
|
299
|
+
}
|
|
300
|
+
return join(this.partsDir, uploadId, String(partNumber).padStart(8, "0"));
|
|
301
|
+
}
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Basic file operations
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
11
305
|
async upload(key, data, _options) {
|
|
12
306
|
const filePath = this.resolvePath(key);
|
|
13
307
|
await fs.mkdir(dirname(filePath), { recursive: true });
|
|
14
308
|
if (data instanceof Buffer) {
|
|
15
309
|
await fs.writeFile(filePath, data);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
await fs.writeFile(filePath, Buffer.concat(chunks));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const chunks = [];
|
|
313
|
+
const reader = data.getReader();
|
|
314
|
+
let done = false;
|
|
315
|
+
while (!done) {
|
|
316
|
+
const result = await reader.read();
|
|
317
|
+
done = result.done;
|
|
318
|
+
if (result.value) chunks.push(result.value);
|
|
26
319
|
}
|
|
320
|
+
await fs.writeFile(filePath, Buffer.concat(chunks));
|
|
27
321
|
}
|
|
28
322
|
async download(key) {
|
|
29
|
-
|
|
30
|
-
return fs.readFile(filePath);
|
|
323
|
+
return fs.readFile(this.resolvePath(key));
|
|
31
324
|
}
|
|
32
325
|
async delete(key) {
|
|
33
|
-
|
|
34
|
-
|
|
326
|
+
await fs.unlink(this.resolvePath(key)).catch((err) => {
|
|
327
|
+
if (err && err.code === "ENOENT") return;
|
|
328
|
+
throw err;
|
|
329
|
+
});
|
|
35
330
|
}
|
|
36
331
|
async exists(key) {
|
|
37
332
|
try {
|
|
@@ -44,11 +339,7 @@ var LocalStorageAdapter = class {
|
|
|
44
339
|
async getInfo(key) {
|
|
45
340
|
const filePath = this.resolvePath(key);
|
|
46
341
|
const stat = await fs.stat(filePath);
|
|
47
|
-
return {
|
|
48
|
-
key,
|
|
49
|
-
size: stat.size,
|
|
50
|
-
lastModified: stat.mtime
|
|
51
|
-
};
|
|
342
|
+
return { key, size: stat.size, lastModified: stat.mtime };
|
|
52
343
|
}
|
|
53
344
|
async list(prefix) {
|
|
54
345
|
const dirPath = this.resolvePath(prefix);
|
|
@@ -56,10 +347,10 @@ var LocalStorageAdapter = class {
|
|
|
56
347
|
const entries = await fs.readdir(dirPath);
|
|
57
348
|
const results = [];
|
|
58
349
|
for (const entry of entries) {
|
|
350
|
+
if (entry.startsWith(".")) continue;
|
|
59
351
|
const fullKey = prefix ? `${prefix}/${entry}` : entry;
|
|
60
352
|
try {
|
|
61
|
-
|
|
62
|
-
results.push(info);
|
|
353
|
+
results.push(await this.getInfo(fullKey));
|
|
63
354
|
} catch {
|
|
64
355
|
}
|
|
65
356
|
}
|
|
@@ -68,73 +359,823 @@ var LocalStorageAdapter = class {
|
|
|
68
359
|
return [];
|
|
69
360
|
}
|
|
70
361
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Presigned URL helpers
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
/**
|
|
366
|
+
* Sign an opaque token for the given payload.
|
|
367
|
+
* Format: base64url(JSON.stringify(payload)) + '.' + base64url(HMAC)
|
|
368
|
+
*/
|
|
369
|
+
signToken(payload) {
|
|
370
|
+
const b64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
371
|
+
const sig = createHmac("sha256", this.signingSecret).update(b64).digest("base64url");
|
|
372
|
+
return `${b64}.${sig}`;
|
|
80
373
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Verify and decode a presigned token. Throws on invalid signature or
|
|
376
|
+
* expiration.
|
|
377
|
+
*/
|
|
378
|
+
verifyToken(token, expectedOp) {
|
|
379
|
+
const [b64, sig] = token.split(".");
|
|
380
|
+
if (!b64 || !sig) throw new Error("Invalid storage token format");
|
|
381
|
+
const expected = createHmac("sha256", this.signingSecret).update(b64).digest("base64url");
|
|
382
|
+
if (expected !== sig) throw new Error("Invalid storage token signature");
|
|
383
|
+
let payload;
|
|
384
|
+
try {
|
|
385
|
+
payload = JSON.parse(Buffer.from(b64, "base64url").toString("utf8"));
|
|
386
|
+
} catch {
|
|
387
|
+
throw new Error("Malformed storage token payload");
|
|
388
|
+
}
|
|
389
|
+
if (payload.op !== expectedOp) {
|
|
390
|
+
throw new Error(`Storage token op mismatch (expected="${expectedOp}", actual="${payload.op}")`);
|
|
391
|
+
}
|
|
392
|
+
if (Date.now() / 1e3 > payload.exp) {
|
|
393
|
+
throw new Error("Storage token expired");
|
|
394
|
+
}
|
|
395
|
+
return payload;
|
|
396
|
+
}
|
|
397
|
+
async getPresignedUpload(key, expiresIn, options) {
|
|
398
|
+
const exp = Math.floor(Date.now() / 1e3) + Math.max(1, expiresIn);
|
|
399
|
+
const token = this.signToken({ k: key, ct: options?.contentType, exp, op: "put" });
|
|
400
|
+
return {
|
|
401
|
+
uploadUrl: `${this.baseUrl}${this.basePath}/_local/raw/${token}`,
|
|
402
|
+
method: "PUT",
|
|
403
|
+
headers: options?.contentType ? { "content-type": options.contentType } : { "content-type": "application/octet-stream" },
|
|
404
|
+
expiresIn,
|
|
405
|
+
downloadUrl: `${this.baseUrl}${this.basePath}/_local/file/${encodeURIComponent(key)}`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
async getPresignedDownload(key, expiresIn) {
|
|
409
|
+
const exp = Math.floor(Date.now() / 1e3) + Math.max(1, expiresIn);
|
|
410
|
+
const token = this.signToken({ k: key, exp, op: "get" });
|
|
411
|
+
return {
|
|
412
|
+
downloadUrl: `${this.baseUrl}${this.basePath}/_local/raw/${token}`,
|
|
413
|
+
expiresIn
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
async getSignedUrl(key, expiresIn) {
|
|
417
|
+
const desc = await this.getPresignedDownload(key, expiresIn);
|
|
418
|
+
return desc.downloadUrl;
|
|
419
|
+
}
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Chunked / multipart upload
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
async initiateChunkedUpload(key, options) {
|
|
424
|
+
const uploadId = randomUUID().replace(/-/g, "");
|
|
425
|
+
const dir = join(this.partsDir, uploadId);
|
|
426
|
+
await fs.mkdir(dir, { recursive: true });
|
|
427
|
+
const meta = {
|
|
428
|
+
key,
|
|
429
|
+
contentType: options?.contentType,
|
|
430
|
+
metadata: options?.metadata,
|
|
431
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
432
|
+
};
|
|
433
|
+
await fs.writeFile(join(dir, "_meta.json"), JSON.stringify(meta), "utf8");
|
|
434
|
+
return uploadId;
|
|
435
|
+
}
|
|
436
|
+
async uploadChunk(uploadId, partNumber, data) {
|
|
437
|
+
if (!Number.isInteger(partNumber) || partNumber < 1) {
|
|
438
|
+
throw new Error(`uploadChunk: partNumber must be a positive integer (got ${partNumber})`);
|
|
87
439
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
440
|
+
const partPath = this.resolvePartPath(uploadId, partNumber);
|
|
441
|
+
await fs.mkdir(dirname(partPath), { recursive: true });
|
|
442
|
+
await fs.writeFile(partPath, data);
|
|
443
|
+
const { createHash } = await import("crypto");
|
|
444
|
+
return createHash("md5").update(data).digest("hex");
|
|
445
|
+
}
|
|
446
|
+
async completeChunkedUpload(uploadId, parts) {
|
|
447
|
+
const dir = join(this.partsDir, uploadId);
|
|
448
|
+
let meta = {};
|
|
449
|
+
try {
|
|
450
|
+
meta = JSON.parse(await fs.readFile(join(dir, "_meta.json"), "utf8"));
|
|
451
|
+
} catch {
|
|
452
|
+
throw new Error(`Upload session "${uploadId}" not found`);
|
|
453
|
+
}
|
|
454
|
+
const targetKey = meta.key;
|
|
455
|
+
if (!targetKey) {
|
|
456
|
+
throw new Error(`Upload session "${uploadId}" missing target key`);
|
|
457
|
+
}
|
|
458
|
+
const sortedParts = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
459
|
+
const finalPath = this.resolvePath(targetKey);
|
|
460
|
+
await fs.mkdir(dirname(finalPath), { recursive: true });
|
|
461
|
+
const out = createWriteStream(finalPath);
|
|
462
|
+
try {
|
|
463
|
+
for (const p of sortedParts) {
|
|
464
|
+
const partPath = this.resolvePartPath(uploadId, p.partNumber);
|
|
465
|
+
await new Promise((resolve, reject) => {
|
|
466
|
+
const inp = createReadStream(partPath);
|
|
467
|
+
inp.on("error", reject);
|
|
468
|
+
inp.on("end", () => resolve());
|
|
469
|
+
inp.pipe(out, { end: false });
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
await new Promise((resolve) => out.end(() => resolve()));
|
|
474
|
+
}
|
|
475
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
476
|
+
return targetKey;
|
|
477
|
+
}
|
|
478
|
+
async abortChunkedUpload(uploadId) {
|
|
479
|
+
await fs.rm(join(this.partsDir, uploadId), { recursive: true, force: true });
|
|
92
480
|
}
|
|
93
481
|
};
|
|
94
482
|
|
|
95
|
-
// src/
|
|
96
|
-
var
|
|
97
|
-
constructor(
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
483
|
+
// src/metadata-store.ts
|
|
484
|
+
var StorageMetadataStore = class {
|
|
485
|
+
constructor(engine) {
|
|
486
|
+
this.engine = engine;
|
|
487
|
+
this.files = /* @__PURE__ */ new Map();
|
|
488
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
100
489
|
}
|
|
101
|
-
|
|
102
|
-
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Files
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
async createFile(rec) {
|
|
494
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
495
|
+
const full = { created_at: now, updated_at: now, ...rec };
|
|
496
|
+
this.files.set(full.id, full);
|
|
497
|
+
if (this.engine) {
|
|
498
|
+
try {
|
|
499
|
+
await this.engine.insert("system_file", full);
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return full;
|
|
103
504
|
}
|
|
104
|
-
async
|
|
105
|
-
|
|
505
|
+
async getFile(id) {
|
|
506
|
+
if (this.engine) {
|
|
507
|
+
try {
|
|
508
|
+
const found = await this.engine.findOne("system_file", { where: { id } });
|
|
509
|
+
if (found) return found;
|
|
510
|
+
} catch {
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return this.files.get(id) ?? null;
|
|
106
514
|
}
|
|
107
|
-
async
|
|
108
|
-
|
|
515
|
+
async updateFile(id, patch) {
|
|
516
|
+
const existing = await this.getFile(id);
|
|
517
|
+
if (!existing) return null;
|
|
518
|
+
const merged = { ...existing, ...patch, id, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
519
|
+
this.files.set(id, merged);
|
|
520
|
+
if (this.engine) {
|
|
521
|
+
try {
|
|
522
|
+
await this.engine.update("system_file", merged, { where: { id } });
|
|
523
|
+
} catch {
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return merged;
|
|
109
527
|
}
|
|
110
|
-
async
|
|
111
|
-
|
|
528
|
+
async deleteFile(id) {
|
|
529
|
+
this.files.delete(id);
|
|
530
|
+
if (this.engine) {
|
|
531
|
+
try {
|
|
532
|
+
await this.engine.delete("system_file", { where: { id } });
|
|
533
|
+
} catch {
|
|
534
|
+
}
|
|
535
|
+
}
|
|
112
536
|
}
|
|
113
|
-
|
|
114
|
-
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Upload sessions
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
async createSession(rec) {
|
|
541
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
542
|
+
const full = {
|
|
543
|
+
uploaded_chunks: 0,
|
|
544
|
+
uploaded_size: 0,
|
|
545
|
+
parts: "[]",
|
|
546
|
+
started_at: now,
|
|
547
|
+
updated_at: now,
|
|
548
|
+
...rec
|
|
549
|
+
};
|
|
550
|
+
this.sessions.set(full.id, full);
|
|
551
|
+
if (this.engine) {
|
|
552
|
+
try {
|
|
553
|
+
await this.engine.insert("system_upload_session", full);
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return full;
|
|
115
558
|
}
|
|
116
|
-
async
|
|
117
|
-
|
|
559
|
+
async getSession(id) {
|
|
560
|
+
if (this.engine) {
|
|
561
|
+
try {
|
|
562
|
+
const found = await this.engine.findOne("system_upload_session", { where: { id } });
|
|
563
|
+
if (found) return found;
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return this.sessions.get(id) ?? null;
|
|
118
568
|
}
|
|
119
|
-
async
|
|
120
|
-
|
|
569
|
+
async updateSession(id, patch) {
|
|
570
|
+
const existing = await this.getSession(id);
|
|
571
|
+
if (!existing) return null;
|
|
572
|
+
const merged = {
|
|
573
|
+
...existing,
|
|
574
|
+
...patch,
|
|
575
|
+
id,
|
|
576
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
577
|
+
};
|
|
578
|
+
this.sessions.set(id, merged);
|
|
579
|
+
if (this.engine) {
|
|
580
|
+
try {
|
|
581
|
+
await this.engine.update("system_upload_session", merged, { where: { id } });
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return merged;
|
|
121
586
|
}
|
|
122
|
-
async
|
|
123
|
-
|
|
587
|
+
async deleteSession(id) {
|
|
588
|
+
this.sessions.delete(id);
|
|
589
|
+
if (this.engine) {
|
|
590
|
+
try {
|
|
591
|
+
await this.engine.delete("system_upload_session", { where: { id } });
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
}
|
|
124
595
|
}
|
|
125
|
-
|
|
126
|
-
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// src/storage-routes.ts
|
|
599
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
600
|
+
function registerStorageRoutes(httpServer, storage, store, opts = {}) {
|
|
601
|
+
const basePath = opts.basePath ?? "/api/v1/storage";
|
|
602
|
+
const presignedTtl = opts.presignedTtl ?? 3600;
|
|
603
|
+
const sessionTtl = opts.sessionTtl ?? 86400;
|
|
604
|
+
httpServer.post(`${basePath}/upload/presigned`, async (req, res) => {
|
|
605
|
+
try {
|
|
606
|
+
const { filename, mimeType, size, scope, bucket } = req.body ?? {};
|
|
607
|
+
if (!filename || !mimeType || size == null) {
|
|
608
|
+
res.status(400).json({ error: "filename, mimeType, and size are required" });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const fileId = randomUUID2();
|
|
612
|
+
const key = buildKey(scope ?? "user", fileId, filename);
|
|
613
|
+
await store.createFile({
|
|
614
|
+
id: fileId,
|
|
615
|
+
key,
|
|
616
|
+
name: filename,
|
|
617
|
+
mime_type: mimeType,
|
|
618
|
+
size,
|
|
619
|
+
scope: scope ?? "user",
|
|
620
|
+
bucket,
|
|
621
|
+
acl: "private",
|
|
622
|
+
status: "pending"
|
|
623
|
+
});
|
|
624
|
+
let uploadUrl;
|
|
625
|
+
let method = "PUT";
|
|
626
|
+
let headers = { "content-type": mimeType };
|
|
627
|
+
let expiresIn = presignedTtl;
|
|
628
|
+
if (storage.getPresignedUpload) {
|
|
629
|
+
const desc = await storage.getPresignedUpload(key, presignedTtl, { contentType: mimeType });
|
|
630
|
+
uploadUrl = desc.uploadUrl;
|
|
631
|
+
method = desc.method;
|
|
632
|
+
if (desc.headers) headers = desc.headers;
|
|
633
|
+
expiresIn = desc.expiresIn;
|
|
634
|
+
} else {
|
|
635
|
+
uploadUrl = `${basePath}/_local/raw/${fileId}`;
|
|
636
|
+
}
|
|
637
|
+
res.json({
|
|
638
|
+
data: {
|
|
639
|
+
uploadUrl,
|
|
640
|
+
method,
|
|
641
|
+
headers,
|
|
642
|
+
fileId,
|
|
643
|
+
expiresIn,
|
|
644
|
+
downloadUrl: `${basePath}/files/${fileId}/url`
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
} catch (err) {
|
|
648
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
httpServer.post(`${basePath}/upload/complete`, async (req, res) => {
|
|
652
|
+
try {
|
|
653
|
+
const { fileId, eTag } = req.body ?? {};
|
|
654
|
+
if (!fileId) {
|
|
655
|
+
res.status(400).json({ error: "fileId is required" });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const file = await store.getFile(fileId);
|
|
659
|
+
if (!file) {
|
|
660
|
+
res.status(404).json({ error: "File not found" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const updated = await store.updateFile(fileId, {
|
|
664
|
+
status: "committed",
|
|
665
|
+
etag: eTag ?? void 0
|
|
666
|
+
});
|
|
667
|
+
res.json({
|
|
668
|
+
data: {
|
|
669
|
+
path: updated.key,
|
|
670
|
+
name: updated.name,
|
|
671
|
+
size: updated.size ?? 0,
|
|
672
|
+
mimeType: updated.mime_type ?? "application/octet-stream",
|
|
673
|
+
lastModified: updated.updated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
674
|
+
created: updated.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
675
|
+
etag: updated.etag
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
} catch (err) {
|
|
679
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
httpServer.post(`${basePath}/upload/chunked`, async (req, res) => {
|
|
683
|
+
try {
|
|
684
|
+
const { filename, mimeType, totalSize, chunkSize: reqChunkSize, scope, bucket, metadata } = req.body ?? {};
|
|
685
|
+
if (!filename || !mimeType || !totalSize) {
|
|
686
|
+
res.status(400).json({ error: "filename, mimeType, and totalSize are required" });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const chunkSize = Math.max(reqChunkSize ?? 5242880, 5242880);
|
|
690
|
+
const totalChunks = Math.ceil(totalSize / chunkSize);
|
|
691
|
+
const fileId = randomUUID2();
|
|
692
|
+
const key = buildKey(scope ?? "user", fileId, filename);
|
|
693
|
+
await store.createFile({
|
|
694
|
+
id: fileId,
|
|
695
|
+
key,
|
|
696
|
+
name: filename,
|
|
697
|
+
mime_type: mimeType,
|
|
698
|
+
size: totalSize,
|
|
699
|
+
scope: scope ?? "user",
|
|
700
|
+
bucket,
|
|
701
|
+
acl: "private",
|
|
702
|
+
status: "pending",
|
|
703
|
+
metadata: metadata ? JSON.stringify(metadata) : void 0
|
|
704
|
+
});
|
|
705
|
+
let backendUploadId;
|
|
706
|
+
if (storage.initiateChunkedUpload) {
|
|
707
|
+
backendUploadId = await storage.initiateChunkedUpload(key, { contentType: mimeType, metadata });
|
|
708
|
+
if ("setUploadKey" in storage && typeof storage.setUploadKey === "function") {
|
|
709
|
+
storage.setUploadKey(backendUploadId, key);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const uploadId = backendUploadId ?? randomUUID2().replace(/-/g, "");
|
|
713
|
+
const resumeToken = randomUUID2();
|
|
714
|
+
const expiresAt = new Date(Date.now() + sessionTtl * 1e3).toISOString();
|
|
715
|
+
await store.createSession({
|
|
716
|
+
id: uploadId,
|
|
717
|
+
file_id: fileId,
|
|
718
|
+
key,
|
|
719
|
+
filename,
|
|
720
|
+
mime_type: mimeType,
|
|
721
|
+
total_size: totalSize,
|
|
722
|
+
chunk_size: chunkSize,
|
|
723
|
+
total_chunks: totalChunks,
|
|
724
|
+
resume_token: resumeToken,
|
|
725
|
+
backend_upload_id: backendUploadId,
|
|
726
|
+
scope: scope ?? "user",
|
|
727
|
+
bucket,
|
|
728
|
+
metadata: metadata ? JSON.stringify(metadata) : void 0,
|
|
729
|
+
status: "in_progress",
|
|
730
|
+
expires_at: expiresAt
|
|
731
|
+
});
|
|
732
|
+
res.json({
|
|
733
|
+
data: {
|
|
734
|
+
uploadId,
|
|
735
|
+
resumeToken,
|
|
736
|
+
fileId,
|
|
737
|
+
totalChunks,
|
|
738
|
+
chunkSize,
|
|
739
|
+
expiresAt
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
} catch (err) {
|
|
743
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
httpServer.put(`${basePath}/upload/chunked/:uploadId/chunk/:chunkIndex`, async (req, res) => {
|
|
747
|
+
try {
|
|
748
|
+
const { uploadId, chunkIndex: chunkIndexStr } = req.params;
|
|
749
|
+
const chunkIndex = parseInt(chunkIndexStr, 10);
|
|
750
|
+
if (!uploadId || isNaN(chunkIndex)) {
|
|
751
|
+
res.status(400).json({ error: "uploadId and chunkIndex are required" });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const session = await store.getSession(uploadId);
|
|
755
|
+
if (!session) {
|
|
756
|
+
res.status(404).json({ error: "Upload session not found" });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const token = req.headers["x-resume-token"] ?? "";
|
|
760
|
+
if (session.resume_token && token !== session.resume_token) {
|
|
761
|
+
res.status(403).json({ error: "Invalid resume token" });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
let data;
|
|
765
|
+
if (req.rawBody) {
|
|
766
|
+
data = await req.rawBody();
|
|
767
|
+
} else if (Buffer.isBuffer(req.body)) {
|
|
768
|
+
data = req.body;
|
|
769
|
+
} else if (req.body instanceof ArrayBuffer) {
|
|
770
|
+
data = Buffer.from(req.body);
|
|
771
|
+
} else {
|
|
772
|
+
res.status(400).json({ error: "Binary body required" });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
let eTag = "";
|
|
776
|
+
if (storage.uploadChunk) {
|
|
777
|
+
eTag = await storage.uploadChunk(uploadId, chunkIndex + 1, data);
|
|
778
|
+
}
|
|
779
|
+
const currentParts = JSON.parse(session.parts ?? "[]");
|
|
780
|
+
currentParts.push({ chunkIndex, eTag });
|
|
781
|
+
const uploadedChunks = (session.uploaded_chunks ?? 0) + 1;
|
|
782
|
+
const uploadedSize = (session.uploaded_size ?? 0) + data.byteLength;
|
|
783
|
+
await store.updateSession(uploadId, {
|
|
784
|
+
uploaded_chunks: uploadedChunks,
|
|
785
|
+
uploaded_size: uploadedSize,
|
|
786
|
+
parts: JSON.stringify(currentParts)
|
|
787
|
+
});
|
|
788
|
+
res.json({
|
|
789
|
+
data: {
|
|
790
|
+
chunkIndex,
|
|
791
|
+
eTag,
|
|
792
|
+
bytesReceived: data.byteLength
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
} catch (err) {
|
|
796
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
httpServer.post(`${basePath}/upload/chunked/:uploadId/complete`, async (req, res) => {
|
|
800
|
+
try {
|
|
801
|
+
const { uploadId } = req.params;
|
|
802
|
+
const session = await store.getSession(uploadId);
|
|
803
|
+
if (!session) {
|
|
804
|
+
res.status(404).json({ error: "Upload session not found" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
await store.updateSession(uploadId, { status: "completing" });
|
|
808
|
+
const partsFromBody = req.body?.parts ?? [];
|
|
809
|
+
const partsForBackend = partsFromBody.map((p) => ({
|
|
810
|
+
partNumber: p.chunkIndex + 1,
|
|
811
|
+
eTag: p.eTag
|
|
812
|
+
}));
|
|
813
|
+
let finalKey = session.key;
|
|
814
|
+
if (storage.completeChunkedUpload) {
|
|
815
|
+
finalKey = await storage.completeChunkedUpload(uploadId, partsForBackend);
|
|
816
|
+
}
|
|
817
|
+
await store.updateFile(session.file_id, { status: "committed", key: finalKey });
|
|
818
|
+
await store.updateSession(uploadId, { status: "completed" });
|
|
819
|
+
res.json({
|
|
820
|
+
data: {
|
|
821
|
+
fileId: session.file_id,
|
|
822
|
+
key: finalKey,
|
|
823
|
+
size: session.total_size,
|
|
824
|
+
mimeType: session.mime_type ?? "application/octet-stream",
|
|
825
|
+
url: `${basePath}/files/${session.file_id}/url`
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
} catch (err) {
|
|
829
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
httpServer.get(`${basePath}/upload/chunked/:uploadId/progress`, async (req, res) => {
|
|
833
|
+
try {
|
|
834
|
+
const { uploadId } = req.params;
|
|
835
|
+
const session = await store.getSession(uploadId);
|
|
836
|
+
if (!session) {
|
|
837
|
+
res.status(404).json({ error: "Upload session not found" });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const uploadedChunks = session.uploaded_chunks ?? 0;
|
|
841
|
+
const uploadedSize = session.uploaded_size ?? 0;
|
|
842
|
+
const percentComplete = session.total_size > 0 ? Math.min(100, Math.round(uploadedSize / session.total_size * 100)) : 0;
|
|
843
|
+
res.json({
|
|
844
|
+
data: {
|
|
845
|
+
uploadId: session.id,
|
|
846
|
+
fileId: session.file_id,
|
|
847
|
+
filename: session.filename,
|
|
848
|
+
totalSize: session.total_size,
|
|
849
|
+
uploadedSize,
|
|
850
|
+
totalChunks: session.total_chunks,
|
|
851
|
+
uploadedChunks,
|
|
852
|
+
percentComplete,
|
|
853
|
+
status: session.status,
|
|
854
|
+
startedAt: session.started_at,
|
|
855
|
+
expiresAt: session.expires_at
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
} catch (err) {
|
|
859
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
httpServer.get(`${basePath}/files/:fileId/url`, async (req, res) => {
|
|
863
|
+
try {
|
|
864
|
+
const { fileId } = req.params;
|
|
865
|
+
const file = await store.getFile(fileId);
|
|
866
|
+
if (!file || file.status !== "committed") {
|
|
867
|
+
res.status(404).json({ error: "File not found or not committed" });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
let url;
|
|
871
|
+
if (storage.getPresignedDownload) {
|
|
872
|
+
const desc = await storage.getPresignedDownload(file.key, presignedTtl);
|
|
873
|
+
url = desc.downloadUrl;
|
|
874
|
+
} else if (storage.getSignedUrl) {
|
|
875
|
+
url = await storage.getSignedUrl(file.key, presignedTtl);
|
|
876
|
+
} else {
|
|
877
|
+
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
|
|
878
|
+
}
|
|
879
|
+
res.json({ url });
|
|
880
|
+
} catch (err) {
|
|
881
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
httpServer.put(`${basePath}/_local/raw/:token`, async (req, res) => {
|
|
885
|
+
try {
|
|
886
|
+
const { token } = req.params;
|
|
887
|
+
const localAdapter = storage;
|
|
888
|
+
if (!localAdapter.verifyToken) {
|
|
889
|
+
res.status(501).json({ error: "Presigned raw upload not supported by this adapter" });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const payload = localAdapter.verifyToken(token, "put");
|
|
893
|
+
let data;
|
|
894
|
+
if (req.rawBody) {
|
|
895
|
+
data = await req.rawBody();
|
|
896
|
+
} else if (Buffer.isBuffer(req.body)) {
|
|
897
|
+
data = req.body;
|
|
898
|
+
} else {
|
|
899
|
+
res.status(400).json({ error: "Binary body required" });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await storage.upload(payload.k, data, { contentType: payload.ct });
|
|
903
|
+
res.json({ ok: true, key: payload.k });
|
|
904
|
+
} catch (err) {
|
|
905
|
+
const statusCode = err.message?.includes("expired") || err.message?.includes("signature") ? 403 : 500;
|
|
906
|
+
res.status(statusCode).json({ error: err.message ?? "Upload failed" });
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
httpServer.get(`${basePath}/_local/raw/:token`, async (req, res) => {
|
|
910
|
+
try {
|
|
911
|
+
const { token } = req.params;
|
|
912
|
+
const localAdapter = storage;
|
|
913
|
+
if (!localAdapter.verifyToken) {
|
|
914
|
+
res.status(501).json({ error: "Presigned download not supported by this adapter" });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const payload = localAdapter.verifyToken(token, "get");
|
|
918
|
+
const data = await storage.download(payload.k);
|
|
919
|
+
res.header("content-type", payload.ct ?? "application/octet-stream");
|
|
920
|
+
res.header("content-length", String(data.byteLength));
|
|
921
|
+
res.send(data);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
const statusCode = err.message?.includes("expired") || err.message?.includes("signature") ? 403 : 500;
|
|
924
|
+
res.status(statusCode).json({ error: err.message ?? "Download failed" });
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function buildKey(scope, fileId, filename) {
|
|
929
|
+
const ext = filename.includes(".") ? "." + filename.split(".").pop() : "";
|
|
930
|
+
return `${scope}/${fileId}${ext}`;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/objects/system-file.object.ts
|
|
934
|
+
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
935
|
+
var SystemFile = ObjectSchema.create({
|
|
936
|
+
name: "system_file",
|
|
937
|
+
label: "System File",
|
|
938
|
+
pluralLabel: "System Files",
|
|
939
|
+
icon: "file",
|
|
940
|
+
description: "Storage service file metadata (fileId \u2194 key mapping)",
|
|
941
|
+
titleFormat: "{name}",
|
|
942
|
+
compactLayout: ["name", "mime_type", "size", "status", "created_at"],
|
|
943
|
+
fields: {
|
|
944
|
+
id: Field.text({
|
|
945
|
+
label: "File ID",
|
|
946
|
+
required: true,
|
|
947
|
+
readonly: true
|
|
948
|
+
}),
|
|
949
|
+
key: Field.text({
|
|
950
|
+
label: "Storage Key",
|
|
951
|
+
required: true,
|
|
952
|
+
searchable: true
|
|
953
|
+
}),
|
|
954
|
+
name: Field.text({
|
|
955
|
+
label: "File Name",
|
|
956
|
+
required: true,
|
|
957
|
+
searchable: true
|
|
958
|
+
}),
|
|
959
|
+
mime_type: Field.text({
|
|
960
|
+
label: "MIME Type"
|
|
961
|
+
}),
|
|
962
|
+
size: Field.number({
|
|
963
|
+
label: "Size (bytes)"
|
|
964
|
+
}),
|
|
965
|
+
scope: Field.select({
|
|
966
|
+
label: "Scope",
|
|
967
|
+
options: [
|
|
968
|
+
{ label: "User", value: "user" },
|
|
969
|
+
{ label: "Tenant", value: "tenant" },
|
|
970
|
+
{ label: "Public", value: "public" },
|
|
971
|
+
{ label: "Private", value: "private" },
|
|
972
|
+
{ label: "Temp", value: "temp" }
|
|
973
|
+
]
|
|
974
|
+
}),
|
|
975
|
+
bucket: Field.text({
|
|
976
|
+
label: "Bucket"
|
|
977
|
+
}),
|
|
978
|
+
acl: Field.select({
|
|
979
|
+
label: "ACL",
|
|
980
|
+
options: [
|
|
981
|
+
{ label: "Private", value: "private" },
|
|
982
|
+
{ label: "Public Read", value: "public-read" }
|
|
983
|
+
]
|
|
984
|
+
}),
|
|
985
|
+
status: Field.select({
|
|
986
|
+
label: "Status",
|
|
987
|
+
required: true,
|
|
988
|
+
options: [
|
|
989
|
+
{ label: "Pending Upload", value: "pending" },
|
|
990
|
+
{ label: "Committed", value: "committed" },
|
|
991
|
+
{ label: "Deleted", value: "deleted" }
|
|
992
|
+
]
|
|
993
|
+
}),
|
|
994
|
+
etag: Field.text({
|
|
995
|
+
label: "ETag"
|
|
996
|
+
}),
|
|
997
|
+
owner_id: Field.text({
|
|
998
|
+
label: "Owner ID"
|
|
999
|
+
}),
|
|
1000
|
+
metadata: Field.text({
|
|
1001
|
+
label: "Metadata (JSON)"
|
|
1002
|
+
}),
|
|
1003
|
+
created_at: Field.datetime({
|
|
1004
|
+
label: "Created At"
|
|
1005
|
+
}),
|
|
1006
|
+
updated_at: Field.datetime({
|
|
1007
|
+
label: "Updated At"
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// src/objects/system-upload-session.object.ts
|
|
1013
|
+
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
1014
|
+
var SystemUploadSession = ObjectSchema2.create({
|
|
1015
|
+
name: "system_upload_session",
|
|
1016
|
+
label: "System Upload Session",
|
|
1017
|
+
pluralLabel: "System Upload Sessions",
|
|
1018
|
+
icon: "upload-cloud",
|
|
1019
|
+
description: "Resumable multipart upload sessions tracked by service-storage",
|
|
1020
|
+
titleFormat: "{filename}",
|
|
1021
|
+
compactLayout: ["filename", "status", "uploaded_chunks", "total_chunks", "expires_at"],
|
|
1022
|
+
fields: {
|
|
1023
|
+
id: Field2.text({
|
|
1024
|
+
label: "Upload Session ID",
|
|
1025
|
+
required: true,
|
|
1026
|
+
readonly: true
|
|
1027
|
+
}),
|
|
1028
|
+
file_id: Field2.text({
|
|
1029
|
+
label: "File ID",
|
|
1030
|
+
required: true
|
|
1031
|
+
}),
|
|
1032
|
+
key: Field2.text({
|
|
1033
|
+
label: "Storage Key",
|
|
1034
|
+
required: true
|
|
1035
|
+
}),
|
|
1036
|
+
filename: Field2.text({
|
|
1037
|
+
label: "Filename",
|
|
1038
|
+
required: true
|
|
1039
|
+
}),
|
|
1040
|
+
mime_type: Field2.text({
|
|
1041
|
+
label: "MIME Type"
|
|
1042
|
+
}),
|
|
1043
|
+
total_size: Field2.number({
|
|
1044
|
+
label: "Total Size (bytes)",
|
|
1045
|
+
required: true
|
|
1046
|
+
}),
|
|
1047
|
+
chunk_size: Field2.number({
|
|
1048
|
+
label: "Chunk Size (bytes)",
|
|
1049
|
+
required: true
|
|
1050
|
+
}),
|
|
1051
|
+
total_chunks: Field2.number({
|
|
1052
|
+
label: "Total Chunks",
|
|
1053
|
+
required: true
|
|
1054
|
+
}),
|
|
1055
|
+
uploaded_chunks: Field2.number({
|
|
1056
|
+
label: "Uploaded Chunks"
|
|
1057
|
+
}),
|
|
1058
|
+
uploaded_size: Field2.number({
|
|
1059
|
+
label: "Uploaded Size (bytes)"
|
|
1060
|
+
}),
|
|
1061
|
+
parts: Field2.text({
|
|
1062
|
+
label: "Uploaded Parts (JSON)"
|
|
1063
|
+
}),
|
|
1064
|
+
resume_token: Field2.text({
|
|
1065
|
+
label: "Resume Token"
|
|
1066
|
+
}),
|
|
1067
|
+
backend_upload_id: Field2.text({
|
|
1068
|
+
label: "Backend Upload ID"
|
|
1069
|
+
}),
|
|
1070
|
+
scope: Field2.text({
|
|
1071
|
+
label: "Scope"
|
|
1072
|
+
}),
|
|
1073
|
+
bucket: Field2.text({
|
|
1074
|
+
label: "Bucket"
|
|
1075
|
+
}),
|
|
1076
|
+
metadata: Field2.text({
|
|
1077
|
+
label: "Metadata (JSON)"
|
|
1078
|
+
}),
|
|
1079
|
+
status: Field2.select({
|
|
1080
|
+
label: "Status",
|
|
1081
|
+
required: true,
|
|
1082
|
+
options: [
|
|
1083
|
+
{ label: "In Progress", value: "in_progress" },
|
|
1084
|
+
{ label: "Completing", value: "completing" },
|
|
1085
|
+
{ label: "Completed", value: "completed" },
|
|
1086
|
+
{ label: "Failed", value: "failed" },
|
|
1087
|
+
{ label: "Expired", value: "expired" }
|
|
1088
|
+
]
|
|
1089
|
+
}),
|
|
1090
|
+
started_at: Field2.datetime({
|
|
1091
|
+
label: "Started At"
|
|
1092
|
+
}),
|
|
1093
|
+
expires_at: Field2.datetime({
|
|
1094
|
+
label: "Expires At"
|
|
1095
|
+
}),
|
|
1096
|
+
updated_at: Field2.datetime({
|
|
1097
|
+
label: "Updated At"
|
|
1098
|
+
})
|
|
127
1099
|
}
|
|
128
|
-
|
|
129
|
-
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// src/storage-service-plugin.ts
|
|
1103
|
+
var StorageServicePlugin = class {
|
|
1104
|
+
constructor(options = {}) {
|
|
1105
|
+
this.name = "com.objectstack.service.storage";
|
|
1106
|
+
this.version = "1.0.0";
|
|
1107
|
+
this.type = "standard";
|
|
1108
|
+
this.storage = null;
|
|
1109
|
+
this.store = null;
|
|
1110
|
+
this.options = { adapter: "local", ...options };
|
|
1111
|
+
}
|
|
1112
|
+
async init(ctx) {
|
|
1113
|
+
const adapter = this.options.adapter;
|
|
1114
|
+
if (adapter === "s3") {
|
|
1115
|
+
const { S3StorageAdapter: S3StorageAdapter2 } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
|
|
1116
|
+
const s3Opts = this.options.s3;
|
|
1117
|
+
if (!s3Opts) {
|
|
1118
|
+
throw new Error('StorageServicePlugin: s3 options are required when adapter is "s3"');
|
|
1119
|
+
}
|
|
1120
|
+
this.storage = new S3StorageAdapter2(s3Opts);
|
|
1121
|
+
} else {
|
|
1122
|
+
const rootDir = this.options.local?.rootDir ?? "./storage";
|
|
1123
|
+
const basePath = this.options.basePath ?? "/api/v1/storage";
|
|
1124
|
+
this.storage = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
|
|
1125
|
+
}
|
|
1126
|
+
ctx.registerService("file-storage", this.storage);
|
|
1127
|
+
ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter`);
|
|
1128
|
+
try {
|
|
1129
|
+
ctx.getService("manifest").register({
|
|
1130
|
+
id: "com.objectstack.service.storage",
|
|
1131
|
+
name: "Storage Service",
|
|
1132
|
+
version: "1.0.0",
|
|
1133
|
+
type: "plugin",
|
|
1134
|
+
scope: "project",
|
|
1135
|
+
objects: [SystemFile, SystemUploadSession]
|
|
1136
|
+
});
|
|
1137
|
+
} catch {
|
|
1138
|
+
}
|
|
130
1139
|
}
|
|
131
|
-
async
|
|
132
|
-
|
|
1140
|
+
async start(ctx) {
|
|
1141
|
+
if (this.options.registerRoutes === false) return;
|
|
1142
|
+
ctx.hook("kernel:ready", async () => {
|
|
1143
|
+
let httpServer = null;
|
|
1144
|
+
try {
|
|
1145
|
+
httpServer = ctx.getService("http-server");
|
|
1146
|
+
} catch {
|
|
1147
|
+
}
|
|
1148
|
+
if (!httpServer || !this.storage) {
|
|
1149
|
+
ctx.logger.warn(
|
|
1150
|
+
'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
|
|
1151
|
+
);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
let engine = null;
|
|
1155
|
+
try {
|
|
1156
|
+
engine = ctx.getService("objectql");
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
this.store = new StorageMetadataStore(engine);
|
|
1160
|
+
registerStorageRoutes(httpServer, this.storage, this.store, {
|
|
1161
|
+
basePath: this.options.basePath ?? "/api/v1/storage",
|
|
1162
|
+
presignedTtl: this.options.presignedTtl,
|
|
1163
|
+
sessionTtl: this.options.sessionTtl
|
|
1164
|
+
});
|
|
1165
|
+
ctx.logger.info("StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage"));
|
|
1166
|
+
});
|
|
133
1167
|
}
|
|
134
1168
|
};
|
|
1169
|
+
|
|
1170
|
+
// src/index.ts
|
|
1171
|
+
init_s3_storage_adapter();
|
|
135
1172
|
export {
|
|
136
1173
|
LocalStorageAdapter,
|
|
137
1174
|
S3StorageAdapter,
|
|
138
|
-
|
|
1175
|
+
StorageMetadataStore,
|
|
1176
|
+
StorageServicePlugin,
|
|
1177
|
+
SystemFile,
|
|
1178
|
+
SystemUploadSession,
|
|
1179
|
+
registerStorageRoutes
|
|
139
1180
|
};
|
|
140
1181
|
//# sourceMappingURL=index.js.map
|