@objectstack/service-storage 4.0.5 → 4.1.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/README.md +36 -4
- package/dist/index.cjs +264 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +630 -17
- package/dist/index.d.ts +630 -17
- package/dist/index.js +263 -35
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Storage Service for ObjectStack — implements `IStorageService` with local file
|
|
|
7
7
|
- **Multiple Adapters**: Local filesystem (development) and S3-compatible storage (production)
|
|
8
8
|
- **Presigned Uploads**: Browser-direct upload via presigned URLs (S3 native, local HMAC-signed tokens)
|
|
9
9
|
- **Chunked / Multipart Upload**: Resumable large file uploads with progress tracking
|
|
10
|
-
- **File Metadata Store**: `
|
|
10
|
+
- **File Metadata Store**: `sys_file` object tracks fileId → key mapping and lifecycle status
|
|
11
11
|
- **REST Routes**: Auto-mounted `/api/v1/storage/*` endpoints consumed by `@objectstack/client`
|
|
12
12
|
- **Type-Safe**: Full TypeScript support with Zod-validated API contracts
|
|
13
13
|
|
|
@@ -125,7 +125,7 @@ const completed = await client.storage.resumeUpload(
|
|
|
125
125
|
▼ ▼
|
|
126
126
|
┌─────────────────┐ ┌─────────────────┐
|
|
127
127
|
│ MetadataStore │ │ Filesystem / S3 │
|
|
128
|
-
│ (
|
|
128
|
+
│ (sys_file) │ │ (actual bytes) │
|
|
129
129
|
└─────────────────┘ └─────────────────┘
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -133,9 +133,41 @@ const completed = await client.storage.resumeUpload(
|
|
|
133
133
|
|
|
134
134
|
The plugin registers two system objects via the manifest service:
|
|
135
135
|
|
|
136
|
-
- **`
|
|
137
|
-
- **`
|
|
136
|
+
- **`sys_file`** — File metadata (fileId, key, name, mimeType, size, scope, status)
|
|
137
|
+
- **`sys_upload_session`** — Chunked upload state (progress, parts, resumeToken)
|
|
138
138
|
|
|
139
139
|
## License
|
|
140
140
|
|
|
141
141
|
Apache-2.0
|
|
142
|
+
|
|
143
|
+
## UI-driven configuration
|
|
144
|
+
|
|
145
|
+
`StorageServicePlugin` registers a `storage` Settings namespace (mail-style)
|
|
146
|
+
so administrators can switch adapter, configure S3 credentials, and tune
|
|
147
|
+
TTL / max-upload limits from the Settings hub instead of restarting the
|
|
148
|
+
process.
|
|
149
|
+
|
|
150
|
+
- Service key in the kernel: `file-storage` — registered as a
|
|
151
|
+
`SwappableStorageService` proxy at `init` time. The inner adapter
|
|
152
|
+
(local FS or S3) is rebuilt and swapped in on every `settings:changed`
|
|
153
|
+
event for `namespace=storage`.
|
|
154
|
+
- The S3 secret key is stored encrypted in `sys_secret` via the
|
|
155
|
+
CryptoAdapter / KMS chain set up by `service-settings`.
|
|
156
|
+
- A `storage/test` action handler uploads → downloads → deletes a small
|
|
157
|
+
probe blob to validate the configuration end-to-end. The handler is
|
|
158
|
+
registered on `kernel:ready`; the `service-settings` package ships a
|
|
159
|
+
validation-only fallback for kernels that mount Settings but not
|
|
160
|
+
Storage.
|
|
161
|
+
|
|
162
|
+
### ⚠ Switching adapters does not migrate files
|
|
163
|
+
|
|
164
|
+
Files uploaded under the previous adapter remain on that backend and
|
|
165
|
+
become unreachable through the new one. The plugin logs a warning on
|
|
166
|
+
every swap. Migrate data out-of-band (e.g. `aws s3 sync` from the local
|
|
167
|
+
root to the new bucket) before flipping the toggle in production.
|
|
168
|
+
|
|
169
|
+
### Disabling the live-wire
|
|
170
|
+
|
|
171
|
+
Pass `bindToSettings: false` to keep the constructor-supplied adapter
|
|
172
|
+
frozen — useful in tests and in deployments where storage config must
|
|
173
|
+
come from env vars only.
|
package/dist/index.cjs
CHANGED
|
@@ -301,6 +301,7 @@ __export(index_exports, {
|
|
|
301
301
|
S3StorageAdapter: () => S3StorageAdapter,
|
|
302
302
|
StorageMetadataStore: () => StorageMetadataStore,
|
|
303
303
|
StorageServicePlugin: () => StorageServicePlugin,
|
|
304
|
+
SwappableStorageService: () => SwappableStorageService,
|
|
304
305
|
SystemFile: () => SystemFile,
|
|
305
306
|
SystemUploadSession: () => SystemUploadSession,
|
|
306
307
|
registerStorageRoutes: () => registerStorageRoutes
|
|
@@ -515,6 +516,9 @@ var LocalStorageAdapter = class {
|
|
|
515
516
|
}
|
|
516
517
|
};
|
|
517
518
|
|
|
519
|
+
// src/storage-service-plugin.ts
|
|
520
|
+
init_s3_storage_adapter();
|
|
521
|
+
|
|
518
522
|
// src/metadata-store.ts
|
|
519
523
|
var StorageMetadataStore = class {
|
|
520
524
|
constructor(engine) {
|
|
@@ -531,7 +535,7 @@ var StorageMetadataStore = class {
|
|
|
531
535
|
this.files.set(full.id, full);
|
|
532
536
|
if (this.engine) {
|
|
533
537
|
try {
|
|
534
|
-
await this.engine.insert("
|
|
538
|
+
await this.engine.insert("sys_file", full);
|
|
535
539
|
} catch {
|
|
536
540
|
}
|
|
537
541
|
}
|
|
@@ -540,7 +544,7 @@ var StorageMetadataStore = class {
|
|
|
540
544
|
async getFile(id) {
|
|
541
545
|
if (this.engine) {
|
|
542
546
|
try {
|
|
543
|
-
const found = await this.engine.findOne("
|
|
547
|
+
const found = await this.engine.findOne("sys_file", { where: { id } });
|
|
544
548
|
if (found) return found;
|
|
545
549
|
} catch {
|
|
546
550
|
}
|
|
@@ -554,7 +558,7 @@ var StorageMetadataStore = class {
|
|
|
554
558
|
this.files.set(id, merged);
|
|
555
559
|
if (this.engine) {
|
|
556
560
|
try {
|
|
557
|
-
await this.engine.update("
|
|
561
|
+
await this.engine.update("sys_file", merged, { where: { id } });
|
|
558
562
|
} catch {
|
|
559
563
|
}
|
|
560
564
|
}
|
|
@@ -564,7 +568,7 @@ var StorageMetadataStore = class {
|
|
|
564
568
|
this.files.delete(id);
|
|
565
569
|
if (this.engine) {
|
|
566
570
|
try {
|
|
567
|
-
await this.engine.delete("
|
|
571
|
+
await this.engine.delete("sys_file", { where: { id } });
|
|
568
572
|
} catch {
|
|
569
573
|
}
|
|
570
574
|
}
|
|
@@ -585,7 +589,7 @@ var StorageMetadataStore = class {
|
|
|
585
589
|
this.sessions.set(full.id, full);
|
|
586
590
|
if (this.engine) {
|
|
587
591
|
try {
|
|
588
|
-
await this.engine.insert("
|
|
592
|
+
await this.engine.insert("sys_upload_session", full);
|
|
589
593
|
} catch {
|
|
590
594
|
}
|
|
591
595
|
}
|
|
@@ -594,7 +598,7 @@ var StorageMetadataStore = class {
|
|
|
594
598
|
async getSession(id) {
|
|
595
599
|
if (this.engine) {
|
|
596
600
|
try {
|
|
597
|
-
const found = await this.engine.findOne("
|
|
601
|
+
const found = await this.engine.findOne("sys_upload_session", { where: { id } });
|
|
598
602
|
if (found) return found;
|
|
599
603
|
} catch {
|
|
600
604
|
}
|
|
@@ -613,7 +617,7 @@ var StorageMetadataStore = class {
|
|
|
613
617
|
this.sessions.set(id, merged);
|
|
614
618
|
if (this.engine) {
|
|
615
619
|
try {
|
|
616
|
-
await this.engine.update("
|
|
620
|
+
await this.engine.update("sys_upload_session", merged, { where: { id } });
|
|
617
621
|
} catch {
|
|
618
622
|
}
|
|
619
623
|
}
|
|
@@ -623,7 +627,7 @@ var StorageMetadataStore = class {
|
|
|
623
627
|
this.sessions.delete(id);
|
|
624
628
|
if (this.engine) {
|
|
625
629
|
try {
|
|
626
|
-
await this.engine.delete("
|
|
630
|
+
await this.engine.delete("sys_upload_session", { where: { id } });
|
|
627
631
|
} catch {
|
|
628
632
|
}
|
|
629
633
|
}
|
|
@@ -916,6 +920,28 @@ function registerStorageRoutes(httpServer, storage, store, opts = {}) {
|
|
|
916
920
|
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
917
921
|
}
|
|
918
922
|
});
|
|
923
|
+
httpServer.get(`${basePath}/files/:fileId`, async (req, res) => {
|
|
924
|
+
try {
|
|
925
|
+
const { fileId } = req.params;
|
|
926
|
+
const file = await store.getFile(fileId);
|
|
927
|
+
if (!file || file.status !== "committed") {
|
|
928
|
+
res.status(404).json({ error: "File not found or not committed" });
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
let url;
|
|
932
|
+
if (storage.getPresignedDownload) {
|
|
933
|
+
const desc = await storage.getPresignedDownload(file.key, presignedTtl);
|
|
934
|
+
url = desc.downloadUrl;
|
|
935
|
+
} else if (storage.getSignedUrl) {
|
|
936
|
+
url = await storage.getSignedUrl(file.key, presignedTtl);
|
|
937
|
+
} else {
|
|
938
|
+
url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
|
|
939
|
+
}
|
|
940
|
+
res.status(302).header("Location", url).send("");
|
|
941
|
+
} catch (err) {
|
|
942
|
+
res.status(500).json({ error: err.message ?? "Internal error" });
|
|
943
|
+
}
|
|
944
|
+
});
|
|
919
945
|
httpServer.put(`${basePath}/_local/raw/:token`, async (req, res) => {
|
|
920
946
|
try {
|
|
921
947
|
const { token } = req.params;
|
|
@@ -968,7 +994,7 @@ function buildKey(scope, fileId, filename) {
|
|
|
968
994
|
// src/objects/system-file.object.ts
|
|
969
995
|
var import_data = require("@objectstack/spec/data");
|
|
970
996
|
var SystemFile = import_data.ObjectSchema.create({
|
|
971
|
-
name: "
|
|
997
|
+
name: "sys_file",
|
|
972
998
|
label: "System File",
|
|
973
999
|
pluralLabel: "System Files",
|
|
974
1000
|
icon: "file",
|
|
@@ -1014,7 +1040,7 @@ var SystemFile = import_data.ObjectSchema.create({
|
|
|
1014
1040
|
label: "ACL",
|
|
1015
1041
|
options: [
|
|
1016
1042
|
{ label: "Private", value: "private" },
|
|
1017
|
-
{ label: "Public Read", value: "
|
|
1043
|
+
{ label: "Public Read", value: "public_read" }
|
|
1018
1044
|
]
|
|
1019
1045
|
}),
|
|
1020
1046
|
status: import_data.Field.select({
|
|
@@ -1047,7 +1073,7 @@ var SystemFile = import_data.ObjectSchema.create({
|
|
|
1047
1073
|
// src/objects/system-upload-session.object.ts
|
|
1048
1074
|
var import_data2 = require("@objectstack/spec/data");
|
|
1049
1075
|
var SystemUploadSession = import_data2.ObjectSchema.create({
|
|
1050
|
-
name: "
|
|
1076
|
+
name: "sys_upload_session",
|
|
1051
1077
|
label: "System Upload Session",
|
|
1052
1078
|
pluralLabel: "System Upload Sessions",
|
|
1053
1079
|
icon: "upload-cloud",
|
|
@@ -1134,6 +1160,103 @@ var SystemUploadSession = import_data2.ObjectSchema.create({
|
|
|
1134
1160
|
}
|
|
1135
1161
|
});
|
|
1136
1162
|
|
|
1163
|
+
// src/swappable-storage-service.ts
|
|
1164
|
+
var SwappableStorageService = class {
|
|
1165
|
+
constructor(initial, onSwap) {
|
|
1166
|
+
this.inner = initial;
|
|
1167
|
+
this.onSwap = onSwap;
|
|
1168
|
+
}
|
|
1169
|
+
/** Replace the inner adapter. */
|
|
1170
|
+
swap(next) {
|
|
1171
|
+
const previous = this.inner;
|
|
1172
|
+
this.inner = next;
|
|
1173
|
+
this.onSwap?.(previous, next);
|
|
1174
|
+
}
|
|
1175
|
+
/** Expose the active inner adapter — primarily for tests. */
|
|
1176
|
+
getInner() {
|
|
1177
|
+
return this.inner;
|
|
1178
|
+
}
|
|
1179
|
+
upload(key, data, options) {
|
|
1180
|
+
return this.inner.upload(key, data, options);
|
|
1181
|
+
}
|
|
1182
|
+
download(key) {
|
|
1183
|
+
return this.inner.download(key);
|
|
1184
|
+
}
|
|
1185
|
+
delete(key) {
|
|
1186
|
+
return this.inner.delete(key);
|
|
1187
|
+
}
|
|
1188
|
+
exists(key) {
|
|
1189
|
+
return this.inner.exists(key);
|
|
1190
|
+
}
|
|
1191
|
+
getInfo(key) {
|
|
1192
|
+
return this.inner.getInfo(key);
|
|
1193
|
+
}
|
|
1194
|
+
list(prefix) {
|
|
1195
|
+
if (typeof this.inner.list !== "function") {
|
|
1196
|
+
return Promise.reject(new Error("Active storage adapter does not support list()"));
|
|
1197
|
+
}
|
|
1198
|
+
return this.inner.list(prefix);
|
|
1199
|
+
}
|
|
1200
|
+
getSignedUrl(key, expiresIn) {
|
|
1201
|
+
if (typeof this.inner.getSignedUrl !== "function") {
|
|
1202
|
+
return Promise.reject(new Error("Active storage adapter does not support getSignedUrl()"));
|
|
1203
|
+
}
|
|
1204
|
+
return this.inner.getSignedUrl(key, expiresIn);
|
|
1205
|
+
}
|
|
1206
|
+
getPresignedUpload(key, expiresIn, options) {
|
|
1207
|
+
if (typeof this.inner.getPresignedUpload !== "function") {
|
|
1208
|
+
return Promise.reject(new Error("Active storage adapter does not support getPresignedUpload()"));
|
|
1209
|
+
}
|
|
1210
|
+
return this.inner.getPresignedUpload(key, expiresIn, options);
|
|
1211
|
+
}
|
|
1212
|
+
getPresignedDownload(key, expiresIn) {
|
|
1213
|
+
if (typeof this.inner.getPresignedDownload !== "function") {
|
|
1214
|
+
return Promise.reject(new Error("Active storage adapter does not support getPresignedDownload()"));
|
|
1215
|
+
}
|
|
1216
|
+
return this.inner.getPresignedDownload(key, expiresIn);
|
|
1217
|
+
}
|
|
1218
|
+
initiateChunkedUpload(key, options) {
|
|
1219
|
+
if (typeof this.inner.initiateChunkedUpload !== "function") {
|
|
1220
|
+
return Promise.reject(new Error("Active storage adapter does not support initiateChunkedUpload()"));
|
|
1221
|
+
}
|
|
1222
|
+
return this.inner.initiateChunkedUpload(key, options);
|
|
1223
|
+
}
|
|
1224
|
+
uploadChunk(uploadId, partNumber, data) {
|
|
1225
|
+
if (typeof this.inner.uploadChunk !== "function") {
|
|
1226
|
+
return Promise.reject(new Error("Active storage adapter does not support uploadChunk()"));
|
|
1227
|
+
}
|
|
1228
|
+
return this.inner.uploadChunk(uploadId, partNumber, data);
|
|
1229
|
+
}
|
|
1230
|
+
completeChunkedUpload(uploadId, parts) {
|
|
1231
|
+
if (typeof this.inner.completeChunkedUpload !== "function") {
|
|
1232
|
+
return Promise.reject(new Error("Active storage adapter does not support completeChunkedUpload()"));
|
|
1233
|
+
}
|
|
1234
|
+
return this.inner.completeChunkedUpload(uploadId, parts);
|
|
1235
|
+
}
|
|
1236
|
+
abortChunkedUpload(uploadId) {
|
|
1237
|
+
if (typeof this.inner.abortChunkedUpload !== "function") {
|
|
1238
|
+
return Promise.reject(new Error("Active storage adapter does not support abortChunkedUpload()"));
|
|
1239
|
+
}
|
|
1240
|
+
return this.inner.abortChunkedUpload(uploadId);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Verify a presigned HMAC token (LocalStorageAdapter-specific).
|
|
1244
|
+
*
|
|
1245
|
+
* `IStorageService` does not declare this method, but `storage-routes`
|
|
1246
|
+
* type-narrows the active storage to `LocalStorageAdapter` to handle the
|
|
1247
|
+
* `/_local/raw/:token` PUT and GET endpoints. Without a passthrough on
|
|
1248
|
+
* the swappable wrapper, the route sees `verifyToken === undefined` and
|
|
1249
|
+
* returns 501 even though the underlying local adapter supports it.
|
|
1250
|
+
*/
|
|
1251
|
+
verifyToken(token, expectedOp) {
|
|
1252
|
+
const inner = this.inner;
|
|
1253
|
+
if (typeof inner.verifyToken !== "function") {
|
|
1254
|
+
throw new Error("Active storage adapter does not support verifyToken()");
|
|
1255
|
+
}
|
|
1256
|
+
return inner.verifyToken(token, expectedOp);
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1137
1260
|
// src/storage-service-plugin.ts
|
|
1138
1261
|
var StorageServicePlugin = class {
|
|
1139
1262
|
constructor(options = {}) {
|
|
@@ -1144,22 +1267,57 @@ var StorageServicePlugin = class {
|
|
|
1144
1267
|
this.store = null;
|
|
1145
1268
|
this.options = { adapter: "local", ...options };
|
|
1146
1269
|
}
|
|
1270
|
+
/** Build a concrete adapter from a values map (settings-derived). */
|
|
1271
|
+
async buildAdapterFromValues(values) {
|
|
1272
|
+
const adapter = String(values.adapter ?? "local");
|
|
1273
|
+
if (adapter === "s3") {
|
|
1274
|
+
const bucket = values.s3_bucket;
|
|
1275
|
+
const region = values.s3_region;
|
|
1276
|
+
if (!bucket || !region) {
|
|
1277
|
+
throw new Error("StorageServicePlugin: S3 adapter requires s3_bucket and s3_region");
|
|
1278
|
+
}
|
|
1279
|
+
const opts = {
|
|
1280
|
+
bucket,
|
|
1281
|
+
region,
|
|
1282
|
+
endpoint: values.s3_endpoint || void 0,
|
|
1283
|
+
accessKeyId: values.s3_access_key_id || void 0,
|
|
1284
|
+
secretAccessKey: values.s3_secret_access_key || void 0,
|
|
1285
|
+
forcePathStyle: !!values.s3_force_path_style
|
|
1286
|
+
};
|
|
1287
|
+
return new S3StorageAdapter(opts);
|
|
1288
|
+
}
|
|
1289
|
+
const rootDir = values.local_root || "./storage";
|
|
1290
|
+
return new LocalStorageAdapter({
|
|
1291
|
+
basePath: this.options.basePath ?? "/api/v1/storage",
|
|
1292
|
+
...this.options.local ?? {},
|
|
1293
|
+
// settings value wins over any constructor-provided local.rootDir
|
|
1294
|
+
rootDir
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1147
1297
|
async init(ctx) {
|
|
1148
1298
|
const adapter = this.options.adapter;
|
|
1299
|
+
let initial;
|
|
1149
1300
|
if (adapter === "s3") {
|
|
1150
|
-
const { S3StorageAdapter:
|
|
1301
|
+
const { S3StorageAdapter: S3Ctor } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
|
|
1151
1302
|
const s3Opts = this.options.s3;
|
|
1152
1303
|
if (!s3Opts) {
|
|
1153
1304
|
throw new Error('StorageServicePlugin: s3 options are required when adapter is "s3"');
|
|
1154
1305
|
}
|
|
1155
|
-
|
|
1306
|
+
initial = new S3Ctor(s3Opts);
|
|
1156
1307
|
} else {
|
|
1157
1308
|
const rootDir = this.options.local?.rootDir ?? "./storage";
|
|
1158
1309
|
const basePath = this.options.basePath ?? "/api/v1/storage";
|
|
1159
|
-
|
|
1310
|
+
initial = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
|
|
1160
1311
|
}
|
|
1312
|
+
this.storage = new SwappableStorageService(initial, (prev, next) => {
|
|
1313
|
+
const prevName = prev?.constructor?.name ?? "unknown";
|
|
1314
|
+
const nextName = next?.constructor?.name ?? "unknown";
|
|
1315
|
+
ctx.logger.warn(
|
|
1316
|
+
`StorageServicePlugin: storage adapter swapped (${prevName} \u2192 ${nextName}). Existing files were NOT migrated and may be unreachable through the new adapter.`
|
|
1317
|
+
);
|
|
1318
|
+
});
|
|
1161
1319
|
ctx.registerService("file-storage", this.storage);
|
|
1162
|
-
ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter`);
|
|
1320
|
+
ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter (swappable)`);
|
|
1163
1321
|
try {
|
|
1164
1322
|
ctx.getService("manifest").register({
|
|
1165
1323
|
id: "com.objectstack.service.storage",
|
|
@@ -1173,31 +1331,101 @@ var StorageServicePlugin = class {
|
|
|
1173
1331
|
}
|
|
1174
1332
|
}
|
|
1175
1333
|
async start(ctx) {
|
|
1176
|
-
if (this.options.registerRoutes === false) return;
|
|
1177
1334
|
ctx.hook("kernel:ready", async () => {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1335
|
+
if (this.options.registerRoutes !== false) {
|
|
1336
|
+
let httpServer = null;
|
|
1337
|
+
try {
|
|
1338
|
+
httpServer = ctx.getService("http-server");
|
|
1339
|
+
} catch {
|
|
1340
|
+
}
|
|
1341
|
+
if (httpServer && this.storage) {
|
|
1342
|
+
let engine = null;
|
|
1343
|
+
try {
|
|
1344
|
+
engine = ctx.getService("objectql");
|
|
1345
|
+
} catch {
|
|
1346
|
+
}
|
|
1347
|
+
this.store = new StorageMetadataStore(engine);
|
|
1348
|
+
registerStorageRoutes(httpServer, this.storage, this.store, {
|
|
1349
|
+
basePath: this.options.basePath ?? "/api/v1/storage",
|
|
1350
|
+
presignedTtl: this.options.presignedTtl,
|
|
1351
|
+
sessionTtl: this.options.sessionTtl
|
|
1352
|
+
});
|
|
1353
|
+
ctx.logger.info(
|
|
1354
|
+
"StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage")
|
|
1355
|
+
);
|
|
1356
|
+
} else if (!httpServer) {
|
|
1357
|
+
ctx.logger.warn(
|
|
1358
|
+
'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1188
1361
|
}
|
|
1189
|
-
|
|
1362
|
+
if (this.options.bindToSettings === false) return;
|
|
1190
1363
|
try {
|
|
1191
|
-
|
|
1364
|
+
const settings = ctx.getService("settings");
|
|
1365
|
+
if (!settings || typeof settings.createClient !== "function") return;
|
|
1366
|
+
const applySettings = async () => {
|
|
1367
|
+
if (!this.storage) return;
|
|
1368
|
+
try {
|
|
1369
|
+
const payload = await settings.getNamespace("storage");
|
|
1370
|
+
const values = {};
|
|
1371
|
+
for (const [k, v] of Object.entries(payload.values)) {
|
|
1372
|
+
values[k] = v?.value;
|
|
1373
|
+
}
|
|
1374
|
+
const hasAny = Object.values(values).some((v) => v !== void 0 && v !== null && v !== "");
|
|
1375
|
+
if (!hasAny) return;
|
|
1376
|
+
const next = await this.buildAdapterFromValues(values);
|
|
1377
|
+
this.storage.swap(next);
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
ctx.logger.warn(
|
|
1380
|
+
"StorageServicePlugin: failed to apply storage settings: " + (err?.message ?? err)
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
await applySettings();
|
|
1385
|
+
if (typeof settings.subscribe === "function") {
|
|
1386
|
+
settings.subscribe("storage", () => {
|
|
1387
|
+
void applySettings();
|
|
1388
|
+
});
|
|
1389
|
+
ctx.logger.info("StorageServicePlugin: bound to settings:changed for namespace=storage");
|
|
1390
|
+
}
|
|
1391
|
+
if (typeof settings.registerAction === "function" && this.storage) {
|
|
1392
|
+
const proxy = this.storage;
|
|
1393
|
+
settings.registerAction("storage", "test", async ({ values }) => {
|
|
1394
|
+
const probeKey = `__objectstack_probe__/${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1395
|
+
const probeBytes = Buffer.from(`probe@${(/* @__PURE__ */ new Date()).toISOString()}`, "utf-8");
|
|
1396
|
+
try {
|
|
1397
|
+
let target = proxy;
|
|
1398
|
+
if (values && Object.keys(values).length > 0) {
|
|
1399
|
+
try {
|
|
1400
|
+
target = await this.buildAdapterFromValues(values);
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
return { ok: false, severity: "error", message: err?.message ?? String(err) };
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
await target.upload(probeKey, probeBytes, { contentType: "text/plain" });
|
|
1406
|
+
const got = await target.download(probeKey);
|
|
1407
|
+
if (!got || !Buffer.isBuffer(got) || got.toString("utf-8") !== probeBytes.toString("utf-8")) {
|
|
1408
|
+
return { ok: false, severity: "error", message: "Probe download did not match upload." };
|
|
1409
|
+
}
|
|
1410
|
+
await target.delete(probeKey);
|
|
1411
|
+
const adapter = String(values?.adapter ?? this.options.adapter ?? "local");
|
|
1412
|
+
return {
|
|
1413
|
+
ok: true,
|
|
1414
|
+
severity: "info",
|
|
1415
|
+
message: `Storage round-trip succeeded (adapter=${adapter}).`
|
|
1416
|
+
};
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
try {
|
|
1419
|
+
await proxy.delete(probeKey);
|
|
1420
|
+
} catch {
|
|
1421
|
+
}
|
|
1422
|
+
return { ok: false, severity: "error", message: err?.message ?? String(err) };
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
ctx.logger.info("StorageServicePlugin: registered settings action storage/test");
|
|
1426
|
+
}
|
|
1192
1427
|
} catch {
|
|
1193
1428
|
}
|
|
1194
|
-
this.store = new StorageMetadataStore(engine);
|
|
1195
|
-
registerStorageRoutes(httpServer, this.storage, this.store, {
|
|
1196
|
-
basePath: this.options.basePath ?? "/api/v1/storage",
|
|
1197
|
-
presignedTtl: this.options.presignedTtl,
|
|
1198
|
-
sessionTtl: this.options.sessionTtl
|
|
1199
|
-
});
|
|
1200
|
-
ctx.logger.info("StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage"));
|
|
1201
1429
|
});
|
|
1202
1430
|
}
|
|
1203
1431
|
};
|
|
@@ -1210,6 +1438,7 @@ init_s3_storage_adapter();
|
|
|
1210
1438
|
S3StorageAdapter,
|
|
1211
1439
|
StorageMetadataStore,
|
|
1212
1440
|
StorageServicePlugin,
|
|
1441
|
+
SwappableStorageService,
|
|
1213
1442
|
SystemFile,
|
|
1214
1443
|
SystemUploadSession,
|
|
1215
1444
|
registerStorageRoutes
|