@zucker-framework/storage 1.0.0 → 1.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/dist/index.mjs CHANGED
@@ -386,6 +386,7 @@ LocalFileStorage = _ts_decorate([
386
386
  import { Injectable as Injectable2, Logger as Logger2 } from "@nestjs/common";
387
387
  import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command, CopyObjectCommand } from "@aws-sdk/client-s3";
388
388
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
389
+ import { Readable } from "stream";
389
390
  function _ts_decorate2(decorators, target, key, desc) {
390
391
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
391
392
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") {
@@ -478,6 +479,9 @@ var S3Storage = class _S3Storage {
478
479
  if (!response.Body) {
479
480
  throw new Error("Stored object response had no body");
480
481
  }
482
+ if (!(response.Body instanceof Readable)) {
483
+ throw new Error("Stored object response must be a Node readable stream");
484
+ }
481
485
  return {
482
486
  stream: response.Body,
483
487
  size: response.ContentLength ?? 0
@@ -584,7 +588,8 @@ __name(encodeKeyPath, "encodeKeyPath");
584
588
  // src/webdav-storage.ts
585
589
  import { Injectable as Injectable3, Logger as Logger3 } from "@nestjs/common";
586
590
  import * as path2 from "path";
587
- import { Readable } from "stream";
591
+ import { Transform, addAbortSignal } from "stream";
592
+ import { pipeline, finished } from "stream/promises";
588
593
  function _ts_decorate3(decorators, target, key, desc) {
589
594
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
590
595
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") {
@@ -617,7 +622,7 @@ var WebDavStorage = class _WebDavStorage {
617
622
  providerType = "webdav";
618
623
  options;
619
624
  constructor(options) {
620
- if (!options.url || !options.username || !options.password) {
625
+ if (!options.url || !options.allowAnonymous && (!options.username || !options.password)) {
621
626
  throw new Error("WebDAV url, username and password are required");
622
627
  }
623
628
  this.options = options;
@@ -644,7 +649,7 @@ var WebDavStorage = class _WebDavStorage {
644
649
  const remotePath = this.resolvePath(key);
645
650
  const parentDir = remotePath.substring(0, remotePath.lastIndexOf("/"));
646
651
  if (parentDir && parentDir !== "/") {
647
- await this.ensureDirectory(parentDir);
652
+ await this.ensureRemoteDirectory(parentDir);
648
653
  }
649
654
  await client.putFileContents(remotePath, buffer, {
650
655
  contentLength: buffer.length,
@@ -660,21 +665,87 @@ var WebDavStorage = class _WebDavStorage {
660
665
  };
661
666
  }
662
667
  async uploadStream(stream, key, options) {
663
- const chunks = [];
664
- for await (const chunk of stream) {
665
- chunks.push(Buffer.from(chunk));
668
+ const controller = new AbortController();
669
+ const abort = /* @__PURE__ */ __name(() => controller.abort(options?.signal?.reason), "abort");
670
+ options?.signal?.addEventListener("abort", abort, {
671
+ once: true
672
+ });
673
+ if (options?.signal?.aborted) abort();
674
+ let size = 0;
675
+ const counter = new Transform({
676
+ transform(chunk, _encoding, callback) {
677
+ size += chunk.length;
678
+ callback(null, chunk);
679
+ }
680
+ });
681
+ let transfer;
682
+ let pumping;
683
+ const sourceError = /* @__PURE__ */ __name((error) => controller.abort(error), "sourceError");
684
+ stream.on("error", sourceError);
685
+ addAbortSignal(controller.signal, stream);
686
+ try {
687
+ controller.signal.throwIfAborted();
688
+ const client = await this.getClient();
689
+ const remotePath = this.resolvePath(key);
690
+ await this.ensureRemoteDirectory(path2.posix.dirname(remotePath), controller.signal);
691
+ controller.signal.throwIfAborted();
692
+ transfer = client.putFileContents(remotePath, counter, {
693
+ overwrite: true,
694
+ contentLength: false,
695
+ signal: controller.signal,
696
+ headers: options?.contentType ? {
697
+ "Content-Type": options.contentType
698
+ } : void 0
699
+ });
700
+ pumping = pipeline(stream, counter, {
701
+ signal: controller.signal
702
+ });
703
+ await Promise.all([
704
+ transfer,
705
+ pumping
706
+ ]);
707
+ return {
708
+ key,
709
+ url: this.buildUrl(key),
710
+ size
711
+ };
712
+ } catch (error) {
713
+ controller.abort(error);
714
+ stream.destroy(error instanceof Error ? error : new Error("Storage upload failed"));
715
+ counter.destroy();
716
+ await Promise.allSettled([
717
+ transfer,
718
+ pumping,
719
+ finished(stream, {
720
+ cleanup: true
721
+ })
722
+ ]);
723
+ throw error;
724
+ } finally {
725
+ options?.signal?.removeEventListener("abort", abort);
726
+ stream.off("error", sourceError);
666
727
  }
667
- return this.upload(Buffer.concat(chunks), key, options);
668
728
  }
669
- async download(key) {
729
+ async download(key, options) {
730
+ options?.signal?.throwIfAborted();
670
731
  const client = await this.getClient();
671
732
  const remotePath = this.resolvePath(key);
672
- const stat = await client.stat(remotePath);
673
- const buffer = await client.getFileContents(remotePath);
674
- const readable = Readable.from(buffer);
733
+ const stat = await client.stat(remotePath, ...options?.signal ? [
734
+ {
735
+ signal: options.signal
736
+ }
737
+ ] : []);
738
+ if (stat.type !== "file") throw new Error("Remote path is not a file");
739
+ options?.signal?.throwIfAborted();
740
+ const stream = client.createReadStream(remotePath, ...options?.signal ? [
741
+ {
742
+ signal: options.signal
743
+ }
744
+ ] : []);
745
+ if (options?.signal) addAbortSignal(options.signal, stream);
675
746
  return {
676
- stream: readable,
677
- size: stat.size ?? buffer.length
747
+ stream,
748
+ size: stat.size
678
749
  };
679
750
  }
680
751
  async delete(key) {
@@ -699,22 +770,26 @@ var WebDavStorage = class _WebDavStorage {
699
770
  async getDownloadUrl(key) {
700
771
  return this.getPublicUrl(key);
701
772
  }
702
- async list(prefix) {
773
+ async list(prefix, options = {}) {
703
774
  const client = await this.getClient();
704
775
  const remotePath = this.resolvePath(prefix);
705
776
  try {
706
777
  const items = await client.getDirectoryContents(remotePath, {
707
- deep: true
778
+ deep: options.deep ?? true
708
779
  });
709
- return items.filter((item) => item.type === "file").map((item) => ({
780
+ return items.filter((item) => item.type === "file" || options.includeDirectories).map((item) => ({
710
781
  key: this.stripBasePath(item.filename),
782
+ type: item.type,
783
+ filename: item.filename,
784
+ basename: item.basename,
785
+ lastmod: item.lastmod,
711
786
  size: item.size ?? 0,
712
787
  lastModified: new Date(item.lastmod ?? Date.now()),
713
788
  contentType: item.mime
714
789
  }));
715
790
  } catch (error) {
716
791
  const err = error;
717
- if (err.status === 404) return [];
792
+ if (err.status === 404 && options.missing !== "throw") return [];
718
793
  throw error;
719
794
  }
720
795
  }
@@ -759,7 +834,12 @@ var WebDavStorage = class _WebDavStorage {
759
834
  /**
760
835
  * 递归创建目录
761
836
  */
762
- async ensureDirectory(dirPath) {
837
+ /** Accepts a storage key, with the same traversal protection as upload. */
838
+ async ensureDirectory(key) {
839
+ return this.ensureRemoteDirectory(this.resolvePath(key));
840
+ }
841
+ async ensureRemoteDirectory(dirPath, signal) {
842
+ if (signal) return this.createDirectoryIfMissing(dirPath, signal);
763
843
  const inflight = this.directoryPromises.get(dirPath);
764
844
  if (inflight) return inflight;
765
845
  const creation = this.createDirectoryIfMissing(dirPath);
@@ -772,21 +852,35 @@ var WebDavStorage = class _WebDavStorage {
772
852
  }
773
853
  }
774
854
  }
775
- async createDirectoryIfMissing(dirPath) {
855
+ async createDirectoryIfMissing(dirPath, signal) {
856
+ signal?.throwIfAborted();
776
857
  const client = await this.getClient();
777
- if (await client.exists(dirPath)) return;
858
+ if (await client.exists(dirPath, ...signal ? [
859
+ {
860
+ signal
861
+ }
862
+ ] : [])) return;
778
863
  const parent = dirPath.substring(0, dirPath.lastIndexOf("/"));
779
864
  if (parent && parent !== "/") {
780
- await this.ensureDirectory(parent);
865
+ await this.ensureRemoteDirectory(parent, signal);
781
866
  }
782
867
  try {
783
- await client.createDirectory(dirPath);
868
+ signal?.throwIfAborted();
869
+ await client.createDirectory(dirPath, ...signal ? [
870
+ {
871
+ signal
872
+ }
873
+ ] : []);
784
874
  } catch (error) {
785
875
  const status = error.status;
786
876
  if ([
787
877
  405,
788
878
  409
789
- ].includes(status ?? 0) && await client.exists(dirPath)) return;
879
+ ].includes(status ?? 0) && await client.exists(dirPath, ...signal ? [
880
+ {
881
+ signal
882
+ }
883
+ ] : [])) return;
790
884
  throw error;
791
885
  }
792
886
  }
@@ -1012,6 +1106,198 @@ ZuckerStorageModule = _ts_decorate5([
1012
1106
  Module({})
1013
1107
  ], ZuckerStorageModule);
1014
1108
 
1109
+ // src/storage-url-hydrator.ts
1110
+ function isPlainRecord(value) {
1111
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1112
+ const prototype = Object.getPrototypeOf(value);
1113
+ return prototype === Object.prototype || prototype === null;
1114
+ }
1115
+ __name(isPlainRecord, "isPlainRecord");
1116
+ async function hydrateStorageUrls(value, options) {
1117
+ const signedUrls = /* @__PURE__ */ new Map();
1118
+ const hydrate = /* @__PURE__ */ __name(async (current) => {
1119
+ if (Array.isArray(current)) return Promise.all(current.map(hydrate));
1120
+ if (!isPlainRecord(current)) return current;
1121
+ const entries = await Promise.all(Object.entries(current).map(async ([key2, nested]) => [
1122
+ key2,
1123
+ await hydrate(nested)
1124
+ ]));
1125
+ const result = Object.fromEntries(entries);
1126
+ const key = result[options.storageKeyField];
1127
+ if (typeof key === "string" && key.length > 0 && options.allowedStorageKeys.has(key)) {
1128
+ let signed = signedUrls.get(key);
1129
+ if (!signed) {
1130
+ signed = options.getDownloadUrl(key);
1131
+ signedUrls.set(key, signed);
1132
+ }
1133
+ const url = await signed;
1134
+ for (const field of options.urlFields) {
1135
+ Object.defineProperty(result, field, {
1136
+ value: url,
1137
+ writable: true,
1138
+ enumerable: true,
1139
+ configurable: true
1140
+ });
1141
+ }
1142
+ }
1143
+ return result;
1144
+ }, "hydrate");
1145
+ return hydrate(value);
1146
+ }
1147
+ __name(hydrateStorageUrls, "hydrateStorageUrls");
1148
+
1149
+ // src/unavailable-file-manager.ts
1150
+ var UnavailableFileManager = class {
1151
+ static {
1152
+ __name(this, "UnavailableFileManager");
1153
+ }
1154
+ providerType;
1155
+ message;
1156
+ constructor(providerType, message = `Storage provider ${providerType} is not configured`) {
1157
+ this.providerType = providerType;
1158
+ this.message = message;
1159
+ }
1160
+ isConfigured() {
1161
+ return false;
1162
+ }
1163
+ unavailable() {
1164
+ return new Error(this.message);
1165
+ }
1166
+ upload(_buffer, _key, _options) {
1167
+ return Promise.reject(this.unavailable());
1168
+ }
1169
+ uploadStream(_stream, _key, _options) {
1170
+ return Promise.reject(this.unavailable());
1171
+ }
1172
+ download(_key) {
1173
+ return Promise.reject(this.unavailable());
1174
+ }
1175
+ delete(_key) {
1176
+ return Promise.reject(this.unavailable());
1177
+ }
1178
+ exists(_key) {
1179
+ return Promise.resolve(false);
1180
+ }
1181
+ getPublicUrl(_key) {
1182
+ return Promise.reject(this.unavailable());
1183
+ }
1184
+ getDownloadUrl(_key) {
1185
+ return Promise.reject(this.unavailable());
1186
+ }
1187
+ list(_prefix) {
1188
+ return Promise.resolve([]);
1189
+ }
1190
+ };
1191
+
1192
+ // src/verified-object-transfer.ts
1193
+ import { createHash } from "crypto";
1194
+ var StorageTransferError = class extends Error {
1195
+ static {
1196
+ __name(this, "StorageTransferError");
1197
+ }
1198
+ code;
1199
+ constructor(code) {
1200
+ super(code), this.code = code;
1201
+ this.name = "StorageTransferError";
1202
+ }
1203
+ };
1204
+ function verifyStoredObject(object, expected, detectMimeType, errorFactory = (code) => new StorageTransferError(code)) {
1205
+ if (object.reportedSize !== object.body.length || object.body.length !== expected.size) throw errorFactory("TARGET_SIZE_CONFLICT");
1206
+ if (detectMimeType(object.body) !== expected.mimeType) throw errorFactory("TARGET_MIME_CONFLICT");
1207
+ if (createHash("sha256").update(object.body).digest("hex") !== expected.sha256) throw errorFactory("TARGET_HASH_CONFLICT");
1208
+ }
1209
+ __name(verifyStoredObject, "verifyStoredObject");
1210
+ async function transferVerifiedObject(options) {
1211
+ const fail = options.errorFactory ?? ((code) => new StorageTransferError(code));
1212
+ const body = options.source.body;
1213
+ if (options.source.reportedSize !== body.length || body.length === 0) throw fail("SOURCE_SIZE_MISMATCH");
1214
+ const metadata = {
1215
+ sha256: createHash("sha256").update(body).digest("hex"),
1216
+ size: body.length,
1217
+ mimeType: options.detectMimeType(body)
1218
+ };
1219
+ if (await options.target.exists(options.destinationKey)) {
1220
+ verifyStoredObject(await options.target.read(options.destinationKey), metadata, options.detectMimeType, fail);
1221
+ return {
1222
+ ...metadata,
1223
+ targetDisposition: options.mode === "dry-run" ? "planned" : "reused"
1224
+ };
1225
+ }
1226
+ if (options.mode === "dry-run" || !options.allowUpload) return {
1227
+ ...metadata,
1228
+ targetDisposition: "planned"
1229
+ };
1230
+ const uploaded = await options.target.write(options.destinationKey, body, {
1231
+ contentType: metadata.mimeType,
1232
+ metadata: {
1233
+ sha256: metadata.sha256,
1234
+ size: String(metadata.size),
1235
+ mimeType: metadata.mimeType
1236
+ },
1237
+ private: true
1238
+ });
1239
+ if (uploaded.size !== metadata.size) throw fail("UPLOAD_SIZE_MISMATCH");
1240
+ verifyStoredObject(await options.target.read(options.destinationKey), metadata, options.detectMimeType, fail);
1241
+ return {
1242
+ ...metadata,
1243
+ targetDisposition: "uploaded"
1244
+ };
1245
+ }
1246
+ __name(transferVerifiedObject, "transferVerifiedObject");
1247
+
1248
+ // src/buffered-file-manager-store.ts
1249
+ var BufferedFileManagerStore = class {
1250
+ static {
1251
+ __name(this, "BufferedFileManagerStore");
1252
+ }
1253
+ manager;
1254
+ maxObjectBytes;
1255
+ errorFactory;
1256
+ constructor(manager, maxObjectBytes, errorFactory = (code) => new StorageTransferError(code)) {
1257
+ this.manager = manager;
1258
+ this.maxObjectBytes = maxObjectBytes;
1259
+ this.errorFactory = errorFactory;
1260
+ if (!Number.isSafeInteger(maxObjectBytes) || maxObjectBytes < 1) throw new RangeError("maxObjectBytes must be a positive integer");
1261
+ if (!manager.isConfigured()) throw errorFactory("STORAGE_NOT_CONFIGURED");
1262
+ }
1263
+ async read(key) {
1264
+ const downloaded = await this.manager.download(key);
1265
+ if (downloaded.size < 1 || downloaded.size > this.maxObjectBytes) {
1266
+ downloaded.stream.destroy();
1267
+ throw this.errorFactory("OBJECT_SIZE_OUT_OF_BOUNDS");
1268
+ }
1269
+ const chunks = [];
1270
+ let size = 0;
1271
+ for await (const chunk of downloaded.stream) {
1272
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1273
+ size += buffer.length;
1274
+ if (size > this.maxObjectBytes) {
1275
+ downloaded.stream.destroy();
1276
+ throw this.errorFactory("OBJECT_SIZE_OUT_OF_BOUNDS");
1277
+ }
1278
+ chunks.push(buffer);
1279
+ }
1280
+ return {
1281
+ body: Buffer.concat(chunks),
1282
+ reportedSize: downloaded.size
1283
+ };
1284
+ }
1285
+ exists(key) {
1286
+ return this.manager.exists(key);
1287
+ }
1288
+ async write(key, body, options) {
1289
+ if (body.length > this.maxObjectBytes) throw this.errorFactory("OBJECT_SIZE_OUT_OF_BOUNDS");
1290
+ const result = await this.manager.upload(body, key, {
1291
+ contentType: options.contentType,
1292
+ metadata: options.metadata,
1293
+ acl: "private"
1294
+ });
1295
+ return {
1296
+ size: result.size
1297
+ };
1298
+ }
1299
+ };
1300
+
1015
1301
  // src/index.ts
1016
1302
  var StorageProvider = /* @__PURE__ */ (function(StorageProvider2) {
1017
1303
  StorageProvider2["LOCAL"] = "LOCAL";
@@ -1020,16 +1306,22 @@ var StorageProvider = /* @__PURE__ */ (function(StorageProvider2) {
1020
1306
  return StorageProvider2;
1021
1307
  })({});
1022
1308
  export {
1309
+ BufferedFileManagerStore,
1023
1310
  DANGEROUS_EXTENSIONS,
1024
1311
  FILE_MANAGER,
1025
1312
  FileLifecycleManager,
1026
1313
  LocalFileStorage,
1027
1314
  S3Storage,
1028
1315
  StorageProvider,
1316
+ StorageTransferError,
1317
+ UnavailableFileManager,
1029
1318
  WebDavStorage,
1030
1319
  ZuckerStorageModule,
1031
1320
  generateDateBasedKey,
1321
+ hydrateStorageUrls,
1032
1322
  isFileDenied,
1033
1323
  isSafeOriginalFileName,
1034
- sanitizeFileName
1324
+ sanitizeFileName,
1325
+ transferVerifiedObject,
1326
+ verifyStoredObject
1035
1327
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zucker-framework/storage",
3
- "version": "1.0.0",
3
+ "version": "1.0.5",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",