@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.
- package/PLATFORM.md +127 -0
- package/package.json +21 -2
- package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
- package/src/branch-scope/context.ts +66 -0
- package/src/branch-scope/guard.ts +100 -0
- package/src/branch-scope/index.ts +42 -0
- package/src/branch-scope/scope.ts +149 -0
- package/src/branch-scope/types.ts +57 -0
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|