@modern-admin/feature-upload 0.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.
Files changed (66) hide show
  1. package/dist/graphql.d.ts +36 -0
  2. package/dist/graphql.d.ts.map +1 -0
  3. package/dist/graphql.js +141 -0
  4. package/dist/graphql.js.map +1 -0
  5. package/dist/index.d.ts +7 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +12 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/nest/index.d.ts +3 -0
  10. package/dist/nest/index.d.ts.map +1 -0
  11. package/dist/nest/index.js +4 -0
  12. package/dist/nest/index.js.map +1 -0
  13. package/dist/nest/upload-sweeper.service.d.ts +32 -0
  14. package/dist/nest/upload-sweeper.service.d.ts.map +1 -0
  15. package/dist/nest/upload-sweeper.service.js +68 -0
  16. package/dist/nest/upload-sweeper.service.js.map +1 -0
  17. package/dist/nest/upload.controller.d.ts +51 -0
  18. package/dist/nest/upload.controller.d.ts.map +1 -0
  19. package/dist/nest/upload.controller.js +220 -0
  20. package/dist/nest/upload.controller.js.map +1 -0
  21. package/dist/nest/upload.module.d.ts +28 -0
  22. package/dist/nest/upload.module.d.ts.map +1 -0
  23. package/dist/nest/upload.module.js +51 -0
  24. package/dist/nest/upload.module.js.map +1 -0
  25. package/dist/nest/upload.tokens.d.ts +23 -0
  26. package/dist/nest/upload.tokens.d.ts.map +1 -0
  27. package/dist/nest/upload.tokens.js +6 -0
  28. package/dist/nest/upload.tokens.js.map +1 -0
  29. package/dist/pending-registry.d.ts +60 -0
  30. package/dist/pending-registry.d.ts.map +1 -0
  31. package/dist/pending-registry.js +106 -0
  32. package/dist/pending-registry.js.map +1 -0
  33. package/dist/providers/local.d.ts +36 -0
  34. package/dist/providers/local.d.ts.map +1 -0
  35. package/dist/providers/local.js +44 -0
  36. package/dist/providers/local.js.map +1 -0
  37. package/dist/providers/s3.d.ts +96 -0
  38. package/dist/providers/s3.d.ts.map +1 -0
  39. package/dist/providers/s3.js +164 -0
  40. package/dist/providers/s3.js.map +1 -0
  41. package/dist/registry.d.ts +27 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +25 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/types.d.ts +130 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +9 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/upload-feature.d.ts +31 -0
  50. package/dist/upload-feature.d.ts.map +1 -0
  51. package/dist/upload-feature.js +170 -0
  52. package/dist/upload-feature.js.map +1 -0
  53. package/package.json +94 -0
  54. package/src/graphql.ts +185 -0
  55. package/src/index.ts +22 -0
  56. package/src/nest/index.ts +4 -0
  57. package/src/nest/upload-sweeper.service.ts +52 -0
  58. package/src/nest/upload.controller.ts +228 -0
  59. package/src/nest/upload.module.ts +44 -0
  60. package/src/nest/upload.tokens.ts +24 -0
  61. package/src/pending-registry.ts +115 -0
  62. package/src/providers/local.ts +63 -0
  63. package/src/providers/s3.ts +226 -0
  64. package/src/registry.ts +39 -0
  65. package/src/types.ts +137 -0
  66. package/src/upload-feature.ts +208 -0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * ModernAdminUploadModule — registers `UploadController` plus the sweeper
3
+ * background task that purges orphaned pending uploads.
4
+ *
5
+ * Import alongside `ModernAdminModule.forRoot()` in the host application:
6
+ *
7
+ * ```ts
8
+ * @Module({
9
+ * imports: [
10
+ * ModernAdminModule.forRoot({ global: true, ... }),
11
+ * ModernAdminUploadModule.forRoot({ pendingTtlMs: 60 * 60 * 1000 }),
12
+ * // feature modules that use uploadFeature() ...
13
+ * ],
14
+ * })
15
+ * export class AdminModule {}
16
+ * ```
17
+ *
18
+ * The module depends on `MODERN_ADMIN` and `ModernAdminAuthGuard` being
19
+ * available in the DI tree, which is satisfied when `ModernAdminModule.forRoot`
20
+ * is registered with `global: true` (the recommended default).
21
+ */
22
+ import { type DynamicModule } from '@nestjs/common';
23
+ import { type ModernAdminUploadModuleOptions } from './upload.tokens.js';
24
+ export type { ModernAdminUploadModuleOptions } from './upload.tokens.js';
25
+ export declare class ModernAdminUploadModule {
26
+ static forRoot(options?: ModernAdminUploadModuleOptions): DynamicModule;
27
+ }
28
+ //# sourceMappingURL=upload.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.module.d.ts","sourceRoot":"","sources":["../../src/nest/upload.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,KAAK,aAAa,EAAU,MAAM,gBAAgB,CAAA;AAG3D,OAAO,EAAyB,KAAK,8BAA8B,EAAE,MAAM,oBAAoB,CAAA;AAE/F,YAAY,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAA;AAExE,qBACa,uBAAuB;IAClC,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,8BAAmC,GAAG,aAAa;CAY5E"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ModernAdminUploadModule — registers `UploadController` plus the sweeper
3
+ * background task that purges orphaned pending uploads.
4
+ *
5
+ * Import alongside `ModernAdminModule.forRoot()` in the host application:
6
+ *
7
+ * ```ts
8
+ * @Module({
9
+ * imports: [
10
+ * ModernAdminModule.forRoot({ global: true, ... }),
11
+ * ModernAdminUploadModule.forRoot({ pendingTtlMs: 60 * 60 * 1000 }),
12
+ * // feature modules that use uploadFeature() ...
13
+ * ],
14
+ * })
15
+ * export class AdminModule {}
16
+ * ```
17
+ *
18
+ * The module depends on `MODERN_ADMIN` and `ModernAdminAuthGuard` being
19
+ * available in the DI tree, which is satisfied when `ModernAdminModule.forRoot`
20
+ * is registered with `global: true` (the recommended default).
21
+ */
22
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
23
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
24
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
25
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
26
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
27
+ };
28
+ var ModernAdminUploadModule_1;
29
+ import { Module } from '@nestjs/common';
30
+ import { UploadController } from './upload.controller.js';
31
+ import { UploadSweeperService } from './upload-sweeper.service.js';
32
+ import { UPLOAD_MODULE_OPTIONS } from './upload.tokens.js';
33
+ let ModernAdminUploadModule = ModernAdminUploadModule_1 = class ModernAdminUploadModule {
34
+ static forRoot(options = {}) {
35
+ return {
36
+ module: ModernAdminUploadModule_1,
37
+ global: options.global ?? false,
38
+ controllers: [UploadController],
39
+ providers: [
40
+ { provide: UPLOAD_MODULE_OPTIONS, useValue: options },
41
+ UploadSweeperService,
42
+ ],
43
+ exports: [UPLOAD_MODULE_OPTIONS],
44
+ };
45
+ }
46
+ };
47
+ ModernAdminUploadModule = ModernAdminUploadModule_1 = __decorate([
48
+ Module({})
49
+ ], ModernAdminUploadModule);
50
+ export { ModernAdminUploadModule };
51
+ //# sourceMappingURL=upload.module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.module.js","sourceRoot":"","sources":["../../src/nest/upload.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;;;;;;;;AAEH,OAAO,EAAsB,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAClE,OAAO,EAAE,qBAAqB,EAAuC,MAAM,oBAAoB,CAAA;AAKxF,IAAM,uBAAuB,+BAA7B,MAAM,uBAAuB;IAClC,MAAM,CAAC,OAAO,CAAC,UAA0C,EAAE;QACzD,OAAO;YACL,MAAM,EAAE,yBAAuB;YAC/B,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,KAAK;YAC/B,WAAW,EAAE,CAAC,gBAAgB,CAAC;YAC/B,SAAS,EAAE;gBACT,EAAE,OAAO,EAAE,qBAAqB,EAAE,QAAQ,EAAE,OAAO,EAAE;gBACrD,oBAAoB;aACrB;YACD,OAAO,EAAE,CAAC,qBAAqB,CAAC;SACjC,CAAA;IACH,CAAC;CACF,CAAA;AAbY,uBAAuB;IADnC,MAAM,CAAC,EAAE,CAAC;GACE,uBAAuB,CAanC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * DI tokens + option types shared between `ModernAdminUploadModule`,
3
+ * `UploadController`, and the sweeper service.
4
+ */
5
+ export declare const UPLOAD_MODULE_OPTIONS: unique symbol;
6
+ export interface ModernAdminUploadModuleOptions {
7
+ /** Mark this module as global. Defaults to false. */
8
+ global?: boolean;
9
+ /**
10
+ * TTL applied to freshly uploaded files before the sweeper deletes them.
11
+ * Default: 1 hour. Files are confirmed (TTL becomes irrelevant) as soon
12
+ * as the parent record is saved, via the action hooks installed by
13
+ * `uploadFeature`.
14
+ */
15
+ pendingTtlMs?: number;
16
+ /**
17
+ * Interval at which the sweeper runs. Default: 5 minutes. Set to `0` to
18
+ * disable the periodic sweeper entirely (the registry still works — entries
19
+ * just never expire).
20
+ */
21
+ sweepIntervalMs?: number;
22
+ }
23
+ //# sourceMappingURL=upload.tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.tokens.d.ts","sourceRoot":"","sources":["../../src/nest/upload.tokens.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,eAAO,MAAM,qBAAqB,eAAmD,CAAA;AAErF,MAAM,WAAW,8BAA8B;IAC7C,qDAAqD;IACrD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * DI tokens + option types shared between `ModernAdminUploadModule`,
3
+ * `UploadController`, and the sweeper service.
4
+ */
5
+ export const UPLOAD_MODULE_OPTIONS = Symbol.for('modern-admin.upload.module-options');
6
+ //# sourceMappingURL=upload.tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.tokens.js","sourceRoot":"","sources":["../../src/nest/upload.tokens.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAA"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * PendingUploadsRegistry — tracks files that were uploaded to storage but have
3
+ * not yet been "confirmed" by saving the parent record.
4
+ *
5
+ * Why
6
+ * ---
7
+ * Uploads happen *before* the form is submitted (the user picks a file, the
8
+ * editor calls `POST /upload`, gets back a key, stores it in form state). If
9
+ * the user then abandons the form (closes the tab, navigates away, server
10
+ * rejects the create payload, etc.), the file ends up orphaned in storage.
11
+ *
12
+ * How
13
+ * ---
14
+ * - The controller calls `track(key, providerId, ttlMs)` after every successful
15
+ * upload, recording an expiry timestamp.
16
+ * - The action hooks installed by `uploadFeature` call `confirm(keys)` from
17
+ * `new.after` / `edit.after` once the record is saved, removing the keys
18
+ * from the pending set so they will not be swept.
19
+ * - The user-initiated `DELETE /upload?…` endpoint calls `cancel(key)` to
20
+ * immediately remove the file from storage when the user removes a freshly
21
+ * uploaded file *before* saving.
22
+ * - A periodic sweeper started by `ModernAdminUploadModule.forRoot()` calls
23
+ * `sweep()` to delete files whose pending entry has expired. This handles
24
+ * abandoned forms, browser crashes, and any other path that bypasses
25
+ * client-side cleanup.
26
+ *
27
+ * The registry is a process-level singleton (same pattern as
28
+ * `UploadProviderRegistry`).
29
+ *
30
+ * TODO(roadmap): swap the in-process `Map` for a Redis-backed store and move
31
+ * the sweeper driver (`UploadSweeperService`) onto BullMQ. This is required
32
+ * for multi-instance deployments where one Nest replica serves the upload
33
+ * request and a different replica processes the form submission — without a
34
+ * shared store the second replica would not see the pending entry, the
35
+ * confirm hook would no-op, and the file would be swept on the originator.
36
+ * BullMQ also gives us crash-safe scheduling and retries.
37
+ */
38
+ export declare const PendingUploadsRegistry: {
39
+ /** Mark `key` as a freshly-uploaded, unconfirmed file. */
40
+ track(key: string, providerId: string, ttlMs: number): void;
41
+ /** Whether `key` is currently in the pending set (helper for tests / cancel). */
42
+ has(key: string): boolean;
43
+ /** Confirm one or more keys — they leave pending without being deleted. */
44
+ confirm(keys: ReadonlyArray<string>): void;
45
+ /**
46
+ * Cancel a single pending key — deletes the file from storage and removes
47
+ * the entry. No-op if the key is not pending (already confirmed or unknown).
48
+ */
49
+ cancel(key: string): Promise<boolean>;
50
+ /**
51
+ * Sweep expired entries — delete each from storage and from the registry.
52
+ * Returns the number of files swept.
53
+ */
54
+ sweep(now?: number): Promise<number>;
55
+ /** For test cleanup. */
56
+ clear(): void;
57
+ /** Number of currently-pending entries (for tests / introspection). */
58
+ size(): number;
59
+ };
60
+ //# sourceMappingURL=pending-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-registry.d.ts","sourceRoot":"","sources":["../src/pending-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAaH,eAAO,MAAM,sBAAsB;IACjC,0DAA0D;eAC/C,MAAM,cAAc,MAAM,SAAS,MAAM,GAAG,IAAI;IAI3D,iFAAiF;aACxE,MAAM,GAAG,OAAO;IAIzB,2EAA2E;kBAC7D,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI;IAI1C;;;OAGG;gBACe,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc3C;;;OAGG;gBACc,MAAM,GAAgB,OAAO,CAAC,MAAM,CAAC;IAkBtD,wBAAwB;aACf,IAAI;IAIb,uEAAuE;YAC/D,MAAM;CAGf,CAAA"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * PendingUploadsRegistry — tracks files that were uploaded to storage but have
3
+ * not yet been "confirmed" by saving the parent record.
4
+ *
5
+ * Why
6
+ * ---
7
+ * Uploads happen *before* the form is submitted (the user picks a file, the
8
+ * editor calls `POST /upload`, gets back a key, stores it in form state). If
9
+ * the user then abandons the form (closes the tab, navigates away, server
10
+ * rejects the create payload, etc.), the file ends up orphaned in storage.
11
+ *
12
+ * How
13
+ * ---
14
+ * - The controller calls `track(key, providerId, ttlMs)` after every successful
15
+ * upload, recording an expiry timestamp.
16
+ * - The action hooks installed by `uploadFeature` call `confirm(keys)` from
17
+ * `new.after` / `edit.after` once the record is saved, removing the keys
18
+ * from the pending set so they will not be swept.
19
+ * - The user-initiated `DELETE /upload?…` endpoint calls `cancel(key)` to
20
+ * immediately remove the file from storage when the user removes a freshly
21
+ * uploaded file *before* saving.
22
+ * - A periodic sweeper started by `ModernAdminUploadModule.forRoot()` calls
23
+ * `sweep()` to delete files whose pending entry has expired. This handles
24
+ * abandoned forms, browser crashes, and any other path that bypasses
25
+ * client-side cleanup.
26
+ *
27
+ * The registry is a process-level singleton (same pattern as
28
+ * `UploadProviderRegistry`).
29
+ *
30
+ * TODO(roadmap): swap the in-process `Map` for a Redis-backed store and move
31
+ * the sweeper driver (`UploadSweeperService`) onto BullMQ. This is required
32
+ * for multi-instance deployments where one Nest replica serves the upload
33
+ * request and a different replica processes the form submission — without a
34
+ * shared store the second replica would not see the pending entry, the
35
+ * confirm hook would no-op, and the file would be swept on the originator.
36
+ * BullMQ also gives us crash-safe scheduling and retries.
37
+ */
38
+ import { UploadProviderRegistry } from './registry.js';
39
+ const _pending = new Map();
40
+ export const PendingUploadsRegistry = {
41
+ /** Mark `key` as a freshly-uploaded, unconfirmed file. */
42
+ track(key, providerId, ttlMs) {
43
+ _pending.set(key, { providerId, expiresAt: Date.now() + ttlMs });
44
+ },
45
+ /** Whether `key` is currently in the pending set (helper for tests / cancel). */
46
+ has(key) {
47
+ return _pending.has(key);
48
+ },
49
+ /** Confirm one or more keys — they leave pending without being deleted. */
50
+ confirm(keys) {
51
+ for (const k of keys)
52
+ _pending.delete(k);
53
+ },
54
+ /**
55
+ * Cancel a single pending key — deletes the file from storage and removes
56
+ * the entry. No-op if the key is not pending (already confirmed or unknown).
57
+ */
58
+ async cancel(key) {
59
+ const entry = _pending.get(key);
60
+ if (!entry)
61
+ return false;
62
+ _pending.delete(key);
63
+ const cfg = UploadProviderRegistry.get(entry.providerId);
64
+ if (!cfg)
65
+ return false;
66
+ try {
67
+ await cfg.provider.delete(key);
68
+ }
69
+ catch {
70
+ // Non-fatal — best-effort cleanup.
71
+ }
72
+ return true;
73
+ },
74
+ /**
75
+ * Sweep expired entries — delete each from storage and from the registry.
76
+ * Returns the number of files swept.
77
+ */
78
+ async sweep(now = Date.now()) {
79
+ let swept = 0;
80
+ for (const [key, entry] of _pending) {
81
+ if (entry.expiresAt <= now) {
82
+ _pending.delete(key);
83
+ const cfg = UploadProviderRegistry.get(entry.providerId);
84
+ if (!cfg)
85
+ continue;
86
+ try {
87
+ await cfg.provider.delete(key);
88
+ swept++;
89
+ }
90
+ catch {
91
+ // Non-fatal.
92
+ }
93
+ }
94
+ }
95
+ return swept;
96
+ },
97
+ /** For test cleanup. */
98
+ clear() {
99
+ _pending.clear();
100
+ },
101
+ /** Number of currently-pending entries (for tests / introspection). */
102
+ size() {
103
+ return _pending.size;
104
+ },
105
+ };
106
+ //# sourceMappingURL=pending-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-registry.js","sourceRoot":"","sources":["../src/pending-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAStD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAwB,CAAA;AAEhD,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,0DAA0D;IAC1D,KAAK,CAAC,GAAW,EAAE,UAAkB,EAAE,KAAa;QAClD,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC,CAAA;IAClE,CAAC;IAED,iFAAiF;IACjF,GAAG,CAAC,GAAW;QACb,OAAO,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC1B,CAAC;IAED,2EAA2E;IAC3E,OAAO,CAAC,IAA2B;QACjC,KAAK,MAAM,CAAC,IAAI,IAAI;YAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAC1C,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAC/B,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QACxB,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QACpB,MAAM,GAAG,GAAG,sBAAsB,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;QACxD,IAAI,CAAC,GAAG;YAAE,OAAO,KAAK,CAAA;QACtB,IAAI,CAAC;YACH,MAAM,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,mCAAmC;QACrC,CAAC;QACD,OAAO,IAAI,CAAA;IACb,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,KAAK,CAAC,MAAc,IAAI,CAAC,GAAG,EAAE;QAClC,IAAI,KAAK,GAAG,CAAC,CAAA;QACb,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,QAAQ,EAAE,CAAC;YACpC,IAAI,KAAK,CAAC,SAAS,IAAI,GAAG,EAAE,CAAC;gBAC3B,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;gBACpB,MAAM,GAAG,GAAG,sBAAsB,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;gBACxD,IAAI,CAAC,GAAG;oBAAE,SAAQ;gBAClB,IAAI,CAAC;oBACH,MAAM,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;oBAC9B,KAAK,EAAE,CAAA;gBACT,CAAC;gBAAC,MAAM,CAAC;oBACP,aAAa;gBACf,CAAC;YACH,CAAC;QACH,CAAC;QACD,OAAO,KAAK,CAAA;IACd,CAAC;IAED,wBAAwB;IACxB,KAAK;QACH,QAAQ,CAAC,KAAK,EAAE,CAAA;IAClB,CAAC;IAED,uEAAuE;IACvE,IAAI;QACF,OAAO,QAAQ,CAAC,IAAI,CAAA;IACtB,CAAC;CACF,CAAA"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * LocalUploadProvider — stores files on the local filesystem.
3
+ *
4
+ * Files are written to `uploadDir` with a UUID-based filename to avoid
5
+ * collisions. Set `baseUrl` to the public URL prefix where the directory is
6
+ * served as static files (e.g. `'/uploads'` or `'http://localhost:3000/uploads'`).
7
+ *
8
+ * @example
9
+ * new LocalUploadProvider({ uploadDir: './public/uploads', baseUrl: '/uploads' })
10
+ */
11
+ import type { IUploadProvider, UploadedFile } from '../types.js';
12
+ export interface LocalUploadOptions {
13
+ /**
14
+ * Absolute or process-relative path to the directory where files are stored.
15
+ * Created automatically on first upload.
16
+ */
17
+ uploadDir: string;
18
+ /**
19
+ * Public URL prefix (without trailing slash) where uploaded files can be
20
+ * accessed. Used to construct the `url` returned by the upload endpoint and
21
+ * the `urlTemplate` for the frontend.
22
+ *
23
+ * @example '/uploads'
24
+ * @example 'https://static.example.com/uploads'
25
+ */
26
+ baseUrl?: string;
27
+ }
28
+ export declare class LocalUploadProvider implements IUploadProvider {
29
+ private readonly options;
30
+ constructor(options: LocalUploadOptions);
31
+ upload(file: UploadedFile, key?: string): Promise<string>;
32
+ getUrl(key: string): string;
33
+ delete(key: string): Promise<void>;
34
+ urlTemplate(): string;
35
+ }
36
+ //# sourceMappingURL=local.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../../src/providers/local.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAEhE,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,qBAAa,mBAAoB,YAAW,eAAe;IAC7C,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,kBAAkB;IAElD,MAAM,CAAC,IAAI,EAAE,YAAY,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAS/D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM;IAKrB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQxC,WAAW,IAAI,MAAM;CAItB"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * LocalUploadProvider — stores files on the local filesystem.
3
+ *
4
+ * Files are written to `uploadDir` with a UUID-based filename to avoid
5
+ * collisions. Set `baseUrl` to the public URL prefix where the directory is
6
+ * served as static files (e.g. `'/uploads'` or `'http://localhost:3000/uploads'`).
7
+ *
8
+ * @example
9
+ * new LocalUploadProvider({ uploadDir: './public/uploads', baseUrl: '/uploads' })
10
+ */
11
+ import { mkdir, writeFile, unlink } from 'node:fs/promises';
12
+ import { join, extname, dirname } from 'node:path';
13
+ import { uuidv7 } from '@modern-admin/core';
14
+ export class LocalUploadProvider {
15
+ constructor(options) {
16
+ this.options = options;
17
+ }
18
+ async upload(file, key) {
19
+ const resolvedKey = key ?? `${uuidv7()}${extname(file.originalName)}`;
20
+ const dest = join(this.options.uploadDir, resolvedKey);
21
+ // Create the full directory tree (handles nested keys like 'avatars/2024/01/uuid.jpg').
22
+ await mkdir(dirname(dest), { recursive: true });
23
+ await writeFile(dest, file.buffer);
24
+ return resolvedKey;
25
+ }
26
+ getUrl(key) {
27
+ const base = this.options.baseUrl ?? '/uploads';
28
+ return `${base}/${key}`;
29
+ }
30
+ async delete(key) {
31
+ try {
32
+ await unlink(join(this.options.uploadDir, key));
33
+ }
34
+ catch (err) {
35
+ if (err.code !== 'ENOENT')
36
+ throw err;
37
+ }
38
+ }
39
+ urlTemplate() {
40
+ const base = this.options.baseUrl ?? '/uploads';
41
+ return `${base}/{key}`;
42
+ }
43
+ }
44
+ //# sourceMappingURL=local.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local.js","sourceRoot":"","sources":["../../src/providers/local.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC3D,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAoB3C,MAAM,OAAO,mBAAmB;IAC9B,YAA6B,OAA2B;QAA3B,YAAO,GAAP,OAAO,CAAoB;IAAG,CAAC;IAE5D,KAAK,CAAC,MAAM,CAAC,IAAkB,EAAE,GAAY;QAC3C,MAAM,WAAW,GAAG,GAAG,IAAI,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,EAAE,CAAA;QACrE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,WAAW,CAAC,CAAA;QACtD,wFAAwF;QACxF,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QAC/C,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;QAClC,OAAO,WAAW,CAAA;IACpB,CAAC;IAED,MAAM,CAAC,GAAW;QAChB,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,UAAU,CAAA;QAC/C,OAAO,GAAG,IAAI,IAAI,GAAG,EAAE,CAAA;IACzB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAA;QACjD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;gBAAE,MAAM,GAAG,CAAA;QACjE,CAAC;IACH,CAAC;IAED,WAAW;QACT,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,UAAU,CAAA;QAC/C,OAAO,GAAG,IAAI,QAAQ,CAAA;IACxB,CAAC;CACF"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * S3UploadProvider — stores files in an AWS S3 bucket (or any S3-compatible
3
+ * service such as MinIO, Cloudflare R2, DigitalOcean Spaces, etc.).
4
+ *
5
+ * Requires `@aws-sdk/client-s3` to be installed in the host project.
6
+ * For streaming multipart upload of large files, also install `@aws-sdk/lib-storage`.
7
+ * For pre-signed URLs (private buckets), install `@aws-sdk/s3-request-presigner`.
8
+ *
9
+ * @example AWS S3 — public bucket
10
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', acl: 'public-read' })
11
+ *
12
+ * @example AWS S3 — private bucket with pre-signed URLs (60 min expiry)
13
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', signed: { expiresIn: 3600 } })
14
+ *
15
+ * @example MinIO / custom endpoint
16
+ * new S3UploadProvider({
17
+ * bucket: 'my-bucket',
18
+ * region: 'us-east-1',
19
+ * endpoint: 'http://localhost:9000',
20
+ * forcePathStyle: true,
21
+ * publicBaseUrl: 'http://localhost:9000/my-bucket',
22
+ * })
23
+ *
24
+ * @example Inject a pre-configured S3Client (share across providers)
25
+ * const s3 = new S3Client({ region: 'us-east-1' })
26
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }, s3)
27
+ */
28
+ import type { IUploadProvider, UploadedFile } from '../types.js';
29
+ export interface S3UploadOptions {
30
+ /** S3 bucket name. */
31
+ bucket: string;
32
+ /** AWS region (e.g. `'us-east-1'`). */
33
+ region: string;
34
+ /** AWS access key id. Falls back to environment / credential chain. */
35
+ accessKeyId?: string;
36
+ /** AWS secret access key. Falls back to environment / credential chain. */
37
+ secretAccessKey?: string;
38
+ /**
39
+ * Custom endpoint for S3-compatible services.
40
+ * @example 'http://localhost:9000'
41
+ * @example 'https://nyc3.digitaloceanspaces.com'
42
+ */
43
+ endpoint?: string;
44
+ /** Force path-style URLs (required for some MinIO / custom endpoint setups). */
45
+ forcePathStyle?: boolean;
46
+ /** Optional key prefix / "folder". Without trailing slash. */
47
+ prefix?: string;
48
+ /**
49
+ * Canned ACL applied to every uploaded object, e.g. `'public-read'`.
50
+ * Omit for private buckets — use `signed` instead.
51
+ */
52
+ acl?: string;
53
+ /**
54
+ * Override the public base URL used for `getUrl()` and `urlTemplate()`.
55
+ * Useful when files are served via CloudFront or a custom CDN.
56
+ * Without trailing slash.
57
+ * @example 'https://cdn.example.com'
58
+ */
59
+ publicBaseUrl?: string;
60
+ /**
61
+ * Generate pre-signed URLs instead of public URLs.
62
+ * Required for private S3 buckets. Needs `@aws-sdk/s3-request-presigner`.
63
+ *
64
+ * - `true` uses the default expiry (3600 s / 1 hour).
65
+ * - Pass `{ expiresIn: seconds }` to customise.
66
+ *
67
+ * When `signed` is set, `urlTemplate()` is not implemented (each URL is
68
+ * unique and time-limited) and `getUrl()` is async.
69
+ *
70
+ * @example
71
+ * signed: true // 1-hour pre-signed URLs
72
+ * signed: { expiresIn: 60 * 60 * 24 } // 24-hour pre-signed URLs
73
+ */
74
+ signed?: boolean | {
75
+ expiresIn?: number;
76
+ };
77
+ }
78
+ export declare class S3UploadProvider implements IUploadProvider {
79
+ private readonly options;
80
+ private readonly injectedClient?;
81
+ private _client;
82
+ constructor(options: S3UploadOptions, injectedClient?: any | undefined);
83
+ private client;
84
+ upload(file: UploadedFile, key?: string): Promise<string>;
85
+ getUrl(key: string): Promise<string>;
86
+ delete(key: string): Promise<void>;
87
+ /**
88
+ * URL template for the frontend. Only available when `signed` is NOT set
89
+ * (public buckets). For private buckets each URL is unique + time-limited,
90
+ * so the frontend must call the upload endpoint to get a fresh URL.
91
+ */
92
+ urlTemplate(): string | undefined;
93
+ private signedUrl;
94
+ private publicBaseUrl;
95
+ }
96
+ //# sourceMappingURL=s3.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../../src/providers/s3.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAEhE,MAAM,WAAW,eAAe;IAC9B,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,2EAA2E;IAC3E,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC1C;AAED,qBAAa,gBAAiB,YAAW,eAAe;IAKpD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAIxB,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAPlC,OAAO,CAAC,OAAO,CAAY;gBAGR,OAAO,EAAE,eAAe,EAIxB,cAAc,CAAC,EAAE,GAAG,YAAA;YAIzB,MAAM;IA4Bd,MAAM,CAAC,IAAI,EAAE,YAAY,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA2CzD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOpC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAaxC;;;;OAIG;IACH,WAAW,IAAI,MAAM,GAAG,SAAS;YAKnB,SAAS;IA0BvB,OAAO,CAAC,aAAa;CAKtB"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * S3UploadProvider — stores files in an AWS S3 bucket (or any S3-compatible
3
+ * service such as MinIO, Cloudflare R2, DigitalOcean Spaces, etc.).
4
+ *
5
+ * Requires `@aws-sdk/client-s3` to be installed in the host project.
6
+ * For streaming multipart upload of large files, also install `@aws-sdk/lib-storage`.
7
+ * For pre-signed URLs (private buckets), install `@aws-sdk/s3-request-presigner`.
8
+ *
9
+ * @example AWS S3 — public bucket
10
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', acl: 'public-read' })
11
+ *
12
+ * @example AWS S3 — private bucket with pre-signed URLs (60 min expiry)
13
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', signed: { expiresIn: 3600 } })
14
+ *
15
+ * @example MinIO / custom endpoint
16
+ * new S3UploadProvider({
17
+ * bucket: 'my-bucket',
18
+ * region: 'us-east-1',
19
+ * endpoint: 'http://localhost:9000',
20
+ * forcePathStyle: true,
21
+ * publicBaseUrl: 'http://localhost:9000/my-bucket',
22
+ * })
23
+ *
24
+ * @example Inject a pre-configured S3Client (share across providers)
25
+ * const s3 = new S3Client({ region: 'us-east-1' })
26
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }, s3)
27
+ */
28
+ import { extname } from 'node:path';
29
+ import { uuidv7 } from '@modern-admin/core';
30
+ export class S3UploadProvider {
31
+ constructor(options,
32
+ // Accept a pre-configured S3Client instance (share across providers /
33
+ // inject in tests). When omitted the provider creates its own client.
34
+ injectedClient) {
35
+ this.options = options;
36
+ this.injectedClient = injectedClient;
37
+ this._client = null;
38
+ }
39
+ async client() {
40
+ if (this.injectedClient)
41
+ return this.injectedClient;
42
+ if (this._client)
43
+ return this._client;
44
+ let S3Client;
45
+ try {
46
+ const mod = await import('@aws-sdk/client-s3');
47
+ S3Client = mod.S3Client;
48
+ }
49
+ catch {
50
+ throw new Error('[modern-admin/feature-upload] S3UploadProvider requires @aws-sdk/client-s3. ' +
51
+ 'Install it: bun add @aws-sdk/client-s3');
52
+ }
53
+ const cfg = {
54
+ region: this.options.region,
55
+ ...(this.options.endpoint ? { endpoint: this.options.endpoint } : {}),
56
+ ...(this.options.forcePathStyle ? { forcePathStyle: true } : {}),
57
+ };
58
+ if (this.options.accessKeyId && this.options.secretAccessKey) {
59
+ cfg.credentials = {
60
+ accessKeyId: this.options.accessKeyId,
61
+ secretAccessKey: this.options.secretAccessKey,
62
+ };
63
+ }
64
+ this._client = new S3Client(cfg);
65
+ return this._client;
66
+ }
67
+ async upload(file, key) {
68
+ const c = await this.client();
69
+ const ext = extname(file.originalName);
70
+ const prefix = this.options.prefix ? `${this.options.prefix}/` : '';
71
+ const resolvedKey = key ?? `${prefix}${uuidv7()}${ext}`;
72
+ // Prefer @aws-sdk/lib-storage for streaming multipart uploads (large files).
73
+ // Fall back to PutObjectCommand if lib-storage is not installed.
74
+ try {
75
+ const libStorage = await import('@aws-sdk/lib-storage');
76
+ const Upload = libStorage.Upload;
77
+ const input = {
78
+ Bucket: this.options.bucket,
79
+ Key: resolvedKey,
80
+ Body: file.buffer,
81
+ ContentType: file.mimeType,
82
+ };
83
+ if (this.options.acl)
84
+ input.ACL = this.options.acl;
85
+ const uploader = new Upload({ client: c, params: input });
86
+ await uploader.done();
87
+ }
88
+ catch (err) {
89
+ // lib-storage not installed — fall back to PutObjectCommand.
90
+ if (err.code === 'ERR_MODULE_NOT_FOUND' ||
91
+ String(err).includes('Cannot find module')) {
92
+ const sdk = await import('@aws-sdk/client-s3');
93
+ const PutObjectCommand = sdk.PutObjectCommand;
94
+ const input = {
95
+ Bucket: this.options.bucket,
96
+ Key: resolvedKey,
97
+ Body: file.buffer,
98
+ ContentType: file.mimeType,
99
+ ContentLength: file.size,
100
+ };
101
+ if (this.options.acl)
102
+ input.ACL = this.options.acl;
103
+ await c.send(new PutObjectCommand(input));
104
+ }
105
+ else {
106
+ throw err;
107
+ }
108
+ }
109
+ return resolvedKey;
110
+ }
111
+ async getUrl(key) {
112
+ if (this.options.signed) {
113
+ return this.signedUrl(key);
114
+ }
115
+ return `${this.publicBaseUrl()}/${key}`;
116
+ }
117
+ async delete(key) {
118
+ try {
119
+ const sdk = await import('@aws-sdk/client-s3');
120
+ const c = await this.client();
121
+ const DeleteObjectCommand = sdk.DeleteObjectCommand;
122
+ await c.send(new DeleteObjectCommand({ Bucket: this.options.bucket, Key: key }));
123
+ }
124
+ catch {
125
+ // Ignore — file may not exist.
126
+ }
127
+ }
128
+ /**
129
+ * URL template for the frontend. Only available when `signed` is NOT set
130
+ * (public buckets). For private buckets each URL is unique + time-limited,
131
+ * so the frontend must call the upload endpoint to get a fresh URL.
132
+ */
133
+ urlTemplate() {
134
+ if (this.options.signed)
135
+ return undefined;
136
+ return `${this.publicBaseUrl()}/{key}`;
137
+ }
138
+ async signedUrl(key) {
139
+ let getSignedUrl;
140
+ try {
141
+ const mod = await import('@aws-sdk/s3-request-presigner');
142
+ getSignedUrl = mod.getSignedUrl;
143
+ }
144
+ catch {
145
+ throw new Error('[modern-admin/feature-upload] Signed URLs require @aws-sdk/s3-request-presigner. ' +
146
+ 'Install it: bun add @aws-sdk/s3-request-presigner');
147
+ }
148
+ const sdk = await import('@aws-sdk/client-s3');
149
+ const c = await this.client();
150
+ const GetObjectCommand = sdk.GetObjectCommand;
151
+ const expiresIn = typeof this.options.signed === 'object'
152
+ ? (this.options.signed.expiresIn ?? 3600)
153
+ : 3600;
154
+ return getSignedUrl(c, new GetObjectCommand({ Bucket: this.options.bucket, Key: key }), { expiresIn });
155
+ }
156
+ publicBaseUrl() {
157
+ if (this.options.publicBaseUrl)
158
+ return this.options.publicBaseUrl;
159
+ if (this.options.endpoint)
160
+ return `${this.options.endpoint}/${this.options.bucket}`;
161
+ return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com`;
162
+ }
163
+ }
164
+ //# sourceMappingURL=s3.js.map