glasswork 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/abilities.d.ts +41 -0
- package/dist/auth/abilities.d.ts.map +1 -0
- package/dist/auth/abilities.js +62 -0
- package/dist/auth/abilities.js.map +1 -0
- package/dist/auth/assert.d.ts +20 -0
- package/dist/auth/assert.d.ts.map +1 -0
- package/dist/auth/assert.js +28 -0
- package/dist/auth/assert.js.map +1 -0
- package/dist/auth/better-auth-provider.d.ts +56 -0
- package/dist/auth/better-auth-provider.d.ts.map +1 -0
- package/dist/auth/better-auth-provider.js +62 -0
- package/dist/auth/better-auth-provider.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/middleware.d.ts +31 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +92 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/types.d.ts +54 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/core/bootstrap.d.ts.map +1 -1
- package/dist/core/bootstrap.js +5 -2
- package/dist/core/bootstrap.js.map +1 -1
- package/dist/core/types.d.ts +14 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/http/route-helpers.d.ts +47 -9
- package/dist/http/route-helpers.d.ts.map +1 -1
- package/dist/http/route-helpers.js +91 -40
- package/dist/http/route-helpers.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs/define-job.d.ts +57 -0
- package/dist/jobs/define-job.d.ts.map +1 -0
- package/dist/jobs/define-job.js +12 -0
- package/dist/jobs/define-job.js.map +1 -0
- package/dist/jobs/drivers/eventbridge-scheduler-driver.d.ts +75 -0
- package/dist/jobs/drivers/eventbridge-scheduler-driver.d.ts.map +1 -0
- package/dist/jobs/drivers/eventbridge-scheduler-driver.js +135 -0
- package/dist/jobs/drivers/eventbridge-scheduler-driver.js.map +1 -0
- package/dist/jobs/drivers/mock-driver.d.ts +73 -0
- package/dist/jobs/drivers/mock-driver.d.ts.map +1 -0
- package/dist/jobs/drivers/mock-driver.js +95 -0
- package/dist/jobs/drivers/mock-driver.js.map +1 -0
- package/dist/jobs/drivers/sqs-driver.d.ts +59 -0
- package/dist/jobs/drivers/sqs-driver.d.ts.map +1 -0
- package/dist/jobs/drivers/sqs-driver.js +176 -0
- package/dist/jobs/drivers/sqs-driver.js.map +1 -0
- package/dist/jobs/errors.d.ts +44 -0
- package/dist/jobs/errors.d.ts.map +1 -0
- package/dist/jobs/errors.js +67 -0
- package/dist/jobs/errors.js.map +1 -0
- package/dist/jobs/index.d.ts +12 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +11 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/job-registry.d.ts +33 -0
- package/dist/jobs/job-registry.d.ts.map +1 -0
- package/dist/jobs/job-registry.js +56 -0
- package/dist/jobs/job-registry.js.map +1 -0
- package/dist/jobs/job-service.d.ts +44 -0
- package/dist/jobs/job-service.d.ts.map +1 -0
- package/dist/jobs/job-service.js +105 -0
- package/dist/jobs/job-service.js.map +1 -0
- package/dist/jobs/periodic-job.d.ts +6 -0
- package/dist/jobs/periodic-job.d.ts.map +1 -0
- package/dist/jobs/periodic-job.js +11 -0
- package/dist/jobs/periodic-job.js.map +1 -0
- package/dist/jobs/schedule-constants.d.ts +5 -0
- package/dist/jobs/schedule-constants.d.ts.map +1 -0
- package/dist/jobs/schedule-constants.js +5 -0
- package/dist/jobs/schedule-constants.js.map +1 -0
- package/dist/jobs/scheduler.d.ts +17 -0
- package/dist/jobs/scheduler.d.ts.map +1 -0
- package/dist/jobs/scheduler.js +83 -0
- package/dist/jobs/scheduler.js.map +1 -0
- package/dist/jobs/types.d.ts +165 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +2 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/jobs/utils.d.ts +15 -0
- package/dist/jobs/utils.d.ts.map +1 -0
- package/dist/jobs/utils.js +49 -0
- package/dist/jobs/utils.js.map +1 -0
- package/dist/jobs/worker.d.ts +35 -0
- package/dist/jobs/worker.d.ts.map +1 -0
- package/dist/jobs/worker.js +361 -0
- package/dist/jobs/worker.js.map +1 -0
- package/dist/list-query/casl.d.ts +18 -19
- package/dist/list-query/casl.d.ts.map +1 -1
- package/dist/list-query/casl.js +40 -46
- package/dist/list-query/casl.js.map +1 -1
- package/dist/uploads/file-upload.d.ts +44 -0
- package/dist/uploads/file-upload.d.ts.map +1 -0
- package/dist/uploads/file-upload.js +145 -0
- package/dist/uploads/file-upload.js.map +1 -0
- package/dist/uploads/index.d.ts +6 -0
- package/dist/uploads/index.d.ts.map +1 -0
- package/dist/uploads/index.js +7 -0
- package/dist/uploads/index.js.map +1 -0
- package/dist/uploads/uploads.service.d.ts +98 -0
- package/dist/uploads/uploads.service.d.ts.map +1 -0
- package/dist/uploads/uploads.service.js +134 -0
- package/dist/uploads/uploads.service.js.map +1 -0
- package/dist/uploads/validators.d.ts +41 -0
- package/dist/uploads/validators.d.ts.map +1 -0
- package/dist/uploads/validators.js +94 -0
- package/dist/uploads/validators.js.map +1 -0
- package/package.json +34 -3
|
@@ -1,37 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
export type CaslAccessibleBy = (ability: any, action?: string) => Record<string, Record<string, unknown>>;
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* @param ability - The CASL ability instance (any object that works with @casl/prisma's accessibleBy)
|
|
6
|
-
* @param subject - The Prisma model name (e.g., 'User', 'Organization')
|
|
7
|
-
* @returns A function that applies the CASL conditions to a builder
|
|
3
|
+
* Register CASL integration dependencies.
|
|
4
|
+
* This must be called at application startup if you use CASL features.
|
|
8
5
|
*
|
|
9
6
|
* @example
|
|
10
7
|
* ```typescript
|
|
11
|
-
* import {
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* .apply(scope)
|
|
16
|
-
* .build();
|
|
8
|
+
* import { accessibleBy } from '@casl/prisma';
|
|
9
|
+
* import { registerCasl } from 'glasswork';
|
|
10
|
+
*
|
|
11
|
+
* registerCasl({ accessibleBy });
|
|
17
12
|
* ```
|
|
18
13
|
*/
|
|
19
|
-
export declare function
|
|
14
|
+
export declare function registerCasl(options: {
|
|
15
|
+
accessibleBy: CaslAccessibleBy;
|
|
16
|
+
}): void;
|
|
20
17
|
/**
|
|
21
18
|
* Create a CASL scope helper that extracts ability from context
|
|
22
19
|
*
|
|
20
|
+
* @param ability - The CASL ability instance
|
|
21
|
+
* @param action - The action to check ('read', 'update', etc.)
|
|
23
22
|
* @param subject - The Prisma model name (e.g., 'User', 'Organization')
|
|
24
|
-
* @returns
|
|
23
|
+
* @returns The CASL conditions object (Prisma where clause)
|
|
25
24
|
*
|
|
26
25
|
* @example
|
|
27
26
|
* ```typescript
|
|
28
|
-
* import {
|
|
29
|
-
*
|
|
30
|
-
* const params =
|
|
27
|
+
* import { accessibleBy } from '@casl/prisma';
|
|
28
|
+
*
|
|
29
|
+
* const params = createListQuery({ filter: UserFilterSchema })
|
|
31
30
|
* .parse(query)
|
|
32
|
-
* .
|
|
31
|
+
* .scope(withCaslScope(ability, 'read', 'User'))
|
|
33
32
|
* .build();
|
|
34
33
|
* ```
|
|
35
34
|
*/
|
|
36
|
-
export declare function withCaslScope
|
|
35
|
+
export declare function withCaslScope<TAbility = any>(ability: TAbility, action: string, subject: string): Record<string, unknown>;
|
|
37
36
|
//# sourceMappingURL=casl.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"casl.d.ts","sourceRoot":"","sources":["../../src/list-query/casl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"casl.d.ts","sourceRoot":"","sources":["../../src/list-query/casl.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,gBAAgB,GAAG,CAE7B,OAAO,EAAE,GAAG,EACZ,MAAM,CAAC,EAAE,MAAM,KACZ,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAU7C;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE;IAAE,YAAY,EAAE,gBAAgB,CAAA;CAAE,QAEvE;AAeD;;;;;;;;;;;;;;;;;GAiBG;AAEH,wBAAgB,aAAa,CAAC,QAAQ,GAAG,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,2BAY/F"}
|
package/dist/list-query/casl.js
CHANGED
|
@@ -1,66 +1,60 @@
|
|
|
1
|
+
const config = {
|
|
2
|
+
accessibleBy: null,
|
|
3
|
+
};
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*/
|
|
5
|
-
async function getAccessibleBy() {
|
|
6
|
-
try {
|
|
7
|
-
const { accessibleBy } = await import('@casl/prisma');
|
|
8
|
-
return accessibleBy;
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
throw new Error('@casl/prisma package not found. Install with: npm install @casl/ability @casl/prisma\n' +
|
|
12
|
-
'Note: @casl/ability is required as a peer dependency of @casl/prisma.');
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Create a CASL scope function that can be applied to a ListQueryBuilder
|
|
17
|
-
*
|
|
18
|
-
* @param ability - The CASL ability instance (any object that works with @casl/prisma's accessibleBy)
|
|
19
|
-
* @param subject - The Prisma model name (e.g., 'User', 'Organization')
|
|
20
|
-
* @returns A function that applies the CASL conditions to a builder
|
|
5
|
+
* Register CASL integration dependencies.
|
|
6
|
+
* This must be called at application startup if you use CASL features.
|
|
21
7
|
*
|
|
22
8
|
* @example
|
|
23
9
|
* ```typescript
|
|
24
|
-
* import {
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* .apply(scope)
|
|
29
|
-
* .build();
|
|
10
|
+
* import { accessibleBy } from '@casl/prisma';
|
|
11
|
+
* import { registerCasl } from 'glasswork';
|
|
12
|
+
*
|
|
13
|
+
* registerCasl({ accessibleBy });
|
|
30
14
|
* ```
|
|
31
15
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
16
|
+
export function registerCasl(options) {
|
|
17
|
+
config.accessibleBy = options.accessibleBy;
|
|
18
|
+
}
|
|
19
|
+
function getAccessibleBy() {
|
|
20
|
+
if (!config.accessibleBy) {
|
|
21
|
+
throw new Error('CASL integration not configured. You must call registerCasl({ accessibleBy }) at application startup.\n' +
|
|
22
|
+
'Example:\n' +
|
|
23
|
+
" import { accessibleBy } from '@casl/prisma';\n" +
|
|
24
|
+
" import { registerCasl } from 'glasswork';\n" +
|
|
25
|
+
' registerCasl({ accessibleBy });');
|
|
26
|
+
}
|
|
27
|
+
return config.accessibleBy;
|
|
39
28
|
}
|
|
40
29
|
/**
|
|
41
30
|
* Create a CASL scope helper that extracts ability from context
|
|
42
31
|
*
|
|
32
|
+
* @param ability - The CASL ability instance
|
|
33
|
+
* @param action - The action to check ('read', 'update', etc.)
|
|
43
34
|
* @param subject - The Prisma model name (e.g., 'User', 'Organization')
|
|
44
|
-
* @returns
|
|
35
|
+
* @returns The CASL conditions object (Prisma where clause)
|
|
45
36
|
*
|
|
46
37
|
* @example
|
|
47
38
|
* ```typescript
|
|
48
|
-
* import {
|
|
49
|
-
*
|
|
50
|
-
* const params =
|
|
39
|
+
* import { accessibleBy } from '@casl/prisma';
|
|
40
|
+
*
|
|
41
|
+
* const params = createListQuery({ filter: UserFilterSchema })
|
|
51
42
|
* .parse(query)
|
|
52
|
-
* .
|
|
43
|
+
* .scope(withCaslScope(ability, 'read', 'User'))
|
|
53
44
|
* .build();
|
|
54
45
|
* ```
|
|
55
46
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
return
|
|
64
|
-
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: Ability type is generic
|
|
48
|
+
export function withCaslScope(ability, action, subject) {
|
|
49
|
+
const accessibleBy = getAccessibleBy();
|
|
50
|
+
const conditions = accessibleBy(ability, action);
|
|
51
|
+
if (!(subject in conditions)) {
|
|
52
|
+
// Return empty object or undefined? Prisma generally assumes conditions exist if asked.
|
|
53
|
+
// If we assume accessibleBy returns all possible subjects, then missing key might be fine or error.
|
|
54
|
+
// Let's just return what is there, which is undefined if missing. The types expect Record<string, unknown>.
|
|
55
|
+
// Casting to allow potentially undefined access if user made a typo.
|
|
56
|
+
return conditions[subject];
|
|
57
|
+
}
|
|
58
|
+
return conditions[subject];
|
|
65
59
|
}
|
|
66
60
|
//# sourceMappingURL=casl.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"casl.js","sourceRoot":"","sources":["../../src/list-query/casl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"casl.js","sourceRoot":"","sources":["../../src/list-query/casl.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,GAAe;IACzB,YAAY,EAAE,IAAI;CACnB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,YAAY,CAAC,OAA2C;IACtE,MAAM,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAC7C,CAAC;AAED,SAAS,eAAe;IACtB,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,yGAAyG;YACvG,YAAY;YACZ,kDAAkD;YAClD,+CAA+C;YAC/C,mCAAmC,CACtC,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC,YAAY,CAAC;AAC7B,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,sEAAsE;AACtE,MAAM,UAAU,aAAa,CAAiB,OAAiB,EAAE,MAAc,EAAE,OAAe;IAC9F,MAAM,YAAY,GAAG,eAAe,EAAE,CAAC;IACvC,MAAM,UAAU,GAAG,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAEjD,IAAI,CAAC,CAAC,OAAO,IAAI,UAAU,CAAC,EAAE,CAAC;QAC7B,wFAAwF;QACxF,oGAAoG;QACpG,4GAA4G;QAC5G,qEAAqE;QACrE,OAAO,UAAU,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,UAAU,CAAC,OAAO,CAAC,CAAC;AAC7B,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface UploadPathConfig {
|
|
2
|
+
/** Directory path for the file (e.g., 'uploads/avatars/user123/abc123') */
|
|
3
|
+
readonly dir: string;
|
|
4
|
+
/** Sanitized file name (e.g., 'photo.jpg') */
|
|
5
|
+
readonly fileName: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Creates an upload configuration object for presigned URL generation.
|
|
9
|
+
* Validates IDs and generates a unique random folder to prevent overwrites.
|
|
10
|
+
*
|
|
11
|
+
* @param prefix - Upload folder prefix (e.g., 'avatars', 'documents')
|
|
12
|
+
* @param ids - Array of IDs to include in path (e.g., [userId] or [clientId, orderId])
|
|
13
|
+
* @param fileName - Sanitized filename (validate with sanitizedFileName schema first)
|
|
14
|
+
* @returns Configuration object with dir and fileName properties
|
|
15
|
+
* @throws BadRequestException if prefix is empty or IDs contain invalid characters
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* // In route handler:
|
|
20
|
+
* const config = createUploadConfig('avatars', [user.id], query.name);
|
|
21
|
+
* const result = await uploadsService.getSignedUploadUrl(config);
|
|
22
|
+
* // Result path: /uploads/avatars/{userId}/{randomId}/{filename}
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function createUploadConfig(prefix: string, ids: readonly string[], fileName: string): UploadPathConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Asserts that a file path belongs to the specified prefix and IDs.
|
|
28
|
+
* Use this to validate delete operations - ensures users can only delete their own files.
|
|
29
|
+
*
|
|
30
|
+
* @param path - The file path to validate (e.g., from query.name)
|
|
31
|
+
* @param prefix - The expected upload prefix (e.g., 'avatars', 'documents')
|
|
32
|
+
* @param ids - The expected IDs in the path
|
|
33
|
+
* @param errorMessage - Custom error message (default: 'Invalid file path')
|
|
34
|
+
* @throws ForbiddenException if path doesn't match expected structure
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* // In delete handler:
|
|
39
|
+
* assertUploadPathOwnership(query.path, 'avatars', [user.id]);
|
|
40
|
+
* await uploadsService.deleteFile(query.path);
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertUploadPathOwnership(path: string, prefix: string, ids: readonly string[], errorMessage?: string): void;
|
|
44
|
+
//# sourceMappingURL=file-upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-upload.d.ts","sourceRoot":"","sources":["../../src/uploads/file-upload.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,gBAAgB;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAMD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,SAAS,MAAM,EAAE,EACtB,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAoBlB;AAsED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,SAAS,MAAM,EAAE,EACtB,YAAY,SAAsB,GACjC,IAAI,CAYN"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { customAlphabet } from 'nanoid';
|
|
2
|
+
import { BadRequestException, ForbiddenException } from '../http/errors.js';
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Constants
|
|
5
|
+
// ============================================================================
|
|
6
|
+
const UPLOAD_RANDOM_ID_LENGTH = 16;
|
|
7
|
+
const MAX_PATH_LENGTH = 1024;
|
|
8
|
+
const NANOID_ALPHABET = '1234567890abcdef';
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Upload Configuration
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/**
|
|
13
|
+
* Creates an upload configuration object for presigned URL generation.
|
|
14
|
+
* Validates IDs and generates a unique random folder to prevent overwrites.
|
|
15
|
+
*
|
|
16
|
+
* @param prefix - Upload folder prefix (e.g., 'avatars', 'documents')
|
|
17
|
+
* @param ids - Array of IDs to include in path (e.g., [userId] or [clientId, orderId])
|
|
18
|
+
* @param fileName - Sanitized filename (validate with sanitizedFileName schema first)
|
|
19
|
+
* @returns Configuration object with dir and fileName properties
|
|
20
|
+
* @throws BadRequestException if prefix is empty or IDs contain invalid characters
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* // In route handler:
|
|
25
|
+
* const config = createUploadConfig('avatars', [user.id], query.name);
|
|
26
|
+
* const result = await uploadsService.getSignedUploadUrl(config);
|
|
27
|
+
* // Result path: /uploads/avatars/{userId}/{randomId}/{filename}
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function createUploadConfig(prefix, ids, fileName) {
|
|
31
|
+
if (!prefix || prefix.trim() === '') {
|
|
32
|
+
throw new BadRequestException('Prefix cannot be empty');
|
|
33
|
+
}
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
if (!id || typeof id !== 'string') {
|
|
36
|
+
throw new BadRequestException(`Invalid ID: ${id}`);
|
|
37
|
+
}
|
|
38
|
+
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
39
|
+
throw new BadRequestException(`Invalid character in ID: ${id}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/*
|
|
43
|
+
* Handle empty IDs array gracefully to avoid double slashes.
|
|
44
|
+
* If ids are present, join them with '/', otherwise use empty string.
|
|
45
|
+
*/
|
|
46
|
+
const idPath = ids.length > 0 ? `${ids.join('/')}/` : '';
|
|
47
|
+
const randomId = customAlphabet(NANOID_ALPHABET, UPLOAD_RANDOM_ID_LENGTH)();
|
|
48
|
+
const dir = `uploads/${prefix}/${idPath}${randomId}`;
|
|
49
|
+
return { dir, fileName };
|
|
50
|
+
}
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Path Ownership Validation
|
|
53
|
+
// ============================================================================
|
|
54
|
+
/**
|
|
55
|
+
* Validate IDs don't contain path traversal characters.
|
|
56
|
+
*/
|
|
57
|
+
function validateIdsForPath(ids, errorMessage) {
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
if (!id || typeof id !== 'string') {
|
|
60
|
+
throw new ForbiddenException(errorMessage);
|
|
61
|
+
}
|
|
62
|
+
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
63
|
+
throw new ForbiddenException(errorMessage);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if path contains control characters (security measure).
|
|
69
|
+
*/
|
|
70
|
+
function hasControlCharacters(path) {
|
|
71
|
+
for (let i = 0; i < path.length; i++) {
|
|
72
|
+
const code = path.charCodeAt(i);
|
|
73
|
+
if ((code >= 0x00 && code <= 0x1f) || code === 0x7f) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Decode and normalize path, rejecting traversal attempts.
|
|
81
|
+
*/
|
|
82
|
+
function decodeAndNormalizePath(path, errorMessage) {
|
|
83
|
+
let decodedPath;
|
|
84
|
+
try {
|
|
85
|
+
decodedPath = decodeURIComponent(path);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new ForbiddenException(errorMessage);
|
|
89
|
+
}
|
|
90
|
+
if (decodedPath.includes('..') || decodedPath.includes('./') || decodedPath.includes('.\\')) {
|
|
91
|
+
throw new ForbiddenException(errorMessage);
|
|
92
|
+
}
|
|
93
|
+
if (decodedPath.includes('\\')) {
|
|
94
|
+
throw new ForbiddenException(errorMessage);
|
|
95
|
+
}
|
|
96
|
+
const normalizedPath = decodedPath.replace(/\/+/g, '/');
|
|
97
|
+
if (normalizedPath !== decodedPath) {
|
|
98
|
+
throw new ForbiddenException(errorMessage);
|
|
99
|
+
}
|
|
100
|
+
return normalizedPath;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Validate relative path has a valid filename at the end.
|
|
104
|
+
*/
|
|
105
|
+
function validateRelativePath(relativePath, errorMessage) {
|
|
106
|
+
if (relativePath === '' || relativePath.endsWith('/')) {
|
|
107
|
+
throw new ForbiddenException(errorMessage);
|
|
108
|
+
}
|
|
109
|
+
const lastSlashIndex = relativePath.lastIndexOf('/');
|
|
110
|
+
const filename = lastSlashIndex >= 0 ? relativePath.slice(lastSlashIndex + 1) : relativePath;
|
|
111
|
+
if (!filename || filename.trim() === '') {
|
|
112
|
+
throw new ForbiddenException(errorMessage);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Asserts that a file path belongs to the specified prefix and IDs.
|
|
117
|
+
* Use this to validate delete operations - ensures users can only delete their own files.
|
|
118
|
+
*
|
|
119
|
+
* @param path - The file path to validate (e.g., from query.name)
|
|
120
|
+
* @param prefix - The expected upload prefix (e.g., 'avatars', 'documents')
|
|
121
|
+
* @param ids - The expected IDs in the path
|
|
122
|
+
* @param errorMessage - Custom error message (default: 'Invalid file path')
|
|
123
|
+
* @throws ForbiddenException if path doesn't match expected structure
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* // In delete handler:
|
|
128
|
+
* assertUploadPathOwnership(query.path, 'avatars', [user.id]);
|
|
129
|
+
* await uploadsService.deleteFile(query.path);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function assertUploadPathOwnership(path, prefix, ids, errorMessage = 'Invalid file path') {
|
|
133
|
+
validateIdsForPath(ids, errorMessage);
|
|
134
|
+
if (path.length > MAX_PATH_LENGTH || hasControlCharacters(path)) {
|
|
135
|
+
throw new ForbiddenException(errorMessage);
|
|
136
|
+
}
|
|
137
|
+
const normalizedPath = decodeAndNormalizePath(path, errorMessage);
|
|
138
|
+
const idPath = ids.length > 0 ? `${ids.join('/')}/` : '';
|
|
139
|
+
const expectedPrefix = `/uploads/${prefix}/${idPath}`;
|
|
140
|
+
if (!normalizedPath.startsWith(expectedPrefix)) {
|
|
141
|
+
throw new ForbiddenException(errorMessage);
|
|
142
|
+
}
|
|
143
|
+
validateRelativePath(normalizedPath.slice(expectedPrefix.length), errorMessage);
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=file-upload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-upload.js","sourceRoot":"","sources":["../../src/uploads/file-upload.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { UploadPathConfig } from './file-upload.js';
|
|
2
|
+
export { assertUploadPathOwnership, createUploadConfig } from './file-upload.js';
|
|
3
|
+
export type { SignedDownloadUrlResponse, SignedUrlResponse, StreamFileResult, UploadFileConfig, UploadsServiceConfig, } from './uploads.service.js';
|
|
4
|
+
export { UploadsService } from './uploads.service.js';
|
|
5
|
+
export { fileNameWithExtension, sanitizedFileName } from './validators.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/uploads/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACjF,YAAY,EACV,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Service
|
|
2
|
+
// Utilities
|
|
3
|
+
export { assertUploadPathOwnership, createUploadConfig } from './file-upload.js';
|
|
4
|
+
export { UploadsService } from './uploads.service.js';
|
|
5
|
+
// Validators
|
|
6
|
+
export { fileNameWithExtension, sanitizedFileName } from './validators.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/uploads/index.ts"],"names":[],"mappings":"AAAA,UAAU;AAGV,YAAY;AACZ,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAQjF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,aAAa;AACb,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
export interface UploadFileConfig {
|
|
3
|
+
/** Directory path for the file (e.g., 'uploads/avatars/user123/abc123') */
|
|
4
|
+
readonly dir: string;
|
|
5
|
+
/** Sanitized file name (e.g., 'photo.jpg') */
|
|
6
|
+
readonly fileName: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SignedUrlResponse {
|
|
9
|
+
/** Presigned URL for uploading the file */
|
|
10
|
+
readonly uploadUrl: string;
|
|
11
|
+
/** Relative path where the file will be stored (e.g., '/uploads/avatars/...') */
|
|
12
|
+
readonly path: string;
|
|
13
|
+
/** Expiration timestamp of the presigned URL */
|
|
14
|
+
readonly expiresAt: Date;
|
|
15
|
+
}
|
|
16
|
+
export interface SignedDownloadUrlResponse {
|
|
17
|
+
/** Presigned URL for downloading the file */
|
|
18
|
+
readonly downloadUrl: string;
|
|
19
|
+
/** Expiration timestamp of the presigned URL */
|
|
20
|
+
readonly expiresAt: Date;
|
|
21
|
+
}
|
|
22
|
+
export interface StreamFileResult {
|
|
23
|
+
/** Readable stream of the file contents */
|
|
24
|
+
readonly body: Readable;
|
|
25
|
+
/** MIME type of the file */
|
|
26
|
+
readonly contentType: string | undefined;
|
|
27
|
+
/** Size of the file in bytes */
|
|
28
|
+
readonly contentLength: number | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface UploadsServiceConfig {
|
|
31
|
+
/** AWS region (e.g., 'eu-west-1') */
|
|
32
|
+
readonly region: string;
|
|
33
|
+
/** S3 bucket name */
|
|
34
|
+
readonly bucketName: string;
|
|
35
|
+
/** Presigned URL expiration in seconds (default: 3600) */
|
|
36
|
+
readonly urlExpiration?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Service for S3 file operations including presigned URL generation and streaming.
|
|
40
|
+
* Framework-agnostic - can be used anywhere TypeScript runs.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* const uploadsService = new UploadsService({
|
|
45
|
+
* region: 'eu-west-1',
|
|
46
|
+
* bucketName: 'my-files-bucket',
|
|
47
|
+
* urlExpiration: 3600,
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Generate presigned upload URL
|
|
51
|
+
* const { uploadUrl, path } = await uploadsService.getSignedUploadUrl({
|
|
52
|
+
* dir: 'uploads/avatars/user123',
|
|
53
|
+
* fileName: 'photo.jpg',
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // Stream file for private access
|
|
57
|
+
* const { body, contentType } = await uploadsService.streamFile('/uploads/docs/file.pdf');
|
|
58
|
+
*
|
|
59
|
+
* // Generate presigned download URL for private files
|
|
60
|
+
* const { downloadUrl } = await uploadsService.getSignedDownloadUrl('/uploads/docs/private.pdf');
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare class UploadsService {
|
|
64
|
+
private readonly client;
|
|
65
|
+
private readonly bucketName;
|
|
66
|
+
private readonly urlExpiration;
|
|
67
|
+
constructor(config: UploadsServiceConfig);
|
|
68
|
+
/**
|
|
69
|
+
* Generate a presigned URL for uploading a file to S3.
|
|
70
|
+
* The URL allows direct upload from the client without going through Lambda.
|
|
71
|
+
*/
|
|
72
|
+
getSignedUploadUrl(fileConfig: UploadFileConfig): Promise<SignedUrlResponse>;
|
|
73
|
+
/**
|
|
74
|
+
* Stream a file from S3 for private file access.
|
|
75
|
+
* Returns a readable stream that can be piped to the HTTP response.
|
|
76
|
+
*
|
|
77
|
+
* **Note**: Lambda has a 6MB response payload limit for synchronous invocations.
|
|
78
|
+
* For larger files, consider using presigned GET URLs with short expiration.
|
|
79
|
+
*/
|
|
80
|
+
streamFile(filePath: string): Promise<StreamFileResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Generate a presigned URL for downloading a file from S3.
|
|
83
|
+
* Use this instead of streaming for files that may exceed Lambda's 6MB response limit.
|
|
84
|
+
*
|
|
85
|
+
* @param filePath - Relative path to the file (e.g., '/uploads/docs/file.pdf')
|
|
86
|
+
* @param expiresIn - Optional expiration in seconds (default: service urlExpiration)
|
|
87
|
+
*/
|
|
88
|
+
getSignedDownloadUrl(filePath: string, expiresIn?: number): Promise<SignedDownloadUrlResponse>;
|
|
89
|
+
/**
|
|
90
|
+
* Delete a file from S3 by path.
|
|
91
|
+
*/
|
|
92
|
+
deleteFile(filePath: string): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Normalize file path to S3 key format (strip leading slashes).
|
|
95
|
+
*/
|
|
96
|
+
private normalizeKey;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=uploads.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uploads.service.d.ts","sourceRoot":"","sources":["../../src/uploads/uploads.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAe5C,MAAM,WAAW,gBAAgB;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,iFAAiF;IACjF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,yBAAyB;IACxC,6CAA6C;IAC7C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gDAAgD;IAChD,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,gCAAgC;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5C;AAED,MAAM,WAAW,oBAAoB;IACnC,qCAAqC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,qBAAqB;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAaD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;gBAE3B,MAAM,EAAE,oBAAoB;IAMxC;;;OAGG;IACG,kBAAkB,CAAC,UAAU,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAgBlF;;;;;;OAMG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAsB7D;;;;;;OAMG;IACG,oBAAoB,CACxB,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,yBAAyB,CAAC;IAgBrC;;OAEG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASjD;;OAEG;IACH,OAAO,CAAC,YAAY;CAGrB"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client, S3ServiceException, } from '@aws-sdk/client-s3';
|
|
2
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
|
+
import { NotFoundException } from '../http/errors.js';
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Constants
|
|
6
|
+
// ============================================================================
|
|
7
|
+
const DEFAULT_URL_EXPIRATION = 3600; // 1 hour
|
|
8
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Service
|
|
11
|
+
// ============================================================================
|
|
12
|
+
/**
|
|
13
|
+
* Service for S3 file operations including presigned URL generation and streaming.
|
|
14
|
+
* Framework-agnostic - can be used anywhere TypeScript runs.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const uploadsService = new UploadsService({
|
|
19
|
+
* region: 'eu-west-1',
|
|
20
|
+
* bucketName: 'my-files-bucket',
|
|
21
|
+
* urlExpiration: 3600,
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Generate presigned upload URL
|
|
25
|
+
* const { uploadUrl, path } = await uploadsService.getSignedUploadUrl({
|
|
26
|
+
* dir: 'uploads/avatars/user123',
|
|
27
|
+
* fileName: 'photo.jpg',
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Stream file for private access
|
|
31
|
+
* const { body, contentType } = await uploadsService.streamFile('/uploads/docs/file.pdf');
|
|
32
|
+
*
|
|
33
|
+
* // Generate presigned download URL for private files
|
|
34
|
+
* const { downloadUrl } = await uploadsService.getSignedDownloadUrl('/uploads/docs/private.pdf');
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class UploadsService {
|
|
38
|
+
client;
|
|
39
|
+
bucketName;
|
|
40
|
+
urlExpiration;
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.client = new S3Client({ region: config.region });
|
|
43
|
+
this.bucketName = config.bucketName;
|
|
44
|
+
this.urlExpiration = config.urlExpiration ?? DEFAULT_URL_EXPIRATION;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Generate a presigned URL for uploading a file to S3.
|
|
48
|
+
* The URL allows direct upload from the client without going through Lambda.
|
|
49
|
+
*/
|
|
50
|
+
async getSignedUploadUrl(fileConfig) {
|
|
51
|
+
const filePath = `${fileConfig.dir}/${fileConfig.fileName}`;
|
|
52
|
+
const command = new PutObjectCommand({
|
|
53
|
+
Bucket: this.bucketName,
|
|
54
|
+
Key: filePath,
|
|
55
|
+
});
|
|
56
|
+
const url = await getSignedUrl(this.client, command, {
|
|
57
|
+
expiresIn: this.urlExpiration,
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
uploadUrl: url,
|
|
61
|
+
path: `/${filePath}`,
|
|
62
|
+
expiresAt: new Date(Date.now() + this.urlExpiration * MILLISECONDS_PER_SECOND),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Stream a file from S3 for private file access.
|
|
67
|
+
* Returns a readable stream that can be piped to the HTTP response.
|
|
68
|
+
*
|
|
69
|
+
* **Note**: Lambda has a 6MB response payload limit for synchronous invocations.
|
|
70
|
+
* For larger files, consider using presigned GET URLs with short expiration.
|
|
71
|
+
*/
|
|
72
|
+
async streamFile(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
const key = this.normalizeKey(filePath);
|
|
75
|
+
const command = new GetObjectCommand({
|
|
76
|
+
Bucket: this.bucketName,
|
|
77
|
+
Key: key,
|
|
78
|
+
});
|
|
79
|
+
const response = await this.client.send(command);
|
|
80
|
+
const body = response.Body;
|
|
81
|
+
return {
|
|
82
|
+
body,
|
|
83
|
+
contentType: response.ContentType,
|
|
84
|
+
contentLength: response.ContentLength,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof S3ServiceException && error.name === 'NoSuchKey') {
|
|
89
|
+
throw new NotFoundException('File not found');
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Generate a presigned URL for downloading a file from S3.
|
|
96
|
+
* Use this instead of streaming for files that may exceed Lambda's 6MB response limit.
|
|
97
|
+
*
|
|
98
|
+
* @param filePath - Relative path to the file (e.g., '/uploads/docs/file.pdf')
|
|
99
|
+
* @param expiresIn - Optional expiration in seconds (default: service urlExpiration)
|
|
100
|
+
*/
|
|
101
|
+
async getSignedDownloadUrl(filePath, expiresIn) {
|
|
102
|
+
const key = this.normalizeKey(filePath);
|
|
103
|
+
const expiration = expiresIn ?? this.urlExpiration;
|
|
104
|
+
const command = new GetObjectCommand({
|
|
105
|
+
Bucket: this.bucketName,
|
|
106
|
+
Key: key,
|
|
107
|
+
});
|
|
108
|
+
const url = await getSignedUrl(this.client, command, {
|
|
109
|
+
expiresIn: expiration,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
downloadUrl: url,
|
|
113
|
+
expiresAt: new Date(Date.now() + expiration * MILLISECONDS_PER_SECOND),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Delete a file from S3 by path.
|
|
118
|
+
*/
|
|
119
|
+
async deleteFile(filePath) {
|
|
120
|
+
const key = this.normalizeKey(filePath);
|
|
121
|
+
const command = new DeleteObjectCommand({
|
|
122
|
+
Bucket: this.bucketName,
|
|
123
|
+
Key: key,
|
|
124
|
+
});
|
|
125
|
+
await this.client.send(command);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Normalize file path to S3 key format (strip leading slashes).
|
|
129
|
+
*/
|
|
130
|
+
normalizeKey(filePath) {
|
|
131
|
+
return filePath.replace(/^\/+/, '').replace(/\/+/g, '/');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=uploads.service.js.map
|