@hodela/site 0.2.0 → 0.3.0
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/dist/email-client.d.ts +55 -0
- package/dist/email-client.js +108 -0
- package/dist/file-client.d.ts +87 -0
- package/dist/file-client.js +117 -0
- package/dist/mailer.d.ts +1 -1
- package/dist/mailer.js +1 -1
- package/dist/server/files.d.ts +6 -6
- package/dist/server/files.js +2 -2
- package/package.json +9 -5
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gửi email từ website Hodela qua API nền tảng. Nền tảng giữ khóa nhà cung cấp mail, kiểm quyền và
|
|
3
|
+
* giới hạn tần suất theo website; mã website chỉ cần `HODELA_API_URL` + `HODELA_API_KEY` (do Hodela cấp).
|
|
4
|
+
*/
|
|
5
|
+
export type SendEmailInput = {
|
|
6
|
+
/** Một hoặc tối đa 5 người nhận. */
|
|
7
|
+
to: string | string[];
|
|
8
|
+
subject: string;
|
|
9
|
+
/** Cần ít nhất một trong `text` hoặc `html`. */
|
|
10
|
+
text?: string | undefined;
|
|
11
|
+
html?: string | undefined;
|
|
12
|
+
/** Người nhận bấm "Trả lời" sẽ gửi về địa chỉ này (ví dụ email khách trong form liên hệ). */
|
|
13
|
+
replyTo?: string | undefined;
|
|
14
|
+
/** Gửi lại cùng khóa trong 24 giờ không tạo thêm thư (an toàn khi thử lại). */
|
|
15
|
+
idempotencyKey?: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
/** `suppressed`: mọi người nhận đã từng trả thư về hoặc báo spam nên không gửi. */
|
|
18
|
+
export type SendEmailResult = {
|
|
19
|
+
id: string;
|
|
20
|
+
status: "sent" | "duplicate" | "suppressed";
|
|
21
|
+
};
|
|
22
|
+
export type EmailLogEntry = {
|
|
23
|
+
recipient: string;
|
|
24
|
+
subject: string;
|
|
25
|
+
status: "pending" | "sent" | "failed" | "bounced" | "complained" | (string & {});
|
|
26
|
+
error: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
};
|
|
29
|
+
export type EmailErrorCode = "not_configured" | "unauthorized" | "disabled" | "invalid_request" | "rate_limited" | "unavailable" | "network";
|
|
30
|
+
export declare class HodelaEmailError extends Error {
|
|
31
|
+
readonly code: EmailErrorCode;
|
|
32
|
+
readonly status: number | null;
|
|
33
|
+
/** Số giây nên chờ trước khi thử lại (khi `code` là rate_limited). */
|
|
34
|
+
readonly retryAfterSec: number | null;
|
|
35
|
+
constructor(code: EmailErrorCode, message: string, opts?: {
|
|
36
|
+
status?: number | undefined;
|
|
37
|
+
retryAfterSec?: number | undefined;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export type HodelaEmailClientOptions = {
|
|
41
|
+
apiUrl: string | undefined;
|
|
42
|
+
apiKey: string | undefined;
|
|
43
|
+
/** Mặc định `fetch` toàn cục. */
|
|
44
|
+
fetch?: typeof fetch | undefined;
|
|
45
|
+
timeoutMs?: number | undefined;
|
|
46
|
+
};
|
|
47
|
+
export declare function validateSendInput(input: SendEmailInput): string | null;
|
|
48
|
+
export declare function createHodelaEmailClient(options: HodelaEmailClientOptions): {
|
|
49
|
+
/** Thư gần đây của website (mới nhất trước, tối đa 100): người nhận, tiêu đề, trạng thái; không có nội dung thư. */
|
|
50
|
+
logs(limit?: number): Promise<EmailLogEntry[]>;
|
|
51
|
+
/** Ném `HodelaEmailError` khi thất bại; mã gọi nên bắt lỗi để không làm hỏng việc chính (lưu form/đơn hàng). */
|
|
52
|
+
send(input: SendEmailInput): Promise<SendEmailResult>;
|
|
53
|
+
};
|
|
54
|
+
/** Gửi một thư với cấu hình lấy từ env của website. */
|
|
55
|
+
export declare function sendEmail(input: SendEmailInput, options: HodelaEmailClientOptions): Promise<SendEmailResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gửi email từ website Hodela qua API nền tảng. Nền tảng giữ khóa nhà cung cấp mail, kiểm quyền và
|
|
3
|
+
* giới hạn tần suất theo website; mã website chỉ cần `HODELA_API_URL` + `HODELA_API_KEY` (do Hodela cấp).
|
|
4
|
+
*/
|
|
5
|
+
export class HodelaEmailError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
/** Số giây nên chờ trước khi thử lại (khi `code` là rate_limited). */
|
|
9
|
+
retryAfterSec;
|
|
10
|
+
constructor(code, message, opts = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "HodelaEmailError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.status = opts.status ?? null;
|
|
15
|
+
this.retryAfterSec = opts.retryAfterSec ?? null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const MAX_RECIPIENTS = 5;
|
|
19
|
+
const EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
|
|
20
|
+
export function validateSendInput(input) {
|
|
21
|
+
const to = Array.isArray(input.to) ? input.to : [input.to];
|
|
22
|
+
if (to.length === 0 || to.length > MAX_RECIPIENTS)
|
|
23
|
+
return `Cần từ 1 đến ${MAX_RECIPIENTS} người nhận.`;
|
|
24
|
+
if (to.some((a) => typeof a !== "string" || !EMAIL_RE.test(a.trim()) || a.length > 254))
|
|
25
|
+
return "Địa chỉ người nhận không hợp lệ.";
|
|
26
|
+
if (input.replyTo !== undefined && !EMAIL_RE.test(input.replyTo.trim()))
|
|
27
|
+
return "replyTo không hợp lệ.";
|
|
28
|
+
if (!input.subject.trim() || /[\r\n]/.test(input.subject) || input.subject.length > 200)
|
|
29
|
+
return "Tiêu đề trống, quá dài hoặc có xuống dòng.";
|
|
30
|
+
if (!input.text?.trim() && !input.html?.trim())
|
|
31
|
+
return "Cần nội dung text hoặc html.";
|
|
32
|
+
if ((input.text?.length ?? 0) > 100_000 || (input.html?.length ?? 0) > 200_000)
|
|
33
|
+
return "Nội dung quá dài.";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export function createHodelaEmailClient(options) {
|
|
37
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
39
|
+
function classify(res, body) {
|
|
40
|
+
const message = body?.error?.message ?? `API Hodela trả về HTTP ${res.status}.`;
|
|
41
|
+
if (res.status === 401)
|
|
42
|
+
return new HodelaEmailError("unauthorized", message, { status: 401 });
|
|
43
|
+
if (res.status === 403)
|
|
44
|
+
return new HodelaEmailError("disabled", message, { status: 403 });
|
|
45
|
+
if (res.status === 400 || res.status === 422)
|
|
46
|
+
return new HodelaEmailError("invalid_request", message, { status: res.status });
|
|
47
|
+
if (res.status === 429) {
|
|
48
|
+
const retry = Number(res.headers.get("retry-after"));
|
|
49
|
+
return new HodelaEmailError("rate_limited", message, { status: 429, retryAfterSec: Number.isFinite(retry) && retry > 0 ? retry : undefined });
|
|
50
|
+
}
|
|
51
|
+
return new HodelaEmailError("unavailable", message, { status: res.status });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
/** Thư gần đây của website (mới nhất trước, tối đa 100): người nhận, tiêu đề, trạng thái; không có nội dung thư. */
|
|
55
|
+
async logs(limit = 20) {
|
|
56
|
+
if (!options.apiUrl || !options.apiKey)
|
|
57
|
+
throw new HodelaEmailError("not_configured", "Website chưa được cấp HODELA_API_URL / HODELA_API_KEY.");
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await doFetch(`${options.apiUrl.replace(/\/+$/, "")}/v1/email/logs?limit=${encodeURIComponent(String(limit))}`, {
|
|
61
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new HodelaEmailError("network", e instanceof Error ? e.message : "Không gọi được API Hodela.");
|
|
67
|
+
}
|
|
68
|
+
const body = (await res.json().catch(() => null));
|
|
69
|
+
if (res.ok && Array.isArray(body?.data))
|
|
70
|
+
return body.data;
|
|
71
|
+
throw classify(res, body);
|
|
72
|
+
},
|
|
73
|
+
/** Ném `HodelaEmailError` khi thất bại; mã gọi nên bắt lỗi để không làm hỏng việc chính (lưu form/đơn hàng). */
|
|
74
|
+
async send(input) {
|
|
75
|
+
if (!options.apiUrl || !options.apiKey) {
|
|
76
|
+
throw new HodelaEmailError("not_configured", "Website chưa được cấp HODELA_API_URL / HODELA_API_KEY.");
|
|
77
|
+
}
|
|
78
|
+
const invalid = validateSendInput(input);
|
|
79
|
+
if (invalid)
|
|
80
|
+
throw new HodelaEmailError("invalid_request", invalid);
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
+
let res;
|
|
84
|
+
try {
|
|
85
|
+
res = await doFetch(`${options.apiUrl.replace(/\/+$/, "")}/v1/email/send`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${options.apiKey}` },
|
|
88
|
+
body: JSON.stringify({ ...input, to: Array.isArray(input.to) ? input.to : [input.to] }),
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
throw new HodelaEmailError("network", e instanceof Error ? e.message : "Không gọi được API Hodela.");
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
const body = (await res.json().catch(() => null));
|
|
99
|
+
if (res.ok && body?.id)
|
|
100
|
+
return { id: body.id, status: body.status === "duplicate" ? "duplicate" : body.status === "suppressed" ? "suppressed" : "sent" };
|
|
101
|
+
throw classify(res, body);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Gửi một thư với cấu hình lấy từ env của website. */
|
|
106
|
+
export async function sendEmail(input, options) {
|
|
107
|
+
return createHodelaEmailClient(options).send(input);
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lưu và phục vụ file của website Hodela qua API nền tảng. Nền tảng lo nơi lưu (R2), hạn mức dung lượng theo gói, kiểm tra loại file
|
|
3
|
+
* bằng nội dung và an toàn khi phục vụ; mã website chỉ cần `HODELA_API_URL` + `HODELA_API_KEY` (do Hodela cấp). Chỉ dùng ở server
|
|
4
|
+
* (server function, server route): khóa API không bao giờ được đưa xuống trình duyệt.
|
|
5
|
+
*/
|
|
6
|
+
export type FileVisibility = "public" | "private";
|
|
7
|
+
export type HodelaFile = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
contentType: string;
|
|
11
|
+
/** Số byte. */
|
|
12
|
+
size: number;
|
|
13
|
+
visibility: FileVisibility;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
/** Đường dẫn công khai trên chính tên miền của website (ví dụ `/_files/<sha256>/anh.png`), dùng thẳng trong `<img src>`. `null` với file riêng tư. */
|
|
16
|
+
url: string | null;
|
|
17
|
+
};
|
|
18
|
+
export type PutFileResult = HodelaFile & {
|
|
19
|
+
/** Nội dung đã có sẵn trong website nên không tốn thêm dung lượng. */
|
|
20
|
+
deduplicated: boolean;
|
|
21
|
+
/** SVG đã bị loại phần không an toàn. */
|
|
22
|
+
sanitized: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type FileUsage = {
|
|
25
|
+
usedBytes: number;
|
|
26
|
+
files: number;
|
|
27
|
+
maxBytes: number;
|
|
28
|
+
maxFiles: number;
|
|
29
|
+
maxFileBytes: number;
|
|
30
|
+
};
|
|
31
|
+
export type SignedUrl = {
|
|
32
|
+
url: string;
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
};
|
|
35
|
+
export type FileErrorCode = "not_configured" | "unauthorized" | "disabled" | "invalid_request" | "file_too_large" | "quota_exceeded" | "quota_files" | "unsupported_type" | "unsafe_file" | "not_found" | "rate_limited" | "unavailable" | "network";
|
|
36
|
+
export declare class HodelaFileError extends Error {
|
|
37
|
+
readonly code: FileErrorCode;
|
|
38
|
+
readonly status: number | null;
|
|
39
|
+
/** Số giây nên chờ trước khi thử lại (khi `code` là rate_limited). */
|
|
40
|
+
readonly retryAfterSec: number | null;
|
|
41
|
+
constructor(code: FileErrorCode, message: string, opts?: {
|
|
42
|
+
status?: number;
|
|
43
|
+
retryAfterSec?: number;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export type HodelaFileClientOptions = {
|
|
47
|
+
apiUrl: string | undefined;
|
|
48
|
+
apiKey: string | undefined;
|
|
49
|
+
/** Mặc định `fetch` toàn cục. */
|
|
50
|
+
fetch?: typeof fetch | undefined;
|
|
51
|
+
/** Mặc định 10 giây cho thao tác thường, 60 giây cho tải lên. */
|
|
52
|
+
timeoutMs?: number | undefined;
|
|
53
|
+
};
|
|
54
|
+
export type PutFileOptions = {
|
|
55
|
+
/** Tên file (bắt buộc với dữ liệu không phải `File`). */
|
|
56
|
+
name?: string | undefined;
|
|
57
|
+
/** Mặc định `public`. File riêng tư chỉ tải được qua `signedUrl`. */
|
|
58
|
+
visibility?: FileVisibility | undefined;
|
|
59
|
+
};
|
|
60
|
+
export type FileBody = Blob | ArrayBuffer | Uint8Array;
|
|
61
|
+
export declare function createHodelaFileClient(options: HodelaFileClientOptions): {
|
|
62
|
+
/**
|
|
63
|
+
* Lưu một file. Nhận `File`/`Blob` (lấy tên từ `File.name`), `ArrayBuffer` hoặc `Uint8Array` (cần `name`).
|
|
64
|
+
* Ném `HodelaFileError` (`file_too_large`, `quota_exceeded`, `unsupported_type`, `unsafe_file`...) để hiện thông báo cho người dùng.
|
|
65
|
+
*/
|
|
66
|
+
put(body: FileBody, opts?: PutFileOptions): Promise<PutFileResult>;
|
|
67
|
+
get: (id: string) => Promise<HodelaFile>;
|
|
68
|
+
/** Mới nhất trước. Trang kế tiếp: truyền `nextBefore` của kết quả trước vào `before`. */
|
|
69
|
+
list: (opts?: {
|
|
70
|
+
limit?: number;
|
|
71
|
+
before?: string;
|
|
72
|
+
}) => Promise<{
|
|
73
|
+
data: HodelaFile[];
|
|
74
|
+
nextBefore: string | null;
|
|
75
|
+
}>;
|
|
76
|
+
/** Dung lượng đã dùng và hạn mức theo gói của chủ website. */
|
|
77
|
+
usage: () => Promise<FileUsage>;
|
|
78
|
+
/** URL tải file riêng tư, hết hạn sau `ttlSec` (mặc định 5 phút, tối đa 24 giờ). Chỉ đưa cho người được phép xem. */
|
|
79
|
+
signedUrl: (id: string, opts?: {
|
|
80
|
+
ttlSec?: number;
|
|
81
|
+
}) => Promise<SignedUrl>;
|
|
82
|
+
delete(id: string): Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
/** Lưu một file với cấu hình lấy từ env của website. */
|
|
85
|
+
export declare function putFile(body: FileBody, opts: PutFileOptions, client: HodelaFileClientOptions): Promise<PutFileResult>;
|
|
86
|
+
/** Câu thông báo thân thiện cho người dùng cuối theo mã lỗi (có thể thay bằng câu của bạn). */
|
|
87
|
+
export declare function describeFileError(error: unknown): string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lưu và phục vụ file của website Hodela qua API nền tảng. Nền tảng lo nơi lưu (R2), hạn mức dung lượng theo gói, kiểm tra loại file
|
|
3
|
+
* bằng nội dung và an toàn khi phục vụ; mã website chỉ cần `HODELA_API_URL` + `HODELA_API_KEY` (do Hodela cấp). Chỉ dùng ở server
|
|
4
|
+
* (server function, server route): khóa API không bao giờ được đưa xuống trình duyệt.
|
|
5
|
+
*/
|
|
6
|
+
export class HodelaFileError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
status;
|
|
9
|
+
/** Số giây nên chờ trước khi thử lại (khi `code` là rate_limited). */
|
|
10
|
+
retryAfterSec;
|
|
11
|
+
constructor(code, message, opts = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "HodelaFileError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = opts.status ?? null;
|
|
16
|
+
this.retryAfterSec = opts.retryAfterSec ?? null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const KNOWN_CODES = new Set(["invalid_request", "file_too_large", "quota_exceeded", "quota_files", "unsupported_type", "unsafe_file", "not_found", "rate_limited", "disabled", "unauthorized"]);
|
|
20
|
+
export function createHodelaFileClient(options) {
|
|
21
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
22
|
+
const base = () => {
|
|
23
|
+
if (!options.apiUrl || !options.apiKey)
|
|
24
|
+
throw new HodelaFileError("not_configured", "Website chưa được cấp HODELA_API_URL / HODELA_API_KEY.");
|
|
25
|
+
return options.apiUrl.replace(/\/+$/, "");
|
|
26
|
+
};
|
|
27
|
+
function classify(res, body) {
|
|
28
|
+
const message = body?.error?.message ?? `API Hodela trả về HTTP ${res.status}.`;
|
|
29
|
+
const code = body?.error?.code;
|
|
30
|
+
if (code && KNOWN_CODES.has(code)) {
|
|
31
|
+
const retry = Number(res.headers.get("retry-after"));
|
|
32
|
+
return new HodelaFileError(code, message, { status: res.status, ...(Number.isFinite(retry) && retry > 0 ? { retryAfterSec: retry } : {}) });
|
|
33
|
+
}
|
|
34
|
+
if (res.status === 401)
|
|
35
|
+
return new HodelaFileError("unauthorized", message, { status: 401 });
|
|
36
|
+
if (res.status === 403)
|
|
37
|
+
return new HodelaFileError("disabled", message, { status: 403 });
|
|
38
|
+
if (res.status === 404)
|
|
39
|
+
return new HodelaFileError("not_found", message, { status: 404 });
|
|
40
|
+
if (res.status === 413)
|
|
41
|
+
return new HodelaFileError("file_too_large", message, { status: 413 });
|
|
42
|
+
if (res.status === 415)
|
|
43
|
+
return new HodelaFileError("unsupported_type", message, { status: 415 });
|
|
44
|
+
if (res.status === 400 || res.status === 422)
|
|
45
|
+
return new HodelaFileError("invalid_request", message, { status: res.status });
|
|
46
|
+
if (res.status === 429) {
|
|
47
|
+
const retry = Number(res.headers.get("retry-after"));
|
|
48
|
+
return new HodelaFileError("rate_limited", message, { status: 429, ...(Number.isFinite(retry) && retry > 0 ? { retryAfterSec: retry } : {}) });
|
|
49
|
+
}
|
|
50
|
+
return new HodelaFileError("unavailable", message, { status: res.status });
|
|
51
|
+
}
|
|
52
|
+
async function request(path, init = {}) {
|
|
53
|
+
const root = base();
|
|
54
|
+
const { timeoutMs, ...rest } = init;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await doFetch(`${root}${path}`, {
|
|
58
|
+
...rest,
|
|
59
|
+
headers: { authorization: `Bearer ${options.apiKey}`, ...rest.headers },
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs ?? options.timeoutMs ?? 10_000),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
throw new HodelaFileError("network", e instanceof Error ? e.message : "Không gọi được API Hodela.");
|
|
65
|
+
}
|
|
66
|
+
const body = (await res.json().catch(() => null));
|
|
67
|
+
if (res.ok && body)
|
|
68
|
+
return body;
|
|
69
|
+
throw classify(res, body);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
/**
|
|
73
|
+
* Lưu một file. Nhận `File`/`Blob` (lấy tên từ `File.name`), `ArrayBuffer` hoặc `Uint8Array` (cần `name`).
|
|
74
|
+
* Ném `HodelaFileError` (`file_too_large`, `quota_exceeded`, `unsupported_type`, `unsafe_file`...) để hiện thông báo cho người dùng.
|
|
75
|
+
*/
|
|
76
|
+
async put(body, opts = {}) {
|
|
77
|
+
const name = opts.name ?? (typeof File !== "undefined" && body instanceof File ? body.name : undefined);
|
|
78
|
+
if (!name?.trim())
|
|
79
|
+
throw new HodelaFileError("invalid_request", "Thiếu tên file.");
|
|
80
|
+
const payload = body instanceof Uint8Array ? body : body instanceof ArrayBuffer ? new Uint8Array(body) : body;
|
|
81
|
+
return request("/v1/files", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "x-file-name": encodeURIComponent(name), "x-file-visibility": opts.visibility ?? "public", "content-type": "application/octet-stream" },
|
|
84
|
+
body: payload,
|
|
85
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
get: (id) => request(`/v1/files/${encodeURIComponent(id)}`),
|
|
89
|
+
/** Mới nhất trước. Trang kế tiếp: truyền `nextBefore` của kết quả trước vào `before`. */
|
|
90
|
+
list: (opts = {}) => request(`/v1/files?limit=${encodeURIComponent(String(opts.limit ?? 50))}${opts.before ? `&before=${encodeURIComponent(opts.before)}` : ""}`),
|
|
91
|
+
/** Dung lượng đã dùng và hạn mức theo gói của chủ website. */
|
|
92
|
+
usage: () => request("/v1/files/usage"),
|
|
93
|
+
/** URL tải file riêng tư, hết hạn sau `ttlSec` (mặc định 5 phút, tối đa 24 giờ). Chỉ đưa cho người được phép xem. */
|
|
94
|
+
signedUrl: (id, opts = {}) => request(`/v1/files/${encodeURIComponent(id)}/signed-url`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ttlSec: opts.ttlSec }) }),
|
|
95
|
+
async delete(id) {
|
|
96
|
+
await request(`/v1/files/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Lưu một file với cấu hình lấy từ env của website. */
|
|
101
|
+
export function putFile(body, opts, client) {
|
|
102
|
+
return createHodelaFileClient(client).put(body, opts);
|
|
103
|
+
}
|
|
104
|
+
/** Câu thông báo thân thiện cho người dùng cuối theo mã lỗi (có thể thay bằng câu của bạn). */
|
|
105
|
+
export function describeFileError(error) {
|
|
106
|
+
if (!(error instanceof HodelaFileError))
|
|
107
|
+
return "Không tải được file, thử lại sau.";
|
|
108
|
+
switch (error.code) {
|
|
109
|
+
case "file_too_large": return "File quá lớn.";
|
|
110
|
+
case "quota_exceeded": return "Đã hết dung lượng lưu trữ.";
|
|
111
|
+
case "quota_files": return "Đã đạt số file tối đa.";
|
|
112
|
+
case "unsupported_type": return "Loại file này chưa được hỗ trợ (ảnh, SVG, PDF, .txt, .csv).";
|
|
113
|
+
case "unsafe_file": return "File chứa nội dung không an toàn.";
|
|
114
|
+
case "rate_limited": return "Thao tác quá nhanh, thử lại sau ít phút.";
|
|
115
|
+
default: return "Không tải được file, thử lại sau.";
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/mailer.d.ts
CHANGED
package/dist/mailer.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// • Gửi bằng Resend riêng: đặt RESEND_API_KEY + MAIL_FROM (+ NOTIFY_EMAIL). Thư đi bằng tên miền của chủ web.
|
|
7
7
|
// • Không gửi email : không đặt gì. Dữ liệu vẫn LUÔN được lưu vào database; chủ web xem ở khu vực quản lý.
|
|
8
8
|
// Việc gửi thư không bao giờ ném lỗi và không bao giờ được chặn việc lưu dữ liệu.
|
|
9
|
-
import { createHodelaEmailClient, HodelaEmailError } from "
|
|
9
|
+
import { createHodelaEmailClient, HodelaEmailError } from "./email-client.js";
|
|
10
10
|
export function mailProvider(env) {
|
|
11
11
|
if (env("RESEND_API_KEY") && env("MAIL_FROM"))
|
|
12
12
|
return "resend";
|
package/dist/server/files.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
export { describeFileError, HodelaFileError } from "
|
|
1
|
+
export { describeFileError, HodelaFileError } from "../file-client.js";
|
|
2
2
|
export declare function files(): {
|
|
3
|
-
put(body: import("
|
|
4
|
-
get: (id: string) => Promise<import("
|
|
3
|
+
put(body: import("../file-client.js").FileBody, opts?: import("../file-client.js").PutFileOptions): Promise<import("../file-client.js").PutFileResult>;
|
|
4
|
+
get: (id: string) => Promise<import("../file-client.js").HodelaFile>;
|
|
5
5
|
list: (opts?: {
|
|
6
6
|
limit?: number;
|
|
7
7
|
before?: string;
|
|
8
8
|
}) => Promise<{
|
|
9
|
-
data: import("
|
|
9
|
+
data: import("../file-client.js").HodelaFile[];
|
|
10
10
|
nextBefore: string | null;
|
|
11
11
|
}>;
|
|
12
|
-
usage: () => Promise<import("
|
|
12
|
+
usage: () => Promise<import("../file-client.js").FileUsage>;
|
|
13
13
|
signedUrl: (id: string, opts?: {
|
|
14
14
|
ttlSec?: number;
|
|
15
|
-
}) => Promise<import("
|
|
15
|
+
}) => Promise<import("../file-client.js").SignedUrl>;
|
|
16
16
|
delete(id: string): Promise<void>;
|
|
17
17
|
};
|
package/dist/server/files.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
// Cùng một đoạn mã chạy ở mọi nơi:
|
|
8
8
|
// • Hodela (xem thử và đã xuất bản): kho file của nền tảng (R2) qua HODELA_API_URL / HODELA_API_KEY do Hodela cấp.
|
|
9
9
|
// • Máy dev (`pnpm dev`, không có hai biến trên): plugin Vite ghi ảnh vào public/hodela-media (@hodela/dev).
|
|
10
|
-
import { createHodelaFileClient } from "
|
|
10
|
+
import { createHodelaFileClient } from "../file-client.js";
|
|
11
11
|
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
12
12
|
import { serverEnv } from "./env.js";
|
|
13
|
-
export { describeFileError, HodelaFileError } from "
|
|
13
|
+
export { describeFileError, HodelaFileError } from "../file-client.js";
|
|
14
14
|
export function files() {
|
|
15
15
|
const apiUrl = serverEnv("HODELA_API_URL");
|
|
16
16
|
const apiKey = serverEnv("HODELA_API_KEY");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hodela/site",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -86,6 +86,14 @@
|
|
|
86
86
|
"./testing/cloudflare-workers": {
|
|
87
87
|
"types": "./dist/testing/cloudflare-workers.d.ts",
|
|
88
88
|
"import": "./dist/testing/cloudflare-workers.js"
|
|
89
|
+
},
|
|
90
|
+
"./email-client": {
|
|
91
|
+
"types": "./dist/email-client.d.ts",
|
|
92
|
+
"import": "./dist/email-client.js"
|
|
93
|
+
},
|
|
94
|
+
"./file-client": {
|
|
95
|
+
"types": "./dist/file-client.d.ts",
|
|
96
|
+
"import": "./dist/file-client.js"
|
|
89
97
|
}
|
|
90
98
|
},
|
|
91
99
|
"scripts": {
|
|
@@ -120,10 +128,6 @@
|
|
|
120
128
|
"publishConfig": {
|
|
121
129
|
"access": "public"
|
|
122
130
|
},
|
|
123
|
-
"dependencies": {
|
|
124
|
-
"@hodela/email-js": "0.1.1",
|
|
125
|
-
"@hodela/file-js": "0.1.0"
|
|
126
|
-
},
|
|
127
131
|
"peerDependenciesMeta": {
|
|
128
132
|
"drizzle-orm": {
|
|
129
133
|
"optional": true
|