@hodela/site 0.1.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/README.md +7 -0
- package/dist/admin.functions.d.ts +18 -0
- package/dist/admin.functions.js +17 -0
- package/dist/customer-auth.js +1 -1
- package/dist/email-client.d.ts +55 -0
- package/dist/email-client.js +108 -0
- package/dist/error-page.d.ts +1 -0
- package/dist/error-page.js +30 -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 +2 -2
- package/dist/react/admin-gate.d.ts +25 -0
- package/dist/react/admin-gate.js +62 -0
- package/dist/react/error-reporting.d.ts +21 -0
- package/dist/react/error-reporting.js +43 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +2 -0
- package/dist/server/admin.d.ts +10 -0
- package/dist/server/admin.js +61 -0
- package/dist/server/customer-auth.d.ts +11 -0
- package/dist/server/customer-auth.js +45 -0
- package/dist/server/db.d.ts +20 -0
- package/dist/server/db.js +133 -0
- package/dist/server/entry.d.ts +4 -0
- package/dist/server/entry.js +97 -0
- package/dist/server/env.d.ts +7 -0
- package/dist/server/env.js +14 -0
- package/dist/server/error-capture.d.ts +2 -0
- package/dist/server/error-capture.js +72 -0
- package/dist/server/files.d.ts +17 -0
- package/dist/server/files.js +21 -0
- package/dist/server/mailer.d.ts +10 -0
- package/dist/server/mailer.js +16 -0
- package/dist/server/session-cookie.d.ts +2 -0
- package/dist/server/session-cookie.js +7 -0
- package/dist/start.d.ts +2 -0
- package/dist/start.js +27 -0
- package/dist/testing/cloudflare-workers.d.ts +1 -0
- package/dist/testing/cloudflare-workers.js +3 -0
- package/package.json +75 -5
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @hodela/site
|
|
2
|
+
|
|
3
|
+
Mã chạy chung của website Hodela, để starter không phải chép vào từng website (cập nhật bằng cách nâng phiên bản gói).
|
|
4
|
+
|
|
5
|
+
Subpath: `access` (PIN, danh sách IP, chặn bot, badge), `analytics`, `sql-split` (bộ tách câu lệnh migration, nền tảng cũng dùng), `admin-token`, `session-cookie`, `customer-auth`, `mailer`, `seo`.
|
|
6
|
+
|
|
7
|
+
Quy ước: thay đổi phải tương thích ngược (mọi website dùng chung một bản trong image build). Test nằm cạnh mã (`pnpm --filter @hodela/site test`). Lộ trình chuyển nốt phần còn lại của starter: `docs/development.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const adminStatus: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
|
|
3
|
+
configured: boolean;
|
|
4
|
+
authed: boolean;
|
|
5
|
+
}>>;
|
|
6
|
+
export declare const adminLogin: import("@tanstack/react-start").RequiredFetcher<undefined, z.ZodObject<{
|
|
7
|
+
password: z.ZodString;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
password: string;
|
|
10
|
+
}, {
|
|
11
|
+
password: string;
|
|
12
|
+
}>, Promise<{
|
|
13
|
+
ok: boolean;
|
|
14
|
+
reason?: "not_configured" | "locked" | "wrong";
|
|
15
|
+
}>>;
|
|
16
|
+
export declare const adminLogout: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
}>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Server function cho khu vực quản lý (dùng bởi <AdminGate>/useAdminSession). Hodela quản lý file này: không sửa.
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { adminConfigured, isAdmin, loginAdmin } from "./server/admin.js";
|
|
5
|
+
export const adminStatus = createServerFn({ method: "GET" }).handler(async () => {
|
|
6
|
+
return { configured: adminConfigured(), authed: await isAdmin() };
|
|
7
|
+
});
|
|
8
|
+
export const adminLogin = createServerFn({ method: "POST" })
|
|
9
|
+
.validator(z.object({ password: z.string().min(1).max(200) }))
|
|
10
|
+
.handler(async ({ data }) => {
|
|
11
|
+
return loginAdmin(data.password);
|
|
12
|
+
});
|
|
13
|
+
export const adminLogout = createServerFn({ method: "POST" }).handler(async () => {
|
|
14
|
+
const { logoutAdmin } = await import("./server/admin.js");
|
|
15
|
+
logoutAdmin();
|
|
16
|
+
return { ok: true };
|
|
17
|
+
});
|
package/dist/customer-auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Tài khoản của KHÁCH HÀNG cuối (khác khu vực quản lý của chủ web): băm mật khẩu và mã phiên. Thuần, kiểm thử được.
|
|
2
|
-
//
|
|
2
|
+
// Dùng qua "@hodela/site/server/customer-auth".
|
|
3
3
|
const enc = new TextEncoder();
|
|
4
4
|
// Cloudflare Workers giới hạn PBKDF2 tối đa 100.000 vòng.
|
|
5
5
|
const ITERATIONS = 100_000;
|
|
@@ -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 @@
|
|
|
1
|
+
export declare function renderErrorPage(): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function renderErrorPage() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<title>This page didn't load</title>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<style>
|
|
9
|
+
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
|
|
10
|
+
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
|
|
11
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
12
|
+
p { color: #4b5563; margin: 0 0 1.5rem; }
|
|
13
|
+
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
|
14
|
+
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
|
|
15
|
+
.primary { background: #111; color: #fff; }
|
|
16
|
+
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div class="card">
|
|
21
|
+
<h1>This page didn't load</h1>
|
|
22
|
+
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
|
|
23
|
+
<div class="actions">
|
|
24
|
+
<button class="primary" onclick="location.reload()">Try again</button>
|
|
25
|
+
<a class="secondary" href="/">Go home</a>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</body>
|
|
29
|
+
</html>`;
|
|
30
|
+
}
|
|
@@ -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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Gửi email thông báo (form liên hệ, đơn hàng, lịch hẹn...). Thuần (nhận env vào) nên kiểm thử được.
|
|
2
|
-
//
|
|
2
|
+
// Dùng qua "@hodela/site/server/mailer".
|
|
3
3
|
//
|
|
4
4
|
// Cách gửi do chủ web chọn, suy ra từ biến môi trường (agent chỉ hỏi chủ web rồi xin đúng các biến này):
|
|
5
5
|
// • Gửi bằng Hodela : đặt NOTIFY_EMAIL (hộp thư nhận thông báo). Hodela cấp sẵn HODELA_API_URL/HODELA_API_KEY.
|
|
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";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type AdminLoginResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: "wrong" | "locked";
|
|
7
|
+
};
|
|
8
|
+
export type AdminSession = {
|
|
9
|
+
/** `loading`: đang hỏi server; `signedOut`: chưa đăng nhập; `signedIn`: đã đăng nhập. */
|
|
10
|
+
state: "loading" | "signedOut" | "signedIn";
|
|
11
|
+
/** `false` khi chủ website chưa đặt biến ADMIN_PASSWORD: khu vực quản lý bị khóa, chưa ai đăng nhập được. */
|
|
12
|
+
configured: boolean;
|
|
13
|
+
/** Đang gửi yêu cầu đăng nhập hoặc đăng xuất. */
|
|
14
|
+
busy: boolean;
|
|
15
|
+
/** `wrong`: sai mật khẩu; `locked`: thử sai quá nhiều lần, chờ ít phút. */
|
|
16
|
+
login: (password: string) => Promise<AdminLoginResult>;
|
|
17
|
+
logout: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/** Phiên quản trị hiện tại. Chỉ dùng bên trong <AdminGate> (cả trong `fallback` và `children`). */
|
|
20
|
+
export declare function useAdminSession(): AdminSession;
|
|
21
|
+
export declare function AdminGate({ children, fallback, loading }: {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
fallback: (session: AdminSession) => ReactNode;
|
|
24
|
+
loading?: ReactNode;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Cổng đăng nhập cho khu vực quản lý của chủ website. Hodela quản lý file này: không sửa.
|
|
3
|
+
//
|
|
4
|
+
// File này chỉ giữ LOGIC phiên quản trị (không vẽ giao diện nào). Giao diện đăng nhập và khung trang do website tự quyết:
|
|
5
|
+
// • mẫu có sẵn, được phép sửa/thay: `@/components/admin-login` (form) và `@/components/admin-layout` (khung);
|
|
6
|
+
// • hoặc tự dựng giao diện riêng bằng `useAdminSession()`.
|
|
7
|
+
//
|
|
8
|
+
// export const Route = createFileRoute("/admin")({
|
|
9
|
+
// component: () => (
|
|
10
|
+
// <AdminGate fallback={(session) => <AdminLogin session={session} />}>
|
|
11
|
+
// <AdminLayout title="Admin" nav={[{ to: "/admin", label: "Overview", exact: true }]}>
|
|
12
|
+
// <Outlet />
|
|
13
|
+
// </AdminLayout>
|
|
14
|
+
// </AdminGate>
|
|
15
|
+
// ),
|
|
16
|
+
// });
|
|
17
|
+
//
|
|
18
|
+
// Chỉ bọc giao diện; dữ liệu vẫn phải được bảo vệ bằng `await assertAdmin()` trong server function.
|
|
19
|
+
import { useRouter } from "@tanstack/react-router";
|
|
20
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
21
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
22
|
+
import { adminLogin, adminLogout, adminStatus } from "../admin.functions.js";
|
|
23
|
+
const SessionContext = createContext(null);
|
|
24
|
+
/** Phiên quản trị hiện tại. Chỉ dùng bên trong <AdminGate> (cả trong `fallback` và `children`). */
|
|
25
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
26
|
+
export function useAdminSession() {
|
|
27
|
+
const session = useContext(SessionContext);
|
|
28
|
+
if (!session)
|
|
29
|
+
throw new Error("useAdminSession must be used inside <AdminGate>.");
|
|
30
|
+
return session;
|
|
31
|
+
}
|
|
32
|
+
export function AdminGate({ children, fallback, loading = null }) {
|
|
33
|
+
const status = useServerFn(adminStatus);
|
|
34
|
+
const loginFn = useServerFn(adminLogin);
|
|
35
|
+
const logoutFn = useServerFn(adminLogout);
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const [info, setInfo] = useState(null);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const refresh = useCallback(() => status().then(setInfo).catch(() => setInfo({ configured: true, authed: false })), [status]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void refresh();
|
|
42
|
+
}, [refresh]);
|
|
43
|
+
const login = useCallback(async (password) => {
|
|
44
|
+
setBusy(true);
|
|
45
|
+
const result = await loginFn({ data: { password } }).catch(() => ({ ok: false, reason: "wrong" }));
|
|
46
|
+
await refresh();
|
|
47
|
+
// Đổi phiên thì dữ liệu route (loader) đã tải khi chưa đăng nhập phải tải lại.
|
|
48
|
+
if (result.ok)
|
|
49
|
+
await router.invalidate();
|
|
50
|
+
setBusy(false);
|
|
51
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.reason === "locked" ? "locked" : "wrong" };
|
|
52
|
+
}, [loginFn, refresh, router]);
|
|
53
|
+
const logout = useCallback(async () => {
|
|
54
|
+
setBusy(true);
|
|
55
|
+
await logoutFn().catch(() => { });
|
|
56
|
+
await refresh();
|
|
57
|
+
await router.invalidate();
|
|
58
|
+
setBusy(false);
|
|
59
|
+
}, [logoutFn, refresh]);
|
|
60
|
+
const session = useMemo(() => ({ state: !info ? "loading" : info.authed ? "signedIn" : "signedOut", configured: info?.configured ?? true, busy, login, logout }), [info, busy, login, logout]);
|
|
61
|
+
return _jsx(SessionContext.Provider, { value: session, children: session.state === "loading" ? loading : session.state === "signedIn" ? children : fallback(session) });
|
|
62
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type HodelaErrorOptions = {
|
|
2
|
+
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
|
3
|
+
handled?: boolean;
|
|
4
|
+
severity?: "error" | "warning" | "info";
|
|
5
|
+
};
|
|
6
|
+
type HodelaEvents = {
|
|
7
|
+
track?: (event: string, properties?: Record<string, unknown>) => string | null;
|
|
8
|
+
captureException?: (error: unknown, context?: Record<string, unknown>, options?: HodelaErrorOptions) => void;
|
|
9
|
+
};
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__hodelaEvents?: HodelaEvents;
|
|
13
|
+
__hodelaReportRuntimeError?: (payload: {
|
|
14
|
+
message: string;
|
|
15
|
+
stack?: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export declare function reportHodelaError(error: unknown, context?: Record<string, unknown>): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function reportHodelaError(error, context = {}) {
|
|
2
|
+
if (typeof window === "undefined")
|
|
3
|
+
return;
|
|
4
|
+
window.__hodelaEvents?.captureException?.(error, {
|
|
5
|
+
source: "react_error_boundary",
|
|
6
|
+
route: window.location.pathname,
|
|
7
|
+
...context,
|
|
8
|
+
}, {
|
|
9
|
+
mechanism: "react_error_boundary",
|
|
10
|
+
handled: false,
|
|
11
|
+
severity: "error",
|
|
12
|
+
});
|
|
13
|
+
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
|
14
|
+
// editor's telemetry never sees them. Forward to hodela.js's reporting hook,
|
|
15
|
+
// which is present only inside the editor preview.
|
|
16
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
17
|
+
window.__hodelaReportRuntimeError?.({
|
|
18
|
+
message: describeThrown(error),
|
|
19
|
+
...(stack !== undefined && { stack }),
|
|
20
|
+
filename: window.location.pathname,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const MAX_SERIALIZED_LENGTH = 2000;
|
|
24
|
+
// Loaders and server fns throw raw Responses and plain objects (a Supabase
|
|
25
|
+
// `{ message, code, details }`), which String() reduces to "[object ...]".
|
|
26
|
+
function describeThrown(error) {
|
|
27
|
+
if (error instanceof Response) {
|
|
28
|
+
return `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`;
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof Error)
|
|
31
|
+
return error.message;
|
|
32
|
+
if (typeof error === "string")
|
|
33
|
+
return error;
|
|
34
|
+
const { message } = (error ?? {});
|
|
35
|
+
if (typeof message === "string" && message.length > 0)
|
|
36
|
+
return message;
|
|
37
|
+
try {
|
|
38
|
+
return (JSON.stringify(error)?.slice(0, MAX_SERIALIZED_LENGTH) ?? String(error));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return String(error);
|
|
42
|
+
}
|
|
43
|
+
}
|