@goplusvn/core 0.1.57 → 0.1.59

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.
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import type { NextRequest } from "next/server";
2
+
3
+ import { safeFileName } from "./keys";
4
+ import { putFile } from "./storage-service";
5
+
6
+ /**
7
+ * Route factory nhận tập tin tải lên.
8
+ *
9
+ * Trả về URL dạng `/api/files/<key>` cho MỌI chế độ kho (S3 hay đĩa local) —
10
+ * cố ý: URL đã lưu trong cột `attachments` không được đổi nghĩa khi admin bật
11
+ * hay tắt S3. Bản cũ ở app ghi thẳng vào `public/uploads` khi chưa có S3, tức
12
+ * chứng từ tài chính và CCCD nằm trong thư mục web đọc công khai không cần
13
+ * phiên; ở đây đĩa local là thư mục PRIVATE và vẫn phải đi qua proxy có quyền.
14
+ *
15
+ * // src/app/api/upload/route.ts
16
+ * export const POST = apiHandler(createUploadHandler())
17
+ */
18
+
19
+ export interface UploadHandlerOptions {
20
+ /** Mặc định 25MB. */
21
+ maxBytes?: number;
22
+ /** Đuôi cho phép (chữ thường, không dấu chấm). */
23
+ allowedExtensions?: string[];
24
+ /** Tiền tố key trong kho. Mặc định `uploads`. */
25
+ prefix?: string;
26
+ /** Tiền tố URL trả về. Mặc định `/api/files`. */
27
+ urlPrefix?: string;
28
+ onError?: (error: unknown, req: NextRequest) => Response | Promise<Response>;
29
+ }
30
+
31
+ const DEFAULT_ALLOWED = [
32
+ "pdf",
33
+ "jpg",
34
+ "jpeg",
35
+ "png",
36
+ "gif",
37
+ "webp",
38
+ "doc",
39
+ "docx",
40
+ "xls",
41
+ "xlsx",
42
+ "csv",
43
+ "txt",
44
+ ];
45
+
46
+ /** Thư mục theo ngày (YYYYMMDD) để một ngày gom về một chỗ. */
47
+ function dateDir(now: Date): string {
48
+ const y = now.getFullYear();
49
+ const m = String(now.getMonth() + 1).padStart(2, "0");
50
+ const d = String(now.getDate()).padStart(2, "0");
51
+ return `${y}${m}${d}`;
52
+ }
53
+
54
+ export function createUploadHandler(options: UploadHandlerOptions = {}) {
55
+ const maxBytes = options.maxBytes ?? 25 * 1024 * 1024;
56
+ const allowed = new Set(options.allowedExtensions ?? DEFAULT_ALLOWED);
57
+ const prefix = (options.prefix ?? "uploads").replace(/^\/|\/$/g, "");
58
+ const urlPrefix = (options.urlPrefix ?? "/api/files").replace(/\/$/, "");
59
+
60
+ return async function handler(req: NextRequest): Promise<Response> {
61
+ try {
62
+ const formData = await req.formData();
63
+ const file = formData.get("file");
64
+ if (!file || typeof file === "string") {
65
+ return Response.json({ error: "No file uploaded" }, { status: 400 });
66
+ }
67
+
68
+ if (file.size > maxBytes) {
69
+ const mb = Math.round(maxBytes / (1024 * 1024));
70
+ return Response.json(
71
+ { error: `Tập tin quá lớn (tối đa ${mb}MB)` },
72
+ { status: 413 },
73
+ );
74
+ }
75
+
76
+ const safeName = safeFileName(file.name || "file");
77
+ const ext = (safeName.split(".").pop() || "").toLowerCase();
78
+ if (!allowed.has(ext)) {
79
+ return Response.json(
80
+ { error: "Định dạng tập tin không được hỗ trợ" },
81
+ { status: 415 },
82
+ );
83
+ }
84
+
85
+ // Chèn timestamp trước phần mở rộng — cùng tên tải lên hai lần không đè nhau.
86
+ const dot = safeName.lastIndexOf(".");
87
+ const base = dot > 0 ? safeName.slice(0, dot) : safeName;
88
+ const key = `${prefix}/${dateDir(new Date())}/${base}_${Date.now()}.${ext}`;
89
+
90
+ const buffer = Buffer.from(await file.arrayBuffer());
91
+ await putFile(key, buffer, file.type || "application/octet-stream");
92
+
93
+ return Response.json({ url: `${urlPrefix}/${key}`, key });
94
+ } catch (error) {
95
+ if (options.onError) return options.onError(error, req);
96
+ console.error("[storage] upload lỗi:", error);
97
+ return Response.json({ error: "Upload failed" }, { status: 500 });
98
+ }
99
+ };
100
+ }