@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@goerp/core/storage` — kho tập tin dùng chung.
|
|
3
|
+
*
|
|
4
|
+
* Barrel này KHÔNG kéo aws-sdk (ratchet `__tests__/no-aws-in-barrel.test.ts`
|
|
5
|
+
* canh). Muốn chạy S3/MinIO thì cắm driver từ `@goerp/core/storage/s3`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
clearStorageCache,
|
|
10
|
+
configureStorage,
|
|
11
|
+
deleteFile,
|
|
12
|
+
getFile,
|
|
13
|
+
getPresignedUrl,
|
|
14
|
+
getPublicUrl,
|
|
15
|
+
getRemoteConfig,
|
|
16
|
+
isRemoteStorageEnabled,
|
|
17
|
+
listObjects,
|
|
18
|
+
putFile,
|
|
19
|
+
storage,
|
|
20
|
+
} from "./storage-service";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
CONTENT_TYPES,
|
|
24
|
+
IMAGE_CONTENT_TYPES,
|
|
25
|
+
INLINE_EXTENSIONS,
|
|
26
|
+
InvalidStorageKeyError,
|
|
27
|
+
assertSafeKey,
|
|
28
|
+
contentTypeFor,
|
|
29
|
+
fileExtension,
|
|
30
|
+
isSafeKey,
|
|
31
|
+
safeFileName,
|
|
32
|
+
} from "./keys";
|
|
33
|
+
|
|
34
|
+
export { createFileProxyHandler } from "./file-proxy";
|
|
35
|
+
export type {
|
|
36
|
+
FileProxyAuthContext,
|
|
37
|
+
FileProxyDecision,
|
|
38
|
+
FileProxyOptions,
|
|
39
|
+
} from "./file-proxy";
|
|
40
|
+
|
|
41
|
+
export { createUploadHandler } from "./upload-handler";
|
|
42
|
+
export type { UploadHandlerOptions } from "./upload-handler";
|
|
43
|
+
|
|
44
|
+
export { StorageNotFoundError, isNotFoundError } from "./types";
|
|
45
|
+
export type {
|
|
46
|
+
StorageConfigInput,
|
|
47
|
+
StorageDb,
|
|
48
|
+
StorageDriver,
|
|
49
|
+
StorageObjectInfo,
|
|
50
|
+
StorageRemoteConfig,
|
|
51
|
+
} from "./types";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chuẩn hoá "key" (đường dẫn logic của tập tin trong kho) + bảng content-type.
|
|
3
|
+
*
|
|
4
|
+
* Key là đầu vào do NGƯỜI DÙNG điều khiển (URL `/api/files/<key>`), nên mọi
|
|
5
|
+
* đường vào kho phải đi qua `assertSafeKey` trước khi chạm đĩa hay S3.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Ký tự an toàn cho tên tập tin — GIỮ dấu tiếng Việt, thay phần còn lại. */
|
|
9
|
+
const UNSAFE_NAME_CHARS = /[^a-zA-Z0-9._\-À-ɏḀ-ỿ]/g;
|
|
10
|
+
|
|
11
|
+
export class InvalidStorageKeyError extends Error {
|
|
12
|
+
constructor(key: string) {
|
|
13
|
+
super(`Key không hợp lệ: ${key}`);
|
|
14
|
+
this.name = "InvalidStorageKeyError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Chặn traversal (`..`), key tuyệt đối, backslash (Windows path) và null byte.
|
|
20
|
+
* Ném `InvalidStorageKeyError` — route factory dịch thành 400.
|
|
21
|
+
*/
|
|
22
|
+
export function assertSafeKey(key: string): string {
|
|
23
|
+
if (
|
|
24
|
+
!key ||
|
|
25
|
+
key.includes("..") ||
|
|
26
|
+
key.startsWith("/") ||
|
|
27
|
+
key.includes("\\") ||
|
|
28
|
+
key.includes("\0")
|
|
29
|
+
) {
|
|
30
|
+
throw new InvalidStorageKeyError(key);
|
|
31
|
+
}
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isSafeKey(key: string): boolean {
|
|
36
|
+
try {
|
|
37
|
+
assertSafeKey(key);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Làm sạch tên tập tin người dùng tải lên (giữ tiếng Việt có dấu). */
|
|
45
|
+
export function safeFileName(name: string): string {
|
|
46
|
+
const trimmed = name.split(/[\\/]/).pop() ?? "file";
|
|
47
|
+
const dot = trimmed.lastIndexOf(".");
|
|
48
|
+
const base = dot > 0 ? trimmed.slice(0, dot) : trimmed;
|
|
49
|
+
const ext = dot > 0 ? trimmed.slice(dot + 1).toLowerCase() : "";
|
|
50
|
+
const safeBase = base.replace(UNSAFE_NAME_CHARS, "_") || "file";
|
|
51
|
+
return ext ? `${safeBase}.${ext.replace(UNSAFE_NAME_CHARS, "")}` : safeBase;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fileExtension(key: string): string {
|
|
55
|
+
const name = key.split("/").pop() ?? "";
|
|
56
|
+
const dot = name.lastIndexOf(".");
|
|
57
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const CONTENT_TYPES: Record<string, string> = {
|
|
61
|
+
pdf: "application/pdf",
|
|
62
|
+
jpg: "image/jpeg",
|
|
63
|
+
jpeg: "image/jpeg",
|
|
64
|
+
png: "image/png",
|
|
65
|
+
gif: "image/gif",
|
|
66
|
+
webp: "image/webp",
|
|
67
|
+
avif: "image/avif",
|
|
68
|
+
svg: "image/svg+xml",
|
|
69
|
+
doc: "application/msword",
|
|
70
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
71
|
+
xls: "application/vnd.ms-excel",
|
|
72
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
73
|
+
csv: "text/csv",
|
|
74
|
+
txt: "text/plain",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Ảnh — dùng cho route công khai (chỉ phục vụ ảnh, không phục vụ chứng từ). */
|
|
78
|
+
export const IMAGE_CONTENT_TYPES: Record<string, string> = {
|
|
79
|
+
jpg: "image/jpeg",
|
|
80
|
+
jpeg: "image/jpeg",
|
|
81
|
+
png: "image/png",
|
|
82
|
+
gif: "image/gif",
|
|
83
|
+
webp: "image/webp",
|
|
84
|
+
avif: "image/avif",
|
|
85
|
+
svg: "image/svg+xml",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Đuôi mở thẳng trong tab (inline); còn lại tải về (attachment). */
|
|
89
|
+
export const INLINE_EXTENSIONS = [
|
|
90
|
+
"pdf",
|
|
91
|
+
"jpg",
|
|
92
|
+
"jpeg",
|
|
93
|
+
"png",
|
|
94
|
+
"gif",
|
|
95
|
+
"webp",
|
|
96
|
+
"avif",
|
|
97
|
+
"svg",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export function contentTypeFor(
|
|
101
|
+
key: string,
|
|
102
|
+
map: Record<string, string> = CONTENT_TYPES,
|
|
103
|
+
): string | undefined {
|
|
104
|
+
return map[fileExtension(key)];
|
|
105
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DeleteObjectCommand,
|
|
5
|
+
GetObjectCommand,
|
|
6
|
+
ListObjectsV2Command,
|
|
7
|
+
PutObjectCommand,
|
|
8
|
+
S3Client,
|
|
9
|
+
} from "@aws-sdk/client-s3";
|
|
10
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
+
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
StorageDriver,
|
|
15
|
+
StorageObjectInfo,
|
|
16
|
+
StorageRemoteConfig,
|
|
17
|
+
} from "../types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Driver S3/MinIO cho `@goerp/core/storage`.
|
|
21
|
+
*
|
|
22
|
+
* Ở SUBPATH RIÊNG (`@goerp/core/storage/s3`) chứ không nằm trong barrel: gói
|
|
23
|
+
* aws-sdk là peer dependency TUỲ CHỌN, app nào chỉ dùng đĩa local thì không
|
|
24
|
+
* phải cài. Bundler phân giải TĨNH cả `await import()`, nên nếu để driver này
|
|
25
|
+
* trong nhánh mặc định của engine thì mọi app không cài aws-sdk sẽ gãy build
|
|
26
|
+
* bằng "Module not found" (đúng cái bẫy next-auth trong proxy-gate).
|
|
27
|
+
*
|
|
28
|
+
* // composition root
|
|
29
|
+
* import { configureStorage } from "@goerp/core/storage"
|
|
30
|
+
* import { createS3Driver } from "@goerp/core/storage/s3"
|
|
31
|
+
* configureStorage({ db, driver: createS3Driver() })
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function fingerprint(config: StorageRemoteConfig, endpoint: string): string {
|
|
35
|
+
return [
|
|
36
|
+
endpoint,
|
|
37
|
+
config.region,
|
|
38
|
+
config.accessKey,
|
|
39
|
+
config.rejectUnauthorized,
|
|
40
|
+
].join("|");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildClient(
|
|
44
|
+
config: StorageRemoteConfig,
|
|
45
|
+
endpoint: string,
|
|
46
|
+
): S3Client {
|
|
47
|
+
return new S3Client({
|
|
48
|
+
endpoint,
|
|
49
|
+
region: config.region,
|
|
50
|
+
credentials: {
|
|
51
|
+
accessKeyId: config.accessKey,
|
|
52
|
+
secretAccessKey: config.secretKey,
|
|
53
|
+
},
|
|
54
|
+
// MinIO không hỗ trợ virtual-host style.
|
|
55
|
+
forcePathStyle: true,
|
|
56
|
+
// MinIO self-hosted thường dùng cert tự ký — agent riêng để không phải tắt
|
|
57
|
+
// verify TLS toàn tiến trình.
|
|
58
|
+
...(endpoint.startsWith("https://") && {
|
|
59
|
+
requestHandler: new NodeHttpHandler({
|
|
60
|
+
httpsAgent: new https.Agent({
|
|
61
|
+
rejectUnauthorized: config.rejectUnauthorized,
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function toBuffer(body: unknown): Promise<Buffer> {
|
|
69
|
+
const stream = body as AsyncIterable<Uint8Array> | null;
|
|
70
|
+
if (!stream) throw new Error("Phản hồi S3 không có nội dung");
|
|
71
|
+
const chunks: Uint8Array[] = [];
|
|
72
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
73
|
+
return Buffer.concat(chunks);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createS3Driver(): StorageDriver {
|
|
77
|
+
// Memo theo dấu vân tay config: admin đổi endpoint + clearCache() là lần gọi
|
|
78
|
+
// sau dựng client mới, không phải restart container.
|
|
79
|
+
const clients = new Map<string, S3Client>();
|
|
80
|
+
|
|
81
|
+
const clientFor = (config: StorageRemoteConfig, endpoint: string) => {
|
|
82
|
+
const id = fingerprint(config, endpoint);
|
|
83
|
+
let client = clients.get(id);
|
|
84
|
+
if (!client) {
|
|
85
|
+
client = buildClient(config, endpoint);
|
|
86
|
+
clients.set(id, client);
|
|
87
|
+
}
|
|
88
|
+
return client;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Thao tác phía server đi endpoint NỘI BỘ. */
|
|
92
|
+
const internal = (config: StorageRemoteConfig) =>
|
|
93
|
+
clientFor(config, config.endpoint);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ký presigned URL phải ký bằng endpoint CÔNG KHAI: chữ ký gắn với host, ký
|
|
97
|
+
* bằng host nội bộ thì trình duyệt nhận URL không gọi tới được.
|
|
98
|
+
*/
|
|
99
|
+
const publicFacing = (config: StorageRemoteConfig) =>
|
|
100
|
+
clientFor(config, config.publicEndpoint || config.endpoint);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
async put(config, key, body, contentType) {
|
|
104
|
+
await internal(config).send(
|
|
105
|
+
new PutObjectCommand({
|
|
106
|
+
Bucket: config.bucket,
|
|
107
|
+
Key: key,
|
|
108
|
+
Body: body,
|
|
109
|
+
ContentType: contentType,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async get(config, key) {
|
|
115
|
+
const response = await internal(config).send(
|
|
116
|
+
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
117
|
+
);
|
|
118
|
+
return toBuffer(response.Body);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async remove(config, key) {
|
|
122
|
+
await internal(config).send(
|
|
123
|
+
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async presign(config, key, expiresIn) {
|
|
128
|
+
return getSignedUrl(
|
|
129
|
+
publicFacing(config),
|
|
130
|
+
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
131
|
+
{ expiresIn },
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async list(config, prefix) {
|
|
136
|
+
const results: StorageObjectInfo[] = [];
|
|
137
|
+
let token: string | undefined;
|
|
138
|
+
do {
|
|
139
|
+
const page = await internal(config).send(
|
|
140
|
+
new ListObjectsV2Command({
|
|
141
|
+
Bucket: config.bucket,
|
|
142
|
+
Prefix: prefix,
|
|
143
|
+
ContinuationToken: token,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
for (const item of page.Contents ?? []) {
|
|
147
|
+
// Key kết thúc bằng "/" là thư mục giả do console MinIO tạo — không phải tập tin.
|
|
148
|
+
if (!item.Key || item.Key.endsWith("/")) continue;
|
|
149
|
+
results.push({
|
|
150
|
+
key: item.Key,
|
|
151
|
+
size: item.Size ?? 0,
|
|
152
|
+
lastModified: item.LastModified,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
token = page.IsTruncated ? page.NextContinuationToken : undefined;
|
|
156
|
+
} while (token);
|
|
157
|
+
return results;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile, readdir } from "fs/promises";
|
|
2
|
+
import { dirname, join, sep } from "path";
|
|
3
|
+
|
|
4
|
+
import { assertSafeKey } from "./keys";
|
|
5
|
+
import {
|
|
6
|
+
StorageNotFoundError,
|
|
7
|
+
isNotFoundError,
|
|
8
|
+
type StorageConfigInput,
|
|
9
|
+
type StorageDriver,
|
|
10
|
+
type StorageObjectInfo,
|
|
11
|
+
type StorageRemoteConfig,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kho tập tin dùng chung của goerp.
|
|
16
|
+
*
|
|
17
|
+
* HAI CHẾ ĐỘ, chọn theo runtime chứ không theo build:
|
|
18
|
+
* • `remote` — S3/MinIO, khi `STORAGE_TYPE=s3` trong `system_configs` VÀ app
|
|
19
|
+
* có cắm driver (`@goerp/core/storage/s3`).
|
|
20
|
+
* • `local` — thư mục đĩa PRIVATE (mặc định `<cwd>/storage/files`). Đây là
|
|
21
|
+
* mặc định để app mới chạy được ngay, chưa cần dựng MinIO.
|
|
22
|
+
*
|
|
23
|
+
* Cấu hình nằm trong DB (bảng `system_configs`) chứ không phải env: admin đổi
|
|
24
|
+
* endpoint trong màn hình cấu hình rồi `clearCache()` là ăn ngay, không cần
|
|
25
|
+
* deploy lại. App cắm phụ thuộc một lần ở composition root:
|
|
26
|
+
*
|
|
27
|
+
* configureStorage({ db, driver: createS3Driver() })
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const CONFIG_KEYS = [
|
|
31
|
+
"STORAGE_TYPE",
|
|
32
|
+
"S3_ENDPOINT",
|
|
33
|
+
"S3_PUBLIC_ENDPOINT",
|
|
34
|
+
"S3_REGION",
|
|
35
|
+
"S3_BUCKET",
|
|
36
|
+
"S3_ACCESS_KEY",
|
|
37
|
+
"S3_SECRET_KEY",
|
|
38
|
+
"S3_REJECT_UNAUTHORIZED",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
let configured: StorageConfigInput | null = null;
|
|
42
|
+
let cachedRemote: StorageRemoteConfig | null = null;
|
|
43
|
+
let cacheLoaded = false;
|
|
44
|
+
|
|
45
|
+
export function configureStorage(input: StorageConfigInput): void {
|
|
46
|
+
configured = input;
|
|
47
|
+
clearStorageCache();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Quên cấu hình đã đọc từ DB — gọi sau khi admin sửa S3_*. */
|
|
51
|
+
export function clearStorageCache(): void {
|
|
52
|
+
cachedRemote = null;
|
|
53
|
+
cacheLoaded = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireConfigured(): StorageConfigInput {
|
|
57
|
+
if (!configured) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"[storage] chưa configureStorage({ db, driver? }) — gọi 1 lần lúc khởi tạo app (cạnh configureSettingsService).",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return configured;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function localRoot(): string {
|
|
66
|
+
return configured?.localDir ?? join(process.cwd(), "storage", "files");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Giá trị trong `system_configs` có thể là JSON string hoặc chuỗi thô. */
|
|
70
|
+
function parseValue(raw: string | null): string {
|
|
71
|
+
if (raw == null) return "";
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
return typeof parsed === "string" ? parsed : String(parsed);
|
|
75
|
+
} catch {
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Đọc cấu hình S3 từ DB (memo hoá). Trả `null` khi chưa bật hoặc thiếu trường
|
|
82
|
+
* bắt buộc — engine tự rơi về chế độ local thay vì ném lỗi giữa request.
|
|
83
|
+
*/
|
|
84
|
+
export async function getRemoteConfig(): Promise<StorageRemoteConfig | null> {
|
|
85
|
+
if (cacheLoaded) return cachedRemote;
|
|
86
|
+
|
|
87
|
+
const { db, driver } = requireConfigured();
|
|
88
|
+
cacheLoaded = true;
|
|
89
|
+
cachedRemote = null;
|
|
90
|
+
|
|
91
|
+
// Không có driver thì đọc config cũng vô nghĩa — khỏi phải hỏi DB.
|
|
92
|
+
if (!driver) return null;
|
|
93
|
+
|
|
94
|
+
const rows = await db.systemConfig.findMany({
|
|
95
|
+
where: { key: { in: [...CONFIG_KEYS] } },
|
|
96
|
+
select: { key: true, value: true },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const map: Record<string, string> = {};
|
|
100
|
+
for (const row of rows) map[row.key] = parseValue(row.value);
|
|
101
|
+
|
|
102
|
+
if ((map.STORAGE_TYPE || "").toLowerCase() !== "s3") return null;
|
|
103
|
+
|
|
104
|
+
const endpoint = map.S3_ENDPOINT || "";
|
|
105
|
+
const bucket = map.S3_BUCKET || "";
|
|
106
|
+
const accessKey = map.S3_ACCESS_KEY || "";
|
|
107
|
+
const secretKey = map.S3_SECRET_KEY || "";
|
|
108
|
+
if (!endpoint || !bucket || !accessKey || !secretKey) {
|
|
109
|
+
console.warn(
|
|
110
|
+
"[storage] STORAGE_TYPE=s3 nhưng thiếu S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY — tạm dùng đĩa local.",
|
|
111
|
+
);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
cachedRemote = {
|
|
116
|
+
endpoint,
|
|
117
|
+
publicEndpoint: map.S3_PUBLIC_ENDPOINT || endpoint,
|
|
118
|
+
region: map.S3_REGION || "us-east-1",
|
|
119
|
+
bucket,
|
|
120
|
+
accessKey,
|
|
121
|
+
secretKey,
|
|
122
|
+
rejectUnauthorized: map.S3_REJECT_UNAUTHORIZED === "true",
|
|
123
|
+
};
|
|
124
|
+
return cachedRemote;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Có đang chạy trên kho từ xa không (false = đĩa local). */
|
|
128
|
+
export async function isRemoteStorageEnabled(): Promise<boolean> {
|
|
129
|
+
return (await getRemoteConfig()) !== null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function withRemote<T>(
|
|
133
|
+
fn: (driver: StorageDriver, config: StorageRemoteConfig) => Promise<T>,
|
|
134
|
+
): Promise<T | null> {
|
|
135
|
+
const config = await getRemoteConfig();
|
|
136
|
+
if (!config) return null;
|
|
137
|
+
return fn(requireConfigured().driver as StorageDriver, config);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Chặn cửa hậu "im lặng rơi về local" ở app đã chạy S3: ghi thành công vào đĩa
|
|
142
|
+
* container = mất tập tin ở lần deploy sau, tệ hơn hẳn một lỗi ồn ào.
|
|
143
|
+
*/
|
|
144
|
+
function assertLocalAllowed(): void {
|
|
145
|
+
if (configured?.requireRemote) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"[storage] requireRemote=true nhưng kho S3 chưa sẵn sàng — kiểm tra STORAGE_TYPE/S3_* trong system_configs. Từ chối dùng đĩa local.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function localPath(key: string): string {
|
|
153
|
+
assertLocalAllowed();
|
|
154
|
+
return join(localRoot(), ...assertSafeKey(key).split("/"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Lưu tập tin. Trả về chính `key` để nơi gọi ghép URL. */
|
|
158
|
+
export async function putFile(
|
|
159
|
+
key: string,
|
|
160
|
+
body: Buffer,
|
|
161
|
+
contentType = "application/octet-stream",
|
|
162
|
+
): Promise<string> {
|
|
163
|
+
assertSafeKey(key);
|
|
164
|
+
const done = await withRemote((driver, config) =>
|
|
165
|
+
driver.put(config, key, body, contentType),
|
|
166
|
+
);
|
|
167
|
+
if (done === null) {
|
|
168
|
+
const path = localPath(key);
|
|
169
|
+
await mkdir(dirname(path), { recursive: true });
|
|
170
|
+
await writeFile(path, body);
|
|
171
|
+
}
|
|
172
|
+
return key;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Đọc tập tin. Không có → ném `StorageNotFoundError`. */
|
|
176
|
+
export async function getFile(key: string): Promise<Buffer> {
|
|
177
|
+
assertSafeKey(key);
|
|
178
|
+
const config = await getRemoteConfig();
|
|
179
|
+
if (config) {
|
|
180
|
+
try {
|
|
181
|
+
return await (requireConfigured().driver as StorageDriver).get(
|
|
182
|
+
config,
|
|
183
|
+
key,
|
|
184
|
+
);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isNotFoundError(error)) throw new StorageNotFoundError(key);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return await readFile(localPath(key));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isNotFoundError(error)) throw new StorageNotFoundError(key);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Xoá tập tin. Không có sẵn cũng coi như thành công (idempotent). */
|
|
199
|
+
export async function deleteFile(key: string): Promise<void> {
|
|
200
|
+
assertSafeKey(key);
|
|
201
|
+
const done = await withRemote((driver, config) => driver.remove(config, key));
|
|
202
|
+
if (done === null) {
|
|
203
|
+
await rm(localPath(key), { force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* URL có chữ ký để trình duyệt tải thẳng từ kho. Chế độ local không ký được →
|
|
209
|
+
* trả `null`, nơi gọi rơi về proxy `/api/files/<key>`.
|
|
210
|
+
*/
|
|
211
|
+
export async function getPresignedUrl(
|
|
212
|
+
key: string,
|
|
213
|
+
expiresIn = 3600,
|
|
214
|
+
): Promise<string | null> {
|
|
215
|
+
assertSafeKey(key);
|
|
216
|
+
return withRemote((driver, config) => driver.presign(config, key, expiresIn));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* URL công khai của object. Chế độ local (hoặc MinIO nội bộ) không có URL trực
|
|
221
|
+
* tiếp cho trình duyệt → dùng proxy của app.
|
|
222
|
+
*/
|
|
223
|
+
export async function getPublicUrl(
|
|
224
|
+
key: string,
|
|
225
|
+
proxyPrefix = "/api/files",
|
|
226
|
+
): Promise<string> {
|
|
227
|
+
assertSafeKey(key);
|
|
228
|
+
const config = await getRemoteConfig();
|
|
229
|
+
if (!config) return `${proxyPrefix}/${key}`;
|
|
230
|
+
return `${config.publicEndpoint}/${config.bucket}/${key}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Liệt kê object theo tiền tố — dùng cho backup/dọn rác. */
|
|
234
|
+
export async function listObjects(
|
|
235
|
+
prefix: string,
|
|
236
|
+
): Promise<StorageObjectInfo[]> {
|
|
237
|
+
const config = await getRemoteConfig();
|
|
238
|
+
if (config) {
|
|
239
|
+
return (requireConfigured().driver as StorageDriver).list(config, prefix);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
assertLocalAllowed();
|
|
243
|
+
const root = localRoot();
|
|
244
|
+
const results: StorageObjectInfo[] = [];
|
|
245
|
+
const walk = async (dir: string): Promise<void> => {
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (isNotFoundError(error)) return;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const full = join(dir, entry.name);
|
|
255
|
+
if (entry.isDirectory()) {
|
|
256
|
+
await walk(full);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const key = full.slice(root.length + 1).split(sep).join("/");
|
|
260
|
+
if (!key.startsWith(prefix)) continue;
|
|
261
|
+
const info = await stat(full);
|
|
262
|
+
results.push({ key, size: info.size, lastModified: info.mtime });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
await walk(root);
|
|
266
|
+
return results;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Gom lại thành facade cho nơi gọi thích gọi kiểu `storage.getFile(...)`. */
|
|
270
|
+
export const storage = {
|
|
271
|
+
clearCache: clearStorageCache,
|
|
272
|
+
getRemoteConfig,
|
|
273
|
+
isRemoteEnabled: isRemoteStorageEnabled,
|
|
274
|
+
put: putFile,
|
|
275
|
+
get: getFile,
|
|
276
|
+
delete: deleteFile,
|
|
277
|
+
getPresignedUrl,
|
|
278
|
+
getPublicUrl,
|
|
279
|
+
list: listObjects,
|
|
280
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kho tập tin — kiểu dùng chung cho engine, driver và các route factory.
|
|
3
|
+
*
|
|
4
|
+
* Core KHÔNG phụ thuộc aws-sdk: driver S3/MinIO nằm ở subpath riêng
|
|
5
|
+
* `@goerp/core/storage/s3` và được app cắm vào qua `configureStorage`. App nào
|
|
6
|
+
* chỉ cần đĩa local thì không phải cài thêm gói nào.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Cấu hình S3/MinIO đọc từ bảng `system_configs`. */
|
|
10
|
+
export interface StorageRemoteConfig {
|
|
11
|
+
/** Endpoint NỘI BỘ — dùng cho upload/get/delete từ phía server. */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/**
|
|
14
|
+
* Endpoint CÔNG KHAI — chỉ dùng khi ký presigned URL để trình duyệt gọi
|
|
15
|
+
* thẳng. Khác endpoint nội bộ thì chữ ký phải ký bằng cái này, nếu không
|
|
16
|
+
* URL trả về trỏ vào host mà trình duyệt không thấy.
|
|
17
|
+
*/
|
|
18
|
+
publicEndpoint: string;
|
|
19
|
+
region: string;
|
|
20
|
+
bucket: string;
|
|
21
|
+
accessKey: string;
|
|
22
|
+
secretKey: string;
|
|
23
|
+
/**
|
|
24
|
+
* Verify chứng chỉ TLS của endpoint. MẶC ĐỊNH `false` vì MinIO self-hosted
|
|
25
|
+
* thường dùng cert tự ký — bật `true` (key `S3_REJECT_UNAUTHORIZED`) khi
|
|
26
|
+
* endpoint có cert hợp lệ.
|
|
27
|
+
*/
|
|
28
|
+
rejectUnauthorized: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StorageObjectInfo {
|
|
32
|
+
key: string;
|
|
33
|
+
size: number;
|
|
34
|
+
lastModified?: Date;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Driver kho từ xa. Nhận config theo từng lời gọi (thay vì giữ state) để
|
|
39
|
+
* `clearCache()` của engine đổi config là ăn ngay — driver tự memo client theo
|
|
40
|
+
* dấu vân tay config.
|
|
41
|
+
*/
|
|
42
|
+
export interface StorageDriver {
|
|
43
|
+
put(
|
|
44
|
+
config: StorageRemoteConfig,
|
|
45
|
+
key: string,
|
|
46
|
+
body: Buffer,
|
|
47
|
+
contentType: string,
|
|
48
|
+
): Promise<void>;
|
|
49
|
+
get(config: StorageRemoteConfig, key: string): Promise<Buffer>;
|
|
50
|
+
remove(config: StorageRemoteConfig, key: string): Promise<void>;
|
|
51
|
+
presign(
|
|
52
|
+
config: StorageRemoteConfig,
|
|
53
|
+
key: string,
|
|
54
|
+
expiresIn: number,
|
|
55
|
+
): Promise<string>;
|
|
56
|
+
list(
|
|
57
|
+
config: StorageRemoteConfig,
|
|
58
|
+
prefix: string,
|
|
59
|
+
): Promise<StorageObjectInfo[]>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Delegate Prisma tối thiểu — args để `any` CÓ CHỦ ĐÍCH (cùng bài học
|
|
64
|
+
* SettingsDb/TaskDb: generated types của mỗi app hẹp hơn nên khai chặt sẽ
|
|
65
|
+
* không assignable).
|
|
66
|
+
*/
|
|
67
|
+
export interface StorageDb {
|
|
68
|
+
systemConfig: {
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
findMany(args: any): Promise<{ key: string; value: string | null }[]>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StorageConfigInput {
|
|
75
|
+
db: StorageDb;
|
|
76
|
+
/**
|
|
77
|
+
* Driver kho từ xa. Bỏ trống → luôn dùng đĩa local (`localDir`), kể cả khi
|
|
78
|
+
* `STORAGE_TYPE=s3` trong DB.
|
|
79
|
+
*/
|
|
80
|
+
driver?: StorageDriver;
|
|
81
|
+
/** Thư mục fallback khi không có driver / STORAGE_TYPE != s3. */
|
|
82
|
+
localDir?: string;
|
|
83
|
+
/**
|
|
84
|
+
* `true` = CẤM rơi về đĩa local: thiếu/hỏng cấu hình S3 thì ném lỗi thay vì
|
|
85
|
+
* âm thầm ghi vào đĩa container. Bật cho app production đã chạy S3 — mất
|
|
86
|
+
* cấu hình mà vẫn "thành công" nghĩa là tập tin bay theo lần deploy sau.
|
|
87
|
+
*/
|
|
88
|
+
requireRemote?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Lỗi "không có key này" — engine và driver đều ném ra dạng này. */
|
|
92
|
+
export class StorageNotFoundError extends Error {
|
|
93
|
+
readonly key: string;
|
|
94
|
+
constructor(key: string) {
|
|
95
|
+
super(`Không tìm thấy tập tin: ${key}`);
|
|
96
|
+
this.name = "StorageNotFoundError";
|
|
97
|
+
this.key = key;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Nhận diện lỗi 404 từ mọi phía (driver S3 ném NoSuchKey, local ném ENOENT). */
|
|
102
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
103
|
+
if (error instanceof StorageNotFoundError) return true;
|
|
104
|
+
const err = error as { name?: string; code?: string; message?: string } | null;
|
|
105
|
+
if (!err) return false;
|
|
106
|
+
if (err.name === "NoSuchKey" || err.name === "NotFound") return true;
|
|
107
|
+
if (err.code === "ENOENT" || err.code === "NoSuchKey") return true;
|
|
108
|
+
return Boolean(
|
|
109
|
+
err.message?.includes("NoSuchKey") || err.message?.includes("ENOENT"),
|
|
110
|
+
);
|
|
111
|
+
}
|