secure-s3-storage 1.0.1
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/LICENSE +21 -0
- package/README.en.md +131 -0
- package/README.md +133 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +349 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nemovim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# secure-s3-storage
|
|
2
|
+
|
|
3
|
+
S3-backed file upload module with content validation, category-based paths, UUID filenames, and date-based (`YYYY-MM-DD`) object keys.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install secure-s3-storage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { init } from "secure-s3-storage";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
|
|
17
|
+
const storage = init({
|
|
18
|
+
bucket: "my-bucket",
|
|
19
|
+
region: "ap-northeast-2",
|
|
20
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
21
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
22
|
+
categories: {
|
|
23
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
24
|
+
documents: ["pdf", "txt", "md"],
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const result = await storage.upload(file);
|
|
29
|
+
|
|
30
|
+
const body = await readFile("./photo.png");
|
|
31
|
+
await storage.put("images", body, "image/png");
|
|
32
|
+
|
|
33
|
+
await storage.remove(result.key);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you use temporary AWS credentials, pass `sessionToken` as well.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const storage = init({
|
|
40
|
+
bucket: "my-bucket",
|
|
41
|
+
region: "ap-northeast-2",
|
|
42
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
43
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
44
|
+
sessionToken: process.env.AWS_SESSION_TOKEN!,
|
|
45
|
+
categories: {
|
|
46
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
47
|
+
documents: ["pdf", "txt", "md"],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### `init(options)`
|
|
55
|
+
|
|
56
|
+
Creates a storage instance.
|
|
57
|
+
|
|
58
|
+
- `bucket`: S3 bucket name
|
|
59
|
+
- `region`: S3 region
|
|
60
|
+
- `accessKeyId` / `secretAccessKey`: AWS keys. Passing only one throws an error.
|
|
61
|
+
- `sessionToken`: Only needed for temporary AWS credentials.
|
|
62
|
+
- `categories`: Mapping of category names to allowed file extensions
|
|
63
|
+
|
|
64
|
+
Example `categories`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
{
|
|
68
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
69
|
+
documents: ["pdf", "txt", "md"],
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Behavior:
|
|
74
|
+
|
|
75
|
+
1. The file extension decides which category to use.
|
|
76
|
+
2. The category name becomes the S3 key prefix and must be a valid key path segment.
|
|
77
|
+
3. Unlisted extensions are rejected with `StorageValidationError`.
|
|
78
|
+
4. If the same extension appears in multiple categories, the first one wins.
|
|
79
|
+
|
|
80
|
+
### `storage.upload(file)`
|
|
81
|
+
|
|
82
|
+
Uploads a browser `File`.
|
|
83
|
+
|
|
84
|
+
- Returns: `UploadResult`
|
|
85
|
+
- `file`: `{ arrayBuffer(): Promise<ArrayBuffer>; name: string; type?: string }`
|
|
86
|
+
|
|
87
|
+
### `storage.put(path, body, contentType?)`
|
|
88
|
+
|
|
89
|
+
Uploads a server-side `Buffer`.
|
|
90
|
+
|
|
91
|
+
- Returns: `UploadResult`
|
|
92
|
+
|
|
93
|
+
### `storage.remove(key)`
|
|
94
|
+
|
|
95
|
+
Deletes an S3 object by full object key.
|
|
96
|
+
|
|
97
|
+
### `storage.getUrl(key)`
|
|
98
|
+
|
|
99
|
+
Builds the public URL for an object key.
|
|
100
|
+
|
|
101
|
+
## Validation And Errors
|
|
102
|
+
|
|
103
|
+
- Validates file content against the file extension.
|
|
104
|
+
- Blocks dangerous executable and script-like extensions.
|
|
105
|
+
- `upload()` validates file content and filename together.
|
|
106
|
+
- `remove()` and `getUrl()` validate empty keys, normalize separators and repeated slashes, and reject `.` and `..` path segments.
|
|
107
|
+
|
|
108
|
+
Validation failures throw `StorageValidationError`.
|
|
109
|
+
|
|
110
|
+
Common error cases:
|
|
111
|
+
|
|
112
|
+
- Unknown category
|
|
113
|
+
- Disallowed extension
|
|
114
|
+
- File content does not match the extension
|
|
115
|
+
- Empty key, invalid path segments, or path traversal in a key
|
|
116
|
+
|
|
117
|
+
## Output Types
|
|
118
|
+
|
|
119
|
+
### `UploadResult`
|
|
120
|
+
|
|
121
|
+
Return value from `storage.upload()` and `storage.put()`.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
{
|
|
125
|
+
bucket: string;
|
|
126
|
+
key: string;
|
|
127
|
+
filename: string;
|
|
128
|
+
extension: string;
|
|
129
|
+
contentType?: string;
|
|
130
|
+
}
|
|
131
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# secure-s3-storage
|
|
2
|
+
|
|
3
|
+
[English README](./README.en.md)
|
|
4
|
+
|
|
5
|
+
S3 기반 파일 업로드 모듈입니다. 파일 내용을 검증하고, 카테고리별 경로로 업로드하며, UUID 파일명과 날짜 기반(`YYYY-MM-DD`) object key를 생성합니다.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install secure-s3-storage
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { init } from "secure-s3-storage";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
|
|
19
|
+
const storage = init({
|
|
20
|
+
bucket: "my-bucket",
|
|
21
|
+
region: "ap-northeast-2",
|
|
22
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
23
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
24
|
+
categories: {
|
|
25
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
26
|
+
documents: ["pdf", "txt", "md"],
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await storage.upload(file);
|
|
31
|
+
|
|
32
|
+
const body = await readFile("./photo.png");
|
|
33
|
+
await storage.put("images", body, "image/png");
|
|
34
|
+
|
|
35
|
+
await storage.remove(result.key);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
임시 AWS credential을 쓰는 경우에는 `sessionToken`도 함께 넣으면 됩니다.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const storage = init({
|
|
42
|
+
bucket: "my-bucket",
|
|
43
|
+
region: "ap-northeast-2",
|
|
44
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
45
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
46
|
+
sessionToken: process.env.AWS_SESSION_TOKEN!,
|
|
47
|
+
categories: {
|
|
48
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
49
|
+
documents: ["pdf", "txt", "md"],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
### `init(options)`
|
|
57
|
+
|
|
58
|
+
스토리지 인스턴스를 생성합니다.
|
|
59
|
+
|
|
60
|
+
- `bucket`: S3 버킷 이름
|
|
61
|
+
- `region`: S3 리전
|
|
62
|
+
- `accessKeyId` / `secretAccessKey`: AWS key입니다. 둘 중 하나만 넣으면 에러가 발생합니다.
|
|
63
|
+
- `sessionToken`: 임시 AWS credential을 사용할 때만 필요합니다.
|
|
64
|
+
- `categories`: 카테고리 이름과 허용할 확장자 목록입니다.
|
|
65
|
+
|
|
66
|
+
`categories` 예시:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
{
|
|
70
|
+
images: ["jpg", "jpeg", "png", "webp"],
|
|
71
|
+
documents: ["pdf", "txt", "md"],
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
동작 방식:
|
|
76
|
+
|
|
77
|
+
1. 파일 확장자를 기준으로 업로드할 카테고리를 결정합니다.
|
|
78
|
+
2. 카테고리 이름이 S3 key prefix로 사용되며, 유효한 key path segment 형태여야 합니다.
|
|
79
|
+
3. 허용되지 않은 확장자는 `StorageValidationError`와 함께 차단됩니다.
|
|
80
|
+
4. 같은 확장자가 여러 카테고리에 있으면 먼저 정의한 카테고리를 사용합니다.
|
|
81
|
+
|
|
82
|
+
### `storage.upload(file)`
|
|
83
|
+
|
|
84
|
+
브라우저 `File` 객체를 업로드합니다.
|
|
85
|
+
|
|
86
|
+
- 반환값: `UploadResult`
|
|
87
|
+
- `file`: `{ arrayBuffer(): Promise<ArrayBuffer>; name: string; type?: string }`
|
|
88
|
+
|
|
89
|
+
### `storage.put(path, body, contentType?)`
|
|
90
|
+
|
|
91
|
+
서버에서 `Buffer`를 직접 업로드합니다.
|
|
92
|
+
|
|
93
|
+
- 반환값: `UploadResult`
|
|
94
|
+
|
|
95
|
+
### `storage.remove(key)`
|
|
96
|
+
|
|
97
|
+
저장된 전체 object key를 받아 S3 객체를 삭제합니다.
|
|
98
|
+
|
|
99
|
+
### `storage.getUrl(key)`
|
|
100
|
+
|
|
101
|
+
object key 기준으로 공개 URL을 생성합니다.
|
|
102
|
+
|
|
103
|
+
## Validation And Errors
|
|
104
|
+
|
|
105
|
+
- 파일 내용과 확장자를 비교해 검증합니다.
|
|
106
|
+
- 실행 파일, 스크립트 같은 위험한 확장자는 차단합니다.
|
|
107
|
+
- `upload()`는 파일 내용과 파일명까지 함께 보고 더 엄격하게 검증합니다.
|
|
108
|
+
- `remove()`와 `getUrl()`은 빈 key를 막고, 경로 구분자와 중복 슬래시를 정규화하며, `.` 및 `..` 경로 세그먼트를 검증합니다.
|
|
109
|
+
|
|
110
|
+
검증에 실패하면 `StorageValidationError`가 발생합니다.
|
|
111
|
+
|
|
112
|
+
주요 에러 상황:
|
|
113
|
+
|
|
114
|
+
- 알 수 없는 카테고리
|
|
115
|
+
- 허용되지 않은 확장자
|
|
116
|
+
- 파일 내용과 확장자가 맞지 않는 경우
|
|
117
|
+
- key가 비어 있거나 잘못된 path segment 또는 path traversal이 포함된 경우
|
|
118
|
+
|
|
119
|
+
## Output Types
|
|
120
|
+
|
|
121
|
+
### `UploadResult`
|
|
122
|
+
|
|
123
|
+
`storage.upload()`와 `storage.put()`의 반환값입니다.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
{
|
|
127
|
+
bucket: string;
|
|
128
|
+
key: string;
|
|
129
|
+
filename: string;
|
|
130
|
+
extension: string;
|
|
131
|
+
contentType?: string;
|
|
132
|
+
}
|
|
133
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
export type StorageInitOptions = {
|
|
3
|
+
bucket: string;
|
|
4
|
+
accessKeyId?: string;
|
|
5
|
+
secretAccessKey?: string;
|
|
6
|
+
sessionToken?: string;
|
|
7
|
+
} & Omit<S3ClientConfig, "credentials"> & {
|
|
8
|
+
categories: Record<string, string[]>;
|
|
9
|
+
};
|
|
10
|
+
export type BrowserFile = {
|
|
11
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
12
|
+
name: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
};
|
|
15
|
+
export type UploadResult = {
|
|
16
|
+
bucket: string;
|
|
17
|
+
key: string;
|
|
18
|
+
filename: string;
|
|
19
|
+
extension: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
};
|
|
22
|
+
export type Storage = ReturnType<typeof init>;
|
|
23
|
+
export declare class StorageValidationError extends Error {
|
|
24
|
+
constructor(message: string);
|
|
25
|
+
}
|
|
26
|
+
export declare function init(options: StorageInitOptions): {
|
|
27
|
+
put: (path: string, file: Buffer, contentType?: string) => Promise<UploadResult>;
|
|
28
|
+
upload: (file: BrowserFile) => Promise<UploadResult>;
|
|
29
|
+
remove: (key: string) => Promise<void>;
|
|
30
|
+
getUrl: (key: string) => string;
|
|
31
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { DeleteObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
4
|
+
import { lookup as lookupMimeType, extension as mimeExtension } from "mime-types";
|
|
5
|
+
const DANGEROUS_EXTENSIONS = new Set([
|
|
6
|
+
"ade",
|
|
7
|
+
"adp",
|
|
8
|
+
"app",
|
|
9
|
+
"asp",
|
|
10
|
+
"aspx",
|
|
11
|
+
"bat",
|
|
12
|
+
"cer",
|
|
13
|
+
"cgi",
|
|
14
|
+
"chm",
|
|
15
|
+
"cmd",
|
|
16
|
+
"com",
|
|
17
|
+
"cpl",
|
|
18
|
+
"crt",
|
|
19
|
+
"cshtml",
|
|
20
|
+
"dll",
|
|
21
|
+
"drv",
|
|
22
|
+
"exe",
|
|
23
|
+
"hta",
|
|
24
|
+
"htaccess",
|
|
25
|
+
"htm",
|
|
26
|
+
"html",
|
|
27
|
+
"inf",
|
|
28
|
+
"iso",
|
|
29
|
+
"jar",
|
|
30
|
+
"js",
|
|
31
|
+
"jsp",
|
|
32
|
+
"jsx",
|
|
33
|
+
"lnk",
|
|
34
|
+
"mht",
|
|
35
|
+
"mhtml",
|
|
36
|
+
"mjs",
|
|
37
|
+
"msi",
|
|
38
|
+
"msix",
|
|
39
|
+
"php",
|
|
40
|
+
"phar",
|
|
41
|
+
"php3",
|
|
42
|
+
"php4",
|
|
43
|
+
"php5",
|
|
44
|
+
"phtml",
|
|
45
|
+
"pl",
|
|
46
|
+
"ps1",
|
|
47
|
+
"py",
|
|
48
|
+
"rb",
|
|
49
|
+
"reg",
|
|
50
|
+
"scr",
|
|
51
|
+
"sh",
|
|
52
|
+
"svg",
|
|
53
|
+
"svgz",
|
|
54
|
+
"swf",
|
|
55
|
+
"ts",
|
|
56
|
+
"tsx",
|
|
57
|
+
"vb",
|
|
58
|
+
"vbe",
|
|
59
|
+
"vbs",
|
|
60
|
+
"war",
|
|
61
|
+
"ws",
|
|
62
|
+
"wsc",
|
|
63
|
+
"wsf",
|
|
64
|
+
"wsh",
|
|
65
|
+
"xhtml",
|
|
66
|
+
"xml",
|
|
67
|
+
]);
|
|
68
|
+
const TEXT_EXTENSIONS = new Set(["csv", "json", "md", "txt", "yml", "yaml"]);
|
|
69
|
+
export class StorageValidationError extends Error {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "StorageValidationError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function init(options) {
|
|
76
|
+
const { bucket, categories, accessKeyId, secretAccessKey, sessionToken, ...s3Config } = options;
|
|
77
|
+
const normalizedAccessKeyId = typeof accessKeyId === "string" ? accessKeyId.trim() : undefined;
|
|
78
|
+
const normalizedSecretAccessKey = typeof secretAccessKey === "string" ? secretAccessKey.trim() : undefined;
|
|
79
|
+
const normalizedSessionToken = typeof sessionToken === "string" ? sessionToken.trim() : undefined;
|
|
80
|
+
const hasAccessKeyId = typeof normalizedAccessKeyId === "string" && normalizedAccessKeyId.length > 0;
|
|
81
|
+
const hasSecretAccessKey = typeof normalizedSecretAccessKey === "string" && normalizedSecretAccessKey.length > 0;
|
|
82
|
+
if (hasAccessKeyId !== hasSecretAccessKey) {
|
|
83
|
+
throw new StorageValidationError("accessKeyId and secretAccessKey must be provided together.");
|
|
84
|
+
}
|
|
85
|
+
const client = new S3Client({
|
|
86
|
+
...s3Config,
|
|
87
|
+
...(hasAccessKeyId && hasSecretAccessKey
|
|
88
|
+
? {
|
|
89
|
+
credentials: {
|
|
90
|
+
accessKeyId: normalizedAccessKeyId,
|
|
91
|
+
secretAccessKey: normalizedSecretAccessKey,
|
|
92
|
+
...(normalizedSessionToken ? { sessionToken: normalizedSessionToken } : {}),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
const normalizedPaths = normalizePathConfigs(categories);
|
|
98
|
+
const region = s3Config.region || 'us-east-1';
|
|
99
|
+
const extensionToPath = new Map();
|
|
100
|
+
for (const [name, cfg] of Object.entries(normalizedPaths)) {
|
|
101
|
+
for (const ext of cfg.allowedExtensions) {
|
|
102
|
+
if (!extensionToPath.has(ext))
|
|
103
|
+
extensionToPath.set(ext, name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function put(path, file, contentType) {
|
|
107
|
+
const config = getPathConfig(normalizedPaths, path);
|
|
108
|
+
const detected = await fileTypeFromBuffer(file);
|
|
109
|
+
let extension = detected?.ext || "";
|
|
110
|
+
if (!extension && contentType) {
|
|
111
|
+
const fromMime = mimeExtension(contentType);
|
|
112
|
+
if (typeof fromMime === "string") {
|
|
113
|
+
extension = fromMime;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!extension) {
|
|
117
|
+
throw new StorageValidationError("Could not determine file extension for put().");
|
|
118
|
+
}
|
|
119
|
+
if (DANGEROUS_EXTENSIONS.has(extension)) {
|
|
120
|
+
throw new StorageValidationError(`Files with .${extension} extension are blocked.`);
|
|
121
|
+
}
|
|
122
|
+
if (!config.allowedExtensions.has(extension)) {
|
|
123
|
+
throw new StorageValidationError(`.${extension} is not allowed for this storage path.`);
|
|
124
|
+
}
|
|
125
|
+
const filename = buildGeneratedFilename(extension);
|
|
126
|
+
const datePrefix = getDatePrefix();
|
|
127
|
+
const prefixWithDate = `${datePrefix}/${config.prefix}`;
|
|
128
|
+
const key = buildObjectKey(prefixWithDate, filename);
|
|
129
|
+
const finalContentType = contentType ?? detected?.mime ?? getContentTypeFromFilename(filename);
|
|
130
|
+
await client.send(new PutObjectCommand({
|
|
131
|
+
Bucket: bucket,
|
|
132
|
+
Key: key,
|
|
133
|
+
Body: file,
|
|
134
|
+
ContentType: finalContentType,
|
|
135
|
+
}));
|
|
136
|
+
return {
|
|
137
|
+
bucket,
|
|
138
|
+
key,
|
|
139
|
+
filename,
|
|
140
|
+
extension,
|
|
141
|
+
contentType: finalContentType,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function upload(file) {
|
|
145
|
+
const normalized = await normalizeUploadFile(file);
|
|
146
|
+
const extension = normalized.filename ? getExtension(normalized.filename) : "";
|
|
147
|
+
if (!extension) {
|
|
148
|
+
throw new StorageValidationError("Could not determine file extension for upload(). Provide a File with a filename or detectable content.");
|
|
149
|
+
}
|
|
150
|
+
const pathName = extensionToPath.get(extension);
|
|
151
|
+
if (!pathName) {
|
|
152
|
+
throw new StorageValidationError(`No storage path configured for .${extension} files.`);
|
|
153
|
+
}
|
|
154
|
+
const config = getPathConfig(normalizedPaths, pathName);
|
|
155
|
+
const resolved = await resolveUploadTarget({
|
|
156
|
+
allowedExtensions: config.allowedExtensions,
|
|
157
|
+
body: normalized.body,
|
|
158
|
+
requestedFilename: normalized.filename,
|
|
159
|
+
sourceFilename: normalized.filename,
|
|
160
|
+
sourceContentType: normalized.contentType,
|
|
161
|
+
});
|
|
162
|
+
const datePrefix = getDatePrefix();
|
|
163
|
+
const prefixWithDate = `${datePrefix}/${config.prefix}`;
|
|
164
|
+
const key = buildObjectKey(prefixWithDate, resolved.filename);
|
|
165
|
+
await client.send(new PutObjectCommand({
|
|
166
|
+
Bucket: bucket,
|
|
167
|
+
Key: key,
|
|
168
|
+
Body: normalized.body,
|
|
169
|
+
ContentType: resolved.contentType,
|
|
170
|
+
}));
|
|
171
|
+
return {
|
|
172
|
+
bucket,
|
|
173
|
+
key,
|
|
174
|
+
filename: resolved.filename,
|
|
175
|
+
extension: resolved.extension,
|
|
176
|
+
contentType: resolved.contentType,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
async function remove(key) {
|
|
180
|
+
const normalizedKey = normalizeObjectKeyInput(key);
|
|
181
|
+
await client.send(new DeleteObjectCommand({
|
|
182
|
+
Bucket: bucket,
|
|
183
|
+
Key: normalizedKey,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
function getUrl(key) {
|
|
187
|
+
const normalizedKey = normalizeObjectKeyInput(key);
|
|
188
|
+
const endpoint = region === 'us-east-1'
|
|
189
|
+
? `https://${bucket}.s3.amazonaws.com`
|
|
190
|
+
: `https://${bucket}.s3.${region}.amazonaws.com`;
|
|
191
|
+
return `${endpoint}/${encodeObjectKey(normalizedKey)}`;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
put,
|
|
195
|
+
upload,
|
|
196
|
+
remove,
|
|
197
|
+
getUrl,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function normalizePathConfigs(categories) {
|
|
201
|
+
return Object.fromEntries(Object.entries(categories).map(([name, extensions]) => [
|
|
202
|
+
name,
|
|
203
|
+
{
|
|
204
|
+
prefix: normalizeCategoryPrefix(name),
|
|
205
|
+
allowedExtensions: new Set(extensions.map(normalizeExtension)),
|
|
206
|
+
},
|
|
207
|
+
]));
|
|
208
|
+
}
|
|
209
|
+
function getPathConfig(paths, path) {
|
|
210
|
+
const config = paths[path];
|
|
211
|
+
if (!config) {
|
|
212
|
+
throw new StorageValidationError(`Unknown storage path: ${path}`);
|
|
213
|
+
}
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
async function resolveUploadTarget(input) {
|
|
217
|
+
const requestedExtension = input.requestedFilename
|
|
218
|
+
? getExtension(input.requestedFilename)
|
|
219
|
+
: "";
|
|
220
|
+
const sourceExtension = input.sourceFilename ? getExtension(input.sourceFilename) : "";
|
|
221
|
+
const detected = await fileTypeFromBuffer(input.body);
|
|
222
|
+
const extension = detected?.ext || requestedExtension || sourceExtension;
|
|
223
|
+
if (!extension) {
|
|
224
|
+
throw new StorageValidationError("Could not determine file extension. Pass a File object or provide filename explicitly.");
|
|
225
|
+
}
|
|
226
|
+
if (DANGEROUS_EXTENSIONS.has(extension)) {
|
|
227
|
+
throw new StorageValidationError(`Files with .${extension} extension are blocked.`);
|
|
228
|
+
}
|
|
229
|
+
if (!input.allowedExtensions.has(extension)) {
|
|
230
|
+
throw new StorageValidationError(`.${extension} is not allowed for this storage path.`);
|
|
231
|
+
}
|
|
232
|
+
if (detected) {
|
|
233
|
+
if (requestedExtension && requestedExtension !== detected.ext) {
|
|
234
|
+
throw new StorageValidationError(`File content does not match the requested extension .${requestedExtension}.`);
|
|
235
|
+
}
|
|
236
|
+
if (sourceExtension && sourceExtension !== detected.ext) {
|
|
237
|
+
throw new StorageValidationError(`File content does not match the source extension .${sourceExtension}.`);
|
|
238
|
+
}
|
|
239
|
+
const expectedMime = getContentTypeFromFilename(`file.${extension}`);
|
|
240
|
+
if (expectedMime && detected.mime !== expectedMime) {
|
|
241
|
+
throw new StorageValidationError(`File content does not match the expected MIME type for .${extension}.`);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
extension,
|
|
245
|
+
filename: buildGeneratedFilename(extension),
|
|
246
|
+
contentType: detected.mime,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (TEXT_EXTENSIONS.has(extension) && isLikelyUtf8Text(input.body)) {
|
|
250
|
+
return {
|
|
251
|
+
extension,
|
|
252
|
+
filename: buildGeneratedFilename(extension),
|
|
253
|
+
contentType: input.sourceContentType ?? getContentTypeFromFilename(`file.${extension}`),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
throw new StorageValidationError(`Unable to validate file contents for .${extension}.`);
|
|
257
|
+
}
|
|
258
|
+
function buildObjectKey(prefix, filename, customKey) {
|
|
259
|
+
const target = customKey ? customKey.trim() : sanitizeFilename(filename);
|
|
260
|
+
if (!target) {
|
|
261
|
+
throw new StorageValidationError("A valid filename or key is required.");
|
|
262
|
+
}
|
|
263
|
+
const normalizedTarget = normalizeObjectKeyInput(target);
|
|
264
|
+
return ensureKeyInsidePrefix(prefix, normalizedTarget);
|
|
265
|
+
}
|
|
266
|
+
function ensureKeyInsidePrefix(prefix, key) {
|
|
267
|
+
const normalizedKey = normalizeObjectKeyInput(key);
|
|
268
|
+
if (!prefix) {
|
|
269
|
+
return normalizedKey;
|
|
270
|
+
}
|
|
271
|
+
if (normalizedKey === prefix || normalizedKey.startsWith(`${prefix}/`)) {
|
|
272
|
+
return normalizedKey;
|
|
273
|
+
}
|
|
274
|
+
return `${prefix}/${normalizedKey}`;
|
|
275
|
+
}
|
|
276
|
+
function normalizeCategoryPrefix(prefix) {
|
|
277
|
+
const normalizedPrefix = normalizeObjectKeyInput(prefix);
|
|
278
|
+
if (normalizedPrefix === ".") {
|
|
279
|
+
throw new StorageValidationError("Category prefix must not be a current-directory segment.");
|
|
280
|
+
}
|
|
281
|
+
return normalizedPrefix;
|
|
282
|
+
}
|
|
283
|
+
function normalizeObjectKeyInput(key) {
|
|
284
|
+
const collapsedKey = stripLeadingSlash(key.trim().replace(/\\/g, "/")).replace(/\/+/g, "/");
|
|
285
|
+
if (!collapsedKey) {
|
|
286
|
+
throw new StorageValidationError("Object key is required.");
|
|
287
|
+
}
|
|
288
|
+
const segments = collapsedKey.split("/");
|
|
289
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
290
|
+
throw new StorageValidationError("Object key contains invalid path segments.");
|
|
291
|
+
}
|
|
292
|
+
return segments.join("/");
|
|
293
|
+
}
|
|
294
|
+
function stripLeadingSlash(value) {
|
|
295
|
+
return value.replace(/^\/+/, "");
|
|
296
|
+
}
|
|
297
|
+
function getExtension(filename) {
|
|
298
|
+
const match = /\.([a-z0-9]+)$/i.exec(filename.trim());
|
|
299
|
+
return match ? normalizeExtension(match[1]) : "";
|
|
300
|
+
}
|
|
301
|
+
function normalizeExtension(extension) {
|
|
302
|
+
return extension.replace(/^\./, "").trim().toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
function sanitizeFilename(filename) {
|
|
305
|
+
return filename
|
|
306
|
+
.trim()
|
|
307
|
+
.replace(/[<>:"\\|?*\u0000-\u001f]/g, "-")
|
|
308
|
+
.replace(/\s+/g, "-")
|
|
309
|
+
.replace(/\/+/g, "-");
|
|
310
|
+
}
|
|
311
|
+
function encodeObjectKey(key) {
|
|
312
|
+
return stripLeadingSlash(key)
|
|
313
|
+
.split("/")
|
|
314
|
+
.map((segment) => encodeURIComponent(segment))
|
|
315
|
+
.join("/");
|
|
316
|
+
}
|
|
317
|
+
function getContentTypeFromFilename(filename) {
|
|
318
|
+
const mime = lookupMimeType(filename);
|
|
319
|
+
return typeof mime === "string" ? mime : undefined;
|
|
320
|
+
}
|
|
321
|
+
async function normalizeUploadFile(file) {
|
|
322
|
+
const body = Buffer.from(await file.arrayBuffer());
|
|
323
|
+
const contentType = file.type || undefined;
|
|
324
|
+
const filename = typeof file.name === "string" ? file.name : undefined;
|
|
325
|
+
return { body, filename, contentType };
|
|
326
|
+
}
|
|
327
|
+
function isLikelyUtf8Text(buffer) {
|
|
328
|
+
if (buffer.length === 0) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
let suspicious = 0;
|
|
332
|
+
for (const byte of buffer) {
|
|
333
|
+
const isControl = byte < 0x09 || (byte > 0x0d && byte < 0x20);
|
|
334
|
+
if (isControl) {
|
|
335
|
+
suspicious += 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return suspicious / buffer.length < 0.02;
|
|
339
|
+
}
|
|
340
|
+
function buildGeneratedFilename(extension) {
|
|
341
|
+
return `${randomUUID()}.${extension}`;
|
|
342
|
+
}
|
|
343
|
+
function getDatePrefix() {
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const year = now.getFullYear();
|
|
346
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
347
|
+
const date = String(now.getDate()).padStart(2, '0');
|
|
348
|
+
return `${year}-${month}-${date}`;
|
|
349
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "secure-s3-storage",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "S3-backed storage module with automatic file validation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"s3",
|
|
22
|
+
"storage",
|
|
23
|
+
"secure",
|
|
24
|
+
"upload",
|
|
25
|
+
"typescript"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@aws-sdk/client-s3": "^3.1054.0",
|
|
30
|
+
"file-type": "^22.0.1",
|
|
31
|
+
"mime-types": "^3.0.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/mime-types": "^3.0.1",
|
|
35
|
+
"@types/node": "^25.9.1",
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|