@goplusvn/core 0.1.57 → 0.1.58
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/PLATFORM.md +62 -0
- package/package.json +21 -2
- package/src/storage/__tests__/keys.test.ts +62 -0
- package/src/storage/__tests__/no-aws-in-barrel.test.ts +45 -0
- package/src/storage/__tests__/storage-service.test.ts +312 -0
- package/src/storage/file-proxy.ts +147 -0
- package/src/storage/index.ts +51 -0
- package/src/storage/keys.ts +105 -0
- package/src/storage/s3/index.ts +160 -0
- package/src/storage/storage-service.ts +280 -0
- package/src/storage/types.ts +111 -0
- package/src/storage/upload-handler.ts +100 -0
package/PLATFORM.md
CHANGED
|
@@ -193,6 +193,68 @@ await casUpdateById(
|
|
|
193
193
|
)
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
## File storage
|
|
197
|
+
|
|
198
|
+
One engine, two backends: a private local directory (default) or S3/MinIO. The
|
|
199
|
+
app configures it once in a dedicated `src/lib/storage.ts` and imports storage
|
|
200
|
+
*only through that file* — the engine is a singleton, so importing core directly
|
|
201
|
+
gives you the functions without the `configureStorage` call that arms them.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
// src/lib/storage.ts — the one door
|
|
205
|
+
import { configureStorage, type StorageDb } from "@goerp/core/storage"
|
|
206
|
+
import { db } from "@/lib/prisma"
|
|
207
|
+
|
|
208
|
+
configureStorage({ db: db as unknown as StorageDb }) // local disk: storage/files
|
|
209
|
+
export * from "@goerp/core/storage"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Both routes are factories — the app supplies only its authorization rule:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// app/api/files/[...key]/route.ts
|
|
216
|
+
export const GET = apiHandler<{ key: string[] }>(
|
|
217
|
+
createFileProxyHandler<Session>({
|
|
218
|
+
authorize: ({ key, session }) =>
|
|
219
|
+
key.startsWith("hop-dong/") ? checkPermission(session, "contract", "view") : true,
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// app/api/upload/route.ts
|
|
224
|
+
export const POST = apiHandler(createUploadHandler()) // 25MB, extension whitelist
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`authorize` returning `false` is 403; returning `{ status: 404 }` hides the
|
|
228
|
+
object's very existence. It runs *after* the key is validated, so a traversal
|
|
229
|
+
attempt never reaches app code. Uploads always return `/api/files/<key>` no
|
|
230
|
+
matter which backend is live, so switching to S3 later doesn't invalidate URLs
|
|
231
|
+
already stored in the DB — and the local directory is private, not
|
|
232
|
+
`public/uploads`: attachments are financial documents and ID scans, and a web
|
|
233
|
+
root serves them to anyone with the path.
|
|
234
|
+
|
|
235
|
+
S3/MinIO is opt-in because bundlers statically resolve dynamic imports — a
|
|
236
|
+
lazily-imported driver would still make `aws-sdk` a hard build dependency for
|
|
237
|
+
every app. It lives at its own subpath with the SDK as an *optional* peer:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { createS3Driver } from "@goerp/core/storage/s3"
|
|
241
|
+
|
|
242
|
+
configureStorage({
|
|
243
|
+
db: db as unknown as StorageDb,
|
|
244
|
+
driver: createS3Driver(),
|
|
245
|
+
requireRemote: process.env.NODE_ENV === "production",
|
|
246
|
+
})
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`requireRemote` makes a missing/broken S3 config *throw* instead of quietly
|
|
250
|
+
falling back to container-local disk — that fallback "succeeds" and then loses
|
|
251
|
+
every file on the next deploy. Credentials come from `system_configs`
|
|
252
|
+
(`STORAGE_TYPE=s3`, `S3_ENDPOINT`, `S3_PUBLIC_ENDPOINT`, `S3_REGION`,
|
|
253
|
+
`S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REJECT_UNAUTHORIZED`), cached
|
|
254
|
+
until `clearCache()`. The public endpoint is used for presigning only —
|
|
255
|
+
a presigned signature is bound to the host that signed it, so signing with the
|
|
256
|
+
internal endpoint yields URLs the browser cannot use.
|
|
257
|
+
|
|
196
258
|
## Utils
|
|
197
259
|
|
|
198
260
|
`@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.58",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -93,17 +93,25 @@
|
|
|
93
93
|
"./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
|
|
94
94
|
"./auth/auth-service": "./src/auth/auth-service.ts",
|
|
95
95
|
"./package.json": "./package.json",
|
|
96
|
-
"./providers/brand-theme": "./src/providers/brand-theme.ts"
|
|
96
|
+
"./providers/brand-theme": "./src/providers/brand-theme.ts",
|
|
97
|
+
"./storage": "./src/storage/index.ts",
|
|
98
|
+
"./storage/s3": "./src/storage/s3/index.ts"
|
|
97
99
|
},
|
|
98
100
|
"peerDependencies": {
|
|
101
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
102
|
+
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
103
|
+
"@smithy/node-http-handler": "^4.0.0",
|
|
99
104
|
"next": ">=14.0.0",
|
|
100
105
|
"react": "^18.0.0 || ^19.0.0",
|
|
101
106
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
102
107
|
},
|
|
103
108
|
"devDependencies": {
|
|
109
|
+
"@aws-sdk/client-s3": "^3.1101.0",
|
|
110
|
+
"@aws-sdk/s3-request-presigner": "^3.1101.0",
|
|
104
111
|
"@eslint/compat": "1.2.7",
|
|
105
112
|
"@eslint/js": "9.18.0",
|
|
106
113
|
"@next/eslint-plugin-next": "16.0.3",
|
|
114
|
+
"@smithy/node-http-handler": "^4.9.13",
|
|
107
115
|
"@testing-library/jest-dom": "^6.9.1",
|
|
108
116
|
"@testing-library/react": "^16.3.0",
|
|
109
117
|
"@types/bcryptjs": "^2.4.6",
|
|
@@ -198,6 +206,17 @@
|
|
|
198
206
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
|
199
207
|
"zod": "3.23.8"
|
|
200
208
|
},
|
|
209
|
+
"peerDependenciesMeta": {
|
|
210
|
+
"@aws-sdk/client-s3": {
|
|
211
|
+
"optional": true
|
|
212
|
+
},
|
|
213
|
+
"@aws-sdk/s3-request-presigner": {
|
|
214
|
+
"optional": true
|
|
215
|
+
},
|
|
216
|
+
"@smithy/node-http-handler": {
|
|
217
|
+
"optional": true
|
|
218
|
+
}
|
|
219
|
+
},
|
|
201
220
|
"scripts": {
|
|
202
221
|
"build": "NODE_OPTIONS='--max-old-space-size=10240' tsup",
|
|
203
222
|
"dev": "tsup --watch",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InvalidStorageKeyError,
|
|
5
|
+
assertSafeKey,
|
|
6
|
+
contentTypeFor,
|
|
7
|
+
fileExtension,
|
|
8
|
+
isSafeKey,
|
|
9
|
+
safeFileName,
|
|
10
|
+
} from "../keys";
|
|
11
|
+
|
|
12
|
+
describe("assertSafeKey", () => {
|
|
13
|
+
// Key đến từ URL `/api/files/<key>` — người dùng điều khiển hoàn toàn.
|
|
14
|
+
it.each([
|
|
15
|
+
"../../etc/passwd",
|
|
16
|
+
"uploads/../../secret.pdf",
|
|
17
|
+
"/etc/passwd",
|
|
18
|
+
"uploads\\win.pdf",
|
|
19
|
+
"uploads/a\0b.pdf",
|
|
20
|
+
"",
|
|
21
|
+
])("từ chối %j", (key) => {
|
|
22
|
+
expect(() => assertSafeKey(key)).toThrow(InvalidStorageKeyError);
|
|
23
|
+
expect(isSafeKey(key)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("cho qua key thường", () => {
|
|
27
|
+
expect(assertSafeKey("uploads/20260801/hoa_don_123.pdf")).toBe(
|
|
28
|
+
"uploads/20260801/hoa_don_123.pdf",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("safeFileName", () => {
|
|
34
|
+
it("giữ dấu tiếng Việt", () => {
|
|
35
|
+
expect(safeFileName("Hợp đồng số 12.pdf")).toBe("Hợp_đồng_số_12.pdf");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("bỏ đường dẫn đính kèm trong tên", () => {
|
|
39
|
+
expect(safeFileName("../../evil.png")).toBe("evil.png");
|
|
40
|
+
expect(safeFileName("C:\\Users\\a\\b.png")).toBe("b.png");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hạ chữ thường phần mở rộng", () => {
|
|
44
|
+
expect(safeFileName("BAO_CAO.XLSX")).toBe("BAO_CAO.xlsx");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("tên rỗng vẫn ra tên dùng được", () => {
|
|
48
|
+
expect(safeFileName("")).toBe("file");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("fileExtension / contentTypeFor", () => {
|
|
53
|
+
it("lấy đuôi ở phần tên cuối, không phải ở thư mục", () => {
|
|
54
|
+
expect(fileExtension("a.pdf/b")).toBe("");
|
|
55
|
+
expect(fileExtension("uploads/2026/bao-cao.XLSX")).toBe("xlsx");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("đuôi lạ trả undefined để nơi gọi tự quyết", () => {
|
|
59
|
+
expect(contentTypeFor("a.exe")).toBeUndefined();
|
|
60
|
+
expect(contentTypeFor("a.pdf")).toBe("application/pdf");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ratchet: `@goerp/core/storage` phải chạy được khi app KHÔNG cài aws-sdk.
|
|
8
|
+
*
|
|
9
|
+
* Bẫy đã trả giá một lần ở proxy-gate: bundler phân giải TĨNH cả
|
|
10
|
+
* `await import()`, nên chỉ cần một dòng import gói không cài trong nhánh chết
|
|
11
|
+
* là app gãy build bằng "Module not found". Driver S3 phải ở `storage/s3/`
|
|
12
|
+
* (subpath riêng), tuyệt đối không rò vào barrel.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Đọc theo cwd: môi trường test là jsdom nên import.meta.url là URL http,
|
|
16
|
+
// fileURLToPath sẽ ném "URL must be of scheme file".
|
|
17
|
+
const STORAGE_DIR = resolve(process.cwd(), "src/storage");
|
|
18
|
+
|
|
19
|
+
function filesInBarrel(dir: string, acc: string[] = []): string[] {
|
|
20
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
// `s3/` là nơi ĐƯỢC PHÉP dùng aws-sdk; __tests__ không vào bundle app.
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
if (entry.name === "s3" || entry.name === "__tests__") continue;
|
|
24
|
+
filesInBarrel(join(dir, entry.name), acc);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
28
|
+
acc.push(join(dir, entry.name));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return acc;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("barrel storage không kéo theo aws-sdk", () => {
|
|
35
|
+
it("không file nào ngoài storage/s3 nhắc tới @aws-sdk/@smithy", () => {
|
|
36
|
+
const offenders = filesInBarrel(STORAGE_DIR).filter((file) => {
|
|
37
|
+
const source = readFileSync(file, "utf8")
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
39
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
40
|
+
return /["'](@aws-sdk|@smithy)\//.test(source);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(offenders).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { createFileProxyHandler } from "../file-proxy";
|
|
8
|
+
import {
|
|
9
|
+
clearStorageCache,
|
|
10
|
+
configureStorage,
|
|
11
|
+
deleteFile,
|
|
12
|
+
getFile,
|
|
13
|
+
getPresignedUrl,
|
|
14
|
+
getPublicUrl,
|
|
15
|
+
isRemoteStorageEnabled,
|
|
16
|
+
listObjects,
|
|
17
|
+
putFile,
|
|
18
|
+
} from "../storage-service";
|
|
19
|
+
import { createUploadHandler } from "../upload-handler";
|
|
20
|
+
import { StorageNotFoundError, type StorageDriver } from "../types";
|
|
21
|
+
|
|
22
|
+
/** DB giả: trả đúng các dòng system_configs mà engine hỏi. */
|
|
23
|
+
function fakeDb(rows: Record<string, string> = {}) {
|
|
24
|
+
return {
|
|
25
|
+
systemConfig: {
|
|
26
|
+
findMany: vi.fn(async () =>
|
|
27
|
+
Object.entries(rows).map(([key, value]) => ({ key, value })),
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let dir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "goerp-storage-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await rm(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("chế độ local (mặc định)", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("không có driver thì không hỏi DB làm gì", async () => {
|
|
49
|
+
const db = fakeDb({ STORAGE_TYPE: "s3" });
|
|
50
|
+
configureStorage({ db, localDir: dir });
|
|
51
|
+
|
|
52
|
+
expect(await isRemoteStorageEnabled()).toBe(false);
|
|
53
|
+
expect(db.systemConfig.findMany).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ghi rồi đọc lại", async () => {
|
|
57
|
+
await putFile("uploads/2026/a.txt", Buffer.from("xin chào"), "text/plain");
|
|
58
|
+
expect((await getFile("uploads/2026/a.txt")).toString()).toBe("xin chào");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("thiếu key → StorageNotFoundError (nơi gọi dịch thành 404)", async () => {
|
|
62
|
+
await expect(getFile("uploads/khong-co.pdf")).rejects.toBeInstanceOf(
|
|
63
|
+
StorageNotFoundError,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("xoá là idempotent", async () => {
|
|
68
|
+
await putFile("a.txt", Buffer.from("x"), "text/plain");
|
|
69
|
+
await deleteFile("a.txt");
|
|
70
|
+
await expect(deleteFile("a.txt")).resolves.toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("liệt kê theo tiền tố", async () => {
|
|
74
|
+
await putFile("uploads/a.txt", Buffer.from("1"), "text/plain");
|
|
75
|
+
await putFile("uploads/sub/b.txt", Buffer.from("22"), "text/plain");
|
|
76
|
+
await putFile("khac/c.txt", Buffer.from("333"), "text/plain");
|
|
77
|
+
|
|
78
|
+
const keys = (await listObjects("uploads/")).map((o) => o.key).sort();
|
|
79
|
+
expect(keys).toEqual(["uploads/a.txt", "uploads/sub/b.txt"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("không ký được URL → null để nơi gọi rơi về proxy", async () => {
|
|
83
|
+
expect(await getPresignedUrl("a.txt")).toBeNull();
|
|
84
|
+
expect(await getPublicUrl("uploads/a.txt")).toBe("/api/files/uploads/a.txt");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("chặn traversal trước khi chạm đĩa", async () => {
|
|
88
|
+
await expect(getFile("../../../etc/passwd")).rejects.toThrow(
|
|
89
|
+
/Key không hợp lệ/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("chế độ từ xa", () => {
|
|
95
|
+
const driver = (): StorageDriver => ({
|
|
96
|
+
put: vi.fn(async () => undefined),
|
|
97
|
+
get: vi.fn(async () => Buffer.from("từ s3")),
|
|
98
|
+
remove: vi.fn(async () => undefined),
|
|
99
|
+
presign: vi.fn(async () => "https://cdn.example/signed"),
|
|
100
|
+
list: vi.fn(async () => []),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("STORAGE_TYPE=s3 + có driver → dùng driver", async () => {
|
|
104
|
+
const d = driver();
|
|
105
|
+
configureStorage({
|
|
106
|
+
db: fakeDb({
|
|
107
|
+
STORAGE_TYPE: '"s3"', // giá trị trong DB là JSON string
|
|
108
|
+
S3_ENDPOINT: '"http://minio:9000"',
|
|
109
|
+
S3_BUCKET: '"erp"',
|
|
110
|
+
S3_ACCESS_KEY: '"ak"',
|
|
111
|
+
S3_SECRET_KEY: '"sk"',
|
|
112
|
+
}),
|
|
113
|
+
driver: d,
|
|
114
|
+
localDir: dir,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(await isRemoteStorageEnabled()).toBe(true);
|
|
118
|
+
expect((await getFile("a.txt")).toString()).toBe("từ s3");
|
|
119
|
+
expect(await getPresignedUrl("a.txt")).toBe("https://cdn.example/signed");
|
|
120
|
+
expect(await getPublicUrl("a.txt")).toBe("http://minio:9000/erp/a.txt");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("thiếu trường bắt buộc → cảnh báo rồi rơi về local, không sập request", async () => {
|
|
124
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
125
|
+
configureStorage({
|
|
126
|
+
db: fakeDb({ STORAGE_TYPE: "s3", S3_ENDPOINT: "http://minio:9000" }),
|
|
127
|
+
driver: driver(),
|
|
128
|
+
localDir: dir,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(await isRemoteStorageEnabled()).toBe(false);
|
|
132
|
+
await putFile("a.txt", Buffer.from("local"), "text/plain");
|
|
133
|
+
expect((await getFile("a.txt")).toString()).toBe("local");
|
|
134
|
+
expect(warn).toHaveBeenCalled();
|
|
135
|
+
warn.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requireRemote: mất cấu hình thì THROW chứ không âm thầm ghi đĩa container", async () => {
|
|
139
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
140
|
+
configureStorage({
|
|
141
|
+
db: fakeDb({ STORAGE_TYPE: "local" }),
|
|
142
|
+
driver: driver(),
|
|
143
|
+
localDir: dir,
|
|
144
|
+
requireRemote: true,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(putFile("a.txt", Buffer.from("x"), "text/plain")).rejects.toThrow(
|
|
148
|
+
/requireRemote/,
|
|
149
|
+
);
|
|
150
|
+
await expect(getFile("a.txt")).rejects.toThrow(/requireRemote/);
|
|
151
|
+
await expect(listObjects("")).rejects.toThrow(/requireRemote/);
|
|
152
|
+
warn.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("clearCache() buộc đọc lại cấu hình", async () => {
|
|
156
|
+
const db = fakeDb({
|
|
157
|
+
STORAGE_TYPE: "s3",
|
|
158
|
+
S3_ENDPOINT: "http://minio:9000",
|
|
159
|
+
S3_BUCKET: "erp",
|
|
160
|
+
S3_ACCESS_KEY: "ak",
|
|
161
|
+
S3_SECRET_KEY: "sk",
|
|
162
|
+
});
|
|
163
|
+
configureStorage({ db, driver: driver(), localDir: dir });
|
|
164
|
+
|
|
165
|
+
await isRemoteStorageEnabled();
|
|
166
|
+
await isRemoteStorageEnabled();
|
|
167
|
+
expect(db.systemConfig.findMany).toHaveBeenCalledTimes(1);
|
|
168
|
+
|
|
169
|
+
clearStorageCache();
|
|
170
|
+
await isRemoteStorageEnabled();
|
|
171
|
+
expect(db.systemConfig.findMany).toHaveBeenCalledTimes(2);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("createFileProxyHandler", () => {
|
|
176
|
+
const req = () => new Request("http://x/api/files/a.pdf") as never;
|
|
177
|
+
const ctx = (key: string, session?: unknown) => ({
|
|
178
|
+
params: Promise.resolve({ key: key.split("/") }),
|
|
179
|
+
session,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
184
|
+
await putFile("uploads/bao cao.pdf", Buffer.from("%PDF"), "application/pdf");
|
|
185
|
+
await putFile("uploads/data.csv", Buffer.from("a,b"), "text/csv");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("trả tập tin, pdf mở inline", async () => {
|
|
189
|
+
const res = await createFileProxyHandler()(req(), ctx("uploads/bao cao.pdf"));
|
|
190
|
+
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
|
193
|
+
expect(res.headers.get("Content-Disposition")).toBe(
|
|
194
|
+
'inline; filename="bao%20cao.pdf"',
|
|
195
|
+
);
|
|
196
|
+
expect(res.headers.get("Cache-Control")).toBe("private, max-age=3600");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("loại không xem được thì tải về", async () => {
|
|
200
|
+
const res = await createFileProxyHandler()(req(), ctx("uploads/data.csv"));
|
|
201
|
+
expect(res.headers.get("Content-Disposition")).toMatch(/^attachment;/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("authorize=false → 403 và KHÔNG đọc kho", async () => {
|
|
205
|
+
const res = await createFileProxyHandler({ authorize: () => false })(
|
|
206
|
+
req(),
|
|
207
|
+
ctx("uploads/bao cao.pdf"),
|
|
208
|
+
);
|
|
209
|
+
expect(res.status).toBe(403);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("authorize trả mã riêng → dùng mã đó (404 để giấu sự tồn tại)", async () => {
|
|
213
|
+
const res = await createFileProxyHandler({
|
|
214
|
+
authorize: () => ({ status: 404, message: "Not found" }),
|
|
215
|
+
})(req(), ctx("uploads/bao cao.pdf"));
|
|
216
|
+
expect(res.status).toBe(404);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("seam authorize nhận đúng key và session", async () => {
|
|
220
|
+
const authorize = vi.fn(() => true);
|
|
221
|
+
await createFileProxyHandler({ authorize })(
|
|
222
|
+
req(),
|
|
223
|
+
ctx("uploads/bao cao.pdf", { userId: "u1" }),
|
|
224
|
+
);
|
|
225
|
+
expect(authorize).toHaveBeenCalledWith(
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
key: "uploads/bao cao.pdf",
|
|
228
|
+
session: { userId: "u1" },
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("key bẩn bị chặn TRƯỚC authorize (seam app hay lấy key đi tra DB)", async () => {
|
|
234
|
+
const authorize = vi.fn(() => true);
|
|
235
|
+
const res = await createFileProxyHandler({ authorize })(
|
|
236
|
+
req(),
|
|
237
|
+
ctx("../../etc/passwd"),
|
|
238
|
+
);
|
|
239
|
+
expect(res.status).toBe(400);
|
|
240
|
+
expect(authorize).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("traversal → 400, thiếu key → 400, không có tập tin → 404", async () => {
|
|
244
|
+
const handler = createFileProxyHandler();
|
|
245
|
+
expect((await handler(req(), ctx("../../etc/passwd"))).status).toBe(400);
|
|
246
|
+
expect((await handler(req(), { params: Promise.resolve({}) })).status).toBe(
|
|
247
|
+
400,
|
|
248
|
+
);
|
|
249
|
+
expect((await handler(req(), ctx("uploads/thieu.pdf"))).status).toBe(404);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("rejectUnknownType chặn loại ngoài bảng (route công khai)", async () => {
|
|
253
|
+
const res = await createFileProxyHandler({
|
|
254
|
+
contentTypes: { png: "image/png" },
|
|
255
|
+
rejectUnknownType: true,
|
|
256
|
+
})(req(), ctx("uploads/bao cao.pdf"));
|
|
257
|
+
expect(res.status).toBe(404);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("createUploadHandler", () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
configureStorage({ db: fakeDb(), localDir: dir });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Dựng request giả thay vì `new Request(body: FormData)`: môi trường test là
|
|
267
|
+
// jsdom nên File là của jsdom, còn Request.formData() lại do undici phân tích
|
|
268
|
+
// — undici không nhận File lạ và ném AssertionError trước khi vào handler.
|
|
269
|
+
const fakeFile = (name: string, content: string, type: string) => ({
|
|
270
|
+
name,
|
|
271
|
+
type,
|
|
272
|
+
size: Buffer.byteLength(content),
|
|
273
|
+
arrayBuffer: async () => Buffer.from(content),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const post = (file: unknown) =>
|
|
277
|
+
({
|
|
278
|
+
formData: async () => ({ get: (key: string) => (key === "file" ? file : null) }),
|
|
279
|
+
}) as never;
|
|
280
|
+
|
|
281
|
+
it("lưu được và trả URL proxy — KHÔNG phải đường dẫn public", async () => {
|
|
282
|
+
const res = await createUploadHandler()(
|
|
283
|
+
post(fakeFile("Hợp đồng.pdf", "nội dung", "application/pdf")),
|
|
284
|
+
);
|
|
285
|
+
const body = (await res.json()) as { url: string; key: string };
|
|
286
|
+
|
|
287
|
+
expect(res.status).toBe(200);
|
|
288
|
+
expect(body.url).toMatch(
|
|
289
|
+
/^\/api\/files\/uploads\/\d{8}\/Hợp_đồng_\d+\.pdf$/,
|
|
290
|
+
);
|
|
291
|
+
expect((await getFile(body.key)).toString()).toBe("nội dung");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("chặn đuôi không cho phép", async () => {
|
|
295
|
+
const res = await createUploadHandler()(
|
|
296
|
+
post(fakeFile("virus.exe", "MZ", "application/octet-stream")),
|
|
297
|
+
);
|
|
298
|
+
expect(res.status).toBe(415);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("chặn tập tin quá lớn", async () => {
|
|
302
|
+
const res = await createUploadHandler({ maxBytes: 4 })(
|
|
303
|
+
post(fakeFile("a.txt", "quá dài", "text/plain")),
|
|
304
|
+
);
|
|
305
|
+
expect(res.status).toBe(413);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("không có tập tin → 400", async () => {
|
|
309
|
+
const res = await createUploadHandler()(post(null));
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_TYPES,
|
|
5
|
+
INLINE_EXTENSIONS,
|
|
6
|
+
InvalidStorageKeyError,
|
|
7
|
+
assertSafeKey,
|
|
8
|
+
fileExtension,
|
|
9
|
+
} from "./keys";
|
|
10
|
+
import { getFile } from "./storage-service";
|
|
11
|
+
import { StorageNotFoundError, isNotFoundError } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Route factory phục vụ tập tin qua chính app server.
|
|
15
|
+
*
|
|
16
|
+
* Vì sao phải proxy: MinIO/S3 nội bộ không mở ra Internet, và ngay cả khi mở
|
|
17
|
+
* thì object nào cũng cần luật quyền riêng. Route này gom phần CHUNG (chặn
|
|
18
|
+
* traversal, đoán content-type, inline hay attachment, 404 khi thiếu key,
|
|
19
|
+
* cache header) và chừa phần RIÊNG cho app qua seam `authorize` — luật kiểu
|
|
20
|
+
* "prefix `misa-invoices/` cần quyền xem hoá đơn" hay "tra ngược key trong
|
|
21
|
+
* cột attachments" là nghiệp vụ của app, không phải của core.
|
|
22
|
+
*
|
|
23
|
+
* // src/app/api/files/[...key]/route.ts
|
|
24
|
+
* export const GET = apiHandler(
|
|
25
|
+
* createFileProxyHandler({
|
|
26
|
+
* authorize: ({ key, session }) => authorizeFileKey(key, session),
|
|
27
|
+
* onError: (error, req) => serverError(error, req, { message: "Không thể tải tập tin" }),
|
|
28
|
+
* }),
|
|
29
|
+
* )
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface FileProxyAuthContext<TSession = unknown> {
|
|
33
|
+
/** Key đã ghép + đã kiểm tra an toàn. */
|
|
34
|
+
key: string;
|
|
35
|
+
req: NextRequest;
|
|
36
|
+
/** Session do lớp bọc (apiHandler) truyền xuống — `undefined` nếu route công khai. */
|
|
37
|
+
session: TSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `true` → cho qua. `false` → 403. Trả object để tự chọn mã lỗi (ví dụ 404 cho
|
|
42
|
+
* route công khai: lộ 403 là lộ luôn "key này có tồn tại").
|
|
43
|
+
*/
|
|
44
|
+
export type FileProxyDecision =
|
|
45
|
+
| boolean
|
|
46
|
+
| { status: number; message?: string };
|
|
47
|
+
|
|
48
|
+
export interface FileProxyOptions<TSession = unknown> {
|
|
49
|
+
authorize?: (
|
|
50
|
+
ctx: FileProxyAuthContext<TSession>,
|
|
51
|
+
) => Promise<FileProxyDecision> | FileProxyDecision;
|
|
52
|
+
/** Bảng đuôi → content-type. Mặc định `CONTENT_TYPES`. */
|
|
53
|
+
contentTypes?: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Đuôi lạ thì làm gì: `false` (mặc định) → `application/octet-stream`;
|
|
56
|
+
* `true` → 404. Route CÔNG KHAI nên bật để chỉ phục vụ đúng loại cho phép.
|
|
57
|
+
*/
|
|
58
|
+
rejectUnknownType?: boolean;
|
|
59
|
+
/** Đuôi mở thẳng trong tab. Mặc định `INLINE_EXTENSIONS`. */
|
|
60
|
+
inlineExtensions?: string[];
|
|
61
|
+
/** Mặc định `private, max-age=3600` — chớ để `public` cho tập tin có phiên. */
|
|
62
|
+
cacheControl?: string;
|
|
63
|
+
/** Bọc lỗi 5xx (mặc định trả 500 trơn). Truyền `serverError` của app vào đây. */
|
|
64
|
+
onError?: (error: unknown, req: NextRequest) => Response | Promise<Response>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RouteContext {
|
|
68
|
+
params?: unknown;
|
|
69
|
+
session?: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveKey(ctx: RouteContext | undefined): Promise<string> {
|
|
73
|
+
const params = (await ctx?.params) as { key?: unknown } | undefined;
|
|
74
|
+
const raw = params?.key;
|
|
75
|
+
if (Array.isArray(raw)) return raw.join("/");
|
|
76
|
+
return typeof raw === "string" ? raw : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createFileProxyHandler<TSession = unknown>(
|
|
80
|
+
options: FileProxyOptions<TSession> = {},
|
|
81
|
+
) {
|
|
82
|
+
const contentTypes = options.contentTypes ?? CONTENT_TYPES;
|
|
83
|
+
const inlineExtensions = options.inlineExtensions ?? INLINE_EXTENSIONS;
|
|
84
|
+
const cacheControl = options.cacheControl ?? "private, max-age=3600";
|
|
85
|
+
|
|
86
|
+
return async function handler(
|
|
87
|
+
req: NextRequest,
|
|
88
|
+
ctx?: RouteContext,
|
|
89
|
+
): Promise<Response> {
|
|
90
|
+
try {
|
|
91
|
+
const key = await resolveKey(ctx);
|
|
92
|
+
if (!key) return new Response("Missing file key", { status: 400 });
|
|
93
|
+
// Chặn traversal NGAY, trước cả `authorize`: seam của app thường lấy key
|
|
94
|
+
// đi tra DB, đừng để chuỗi bẩn đi xa hơn cổng. Ném
|
|
95
|
+
// InvalidStorageKeyError → catch bên dưới trả 400.
|
|
96
|
+
assertSafeKey(key);
|
|
97
|
+
|
|
98
|
+
const ext = fileExtension(key);
|
|
99
|
+
const contentType = contentTypes[ext];
|
|
100
|
+
if (!contentType && options.rejectUnknownType) {
|
|
101
|
+
return new Response("Not found", { status: 404 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.authorize) {
|
|
105
|
+
const decision = await options.authorize({
|
|
106
|
+
key,
|
|
107
|
+
req,
|
|
108
|
+
session: ctx?.session as TSession,
|
|
109
|
+
});
|
|
110
|
+
if (decision !== true) {
|
|
111
|
+
const denial =
|
|
112
|
+
typeof decision === "object"
|
|
113
|
+
? decision
|
|
114
|
+
: { status: 403, message: "Forbidden" };
|
|
115
|
+
return new Response(denial.message ?? "Forbidden", {
|
|
116
|
+
status: denial.status,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const body = await getFile(key);
|
|
122
|
+
const filename = key.split("/").pop() || "file";
|
|
123
|
+
const disposition = inlineExtensions.includes(ext)
|
|
124
|
+
? "inline"
|
|
125
|
+
: "attachment";
|
|
126
|
+
|
|
127
|
+
return new Response(new Uint8Array(body), {
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": contentType ?? "application/octet-stream",
|
|
130
|
+
"Content-Length": String(body.length),
|
|
131
|
+
"Content-Disposition": `${disposition}; filename="${encodeURIComponent(filename)}"`,
|
|
132
|
+
"Cache-Control": cacheControl,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof InvalidStorageKeyError) {
|
|
137
|
+
return new Response("Invalid file key", { status: 400 });
|
|
138
|
+
}
|
|
139
|
+
if (error instanceof StorageNotFoundError || isNotFoundError(error)) {
|
|
140
|
+
return new Response("File not found", { status: 404 });
|
|
141
|
+
}
|
|
142
|
+
if (options.onError) return options.onError(error, req);
|
|
143
|
+
console.error("[storage] file proxy lỗi:", error);
|
|
144
|
+
return new Response("Internal error", { status: 500 });
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|