@hodela/site 0.0.0-stage → 0.2.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 +6 -2
- package/dist/access.d.ts +28 -0
- package/dist/access.js +236 -0
- package/dist/admin-token.d.ts +6 -0
- package/dist/admin-token.js +34 -0
- package/dist/admin.functions.d.ts +18 -0
- package/dist/admin.functions.js +17 -0
- package/dist/analytics.d.ts +9 -0
- package/dist/analytics.js +50 -0
- package/dist/customer-auth.d.ts +8 -0
- package/dist/customer-auth.js +58 -0
- package/dist/error-page.d.ts +1 -0
- package/dist/error-page.js +30 -0
- package/dist/mailer.d.ts +32 -0
- package/dist/mailer.js +82 -0
- 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/seo.d.ts +60 -0
- package/dist/seo.js +99 -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/session-cookie.d.ts +15 -0
- package/dist/session-cookie.js +18 -0
- package/dist/sql-split.d.ts +24 -0
- package/dist/sql-split.js +182 -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 +130 -4
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hodela/site
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type AccessConfig = {
|
|
2
|
+
pinHash?: string;
|
|
3
|
+
ipLimit?: {
|
|
4
|
+
mode: "whitelist" | "blacklist";
|
|
5
|
+
entries: string[];
|
|
6
|
+
};
|
|
7
|
+
blockBots?: boolean;
|
|
8
|
+
showBadge: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** Đọc binding HODELA_ACCESS. Thiếu hoặc sai định dạng thì không áp dụng gì (không bao giờ ném lỗi). */
|
|
11
|
+
export declare function parseAccessConfig(raw: unknown): AccessConfig;
|
|
12
|
+
type ParsedIp = {
|
|
13
|
+
value: bigint;
|
|
14
|
+
bits: 32 | 128;
|
|
15
|
+
};
|
|
16
|
+
export declare function parseIp(text: string): ParsedIp | null;
|
|
17
|
+
export declare function ipMatches(clientIp: string, entries: string[]): boolean;
|
|
18
|
+
/** true nếu IP bị chặn theo danh sách. IP không xác định được: danh sách cho phép chặn, danh sách chặn cho qua. */
|
|
19
|
+
export declare function isIpBlocked(clientIp: string | null, limit: NonNullable<AccessConfig["ipLimit"]>): boolean;
|
|
20
|
+
export declare function isBotUserAgent(userAgent: string | null): boolean;
|
|
21
|
+
export declare function sha256Hex(text: string): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Trả về phản hồi chặn (403/401/429/303) hoặc null nếu cho qua. Thứ tự: IP, bot, PIN.
|
|
24
|
+
* `secret` là HODELA_SESSION_SECRET dùng ký cookie PIN.
|
|
25
|
+
*/
|
|
26
|
+
export declare function enforceAccess(request: Request, config: AccessConfig, secret: string): Promise<Response | null>;
|
|
27
|
+
export declare function badgeHtml(config: AccessConfig): string;
|
|
28
|
+
export {};
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Truy cập vào website đã xuất bản, do chủ web bật trong Cài đặt: PIN, danh sách IP, chặn bot, badge Hodela.
|
|
2
|
+
// Nền tảng đưa cấu hình vào binding HODELA_ACCESS (JSON) và IP khách vào header x-hodela-client-ip.
|
|
3
|
+
// Chỉ áp dụng khi HODELA_ENV=production (bản xem trước dành cho chủ web). Gọi từ src/server.ts.
|
|
4
|
+
//
|
|
5
|
+
// File này do Hodela quản lý (cùng src/server.ts), đừng sửa.
|
|
6
|
+
const PIN_PATH = "/__hodela/pin";
|
|
7
|
+
const PIN_COOKIE = "hodela_pin";
|
|
8
|
+
const PIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
|
9
|
+
const MAX_PIN_FAILURES = 5;
|
|
10
|
+
const PIN_LOCK_MS = 60_000;
|
|
11
|
+
/** Đọc binding HODELA_ACCESS. Thiếu hoặc sai định dạng thì không áp dụng gì (không bao giờ ném lỗi). */
|
|
12
|
+
export function parseAccessConfig(raw) {
|
|
13
|
+
const out = { showBadge: false };
|
|
14
|
+
if (typeof raw !== "string" || raw === "")
|
|
15
|
+
return out;
|
|
16
|
+
let json;
|
|
17
|
+
try {
|
|
18
|
+
json = JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
if (!json || typeof json !== "object")
|
|
24
|
+
return out;
|
|
25
|
+
const o = json;
|
|
26
|
+
if (typeof o["pinHash"] === "string" && /^[0-9a-f]{64}$/.test(o["pinHash"]))
|
|
27
|
+
out.pinHash = o["pinHash"];
|
|
28
|
+
const ip = o["ipLimit"];
|
|
29
|
+
if (ip && (ip.mode === "whitelist" || ip.mode === "blacklist") && Array.isArray(ip.entries)) {
|
|
30
|
+
const entries = ip.entries.filter((e) => typeof e === "string" && parseCidr(e) !== null);
|
|
31
|
+
if (entries.length > 0)
|
|
32
|
+
out.ipLimit = { mode: ip.mode, entries };
|
|
33
|
+
}
|
|
34
|
+
if (o["blockBots"] === true)
|
|
35
|
+
out.blockBots = true;
|
|
36
|
+
out.showBadge = o["showBadge"] === true;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function parseIpv4(text) {
|
|
40
|
+
const parts = text.split(".");
|
|
41
|
+
if (parts.length !== 4)
|
|
42
|
+
return null;
|
|
43
|
+
let value = 0n;
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
if (!/^\d{1,3}$/.test(part))
|
|
46
|
+
return null;
|
|
47
|
+
const n = Number(part);
|
|
48
|
+
if (n > 255)
|
|
49
|
+
return null;
|
|
50
|
+
value = (value << 8n) | BigInt(n);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function parseIpv6(text) {
|
|
55
|
+
if (!/^[0-9a-fA-F:.]+$/.test(text) || !text.includes(":"))
|
|
56
|
+
return null;
|
|
57
|
+
let head = text;
|
|
58
|
+
let tail4 = null;
|
|
59
|
+
const lastColon = text.lastIndexOf(":");
|
|
60
|
+
if (text.slice(lastColon + 1).includes(".")) {
|
|
61
|
+
tail4 = parseIpv4(text.slice(lastColon + 1));
|
|
62
|
+
if (tail4 === null)
|
|
63
|
+
return null;
|
|
64
|
+
head = `${text.slice(0, lastColon + 1)}0:0`;
|
|
65
|
+
}
|
|
66
|
+
const halves = head.split("::");
|
|
67
|
+
if (halves.length > 2)
|
|
68
|
+
return null;
|
|
69
|
+
const toGroups = (s) => (s === "" ? [] : s.split(":"));
|
|
70
|
+
const left = toGroups(halves[0] ?? "");
|
|
71
|
+
const right = halves.length === 2 ? toGroups(halves[1] ?? "") : [];
|
|
72
|
+
const missing = 8 - left.length - right.length;
|
|
73
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0)
|
|
74
|
+
return null;
|
|
75
|
+
const groups = [...left, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...right];
|
|
76
|
+
if (groups.length !== 8)
|
|
77
|
+
return null;
|
|
78
|
+
let value = 0n;
|
|
79
|
+
for (const g of groups) {
|
|
80
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g))
|
|
81
|
+
return null;
|
|
82
|
+
value = (value << 16n) | BigInt(parseInt(g, 16));
|
|
83
|
+
}
|
|
84
|
+
if (tail4 !== null)
|
|
85
|
+
value = (value & ~0xffffffffn) | tail4;
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
export function parseIp(text) {
|
|
89
|
+
const t = text.trim();
|
|
90
|
+
const v4 = parseIpv4(t);
|
|
91
|
+
if (v4 !== null)
|
|
92
|
+
return { value: v4, bits: 32 };
|
|
93
|
+
const v6 = parseIpv6(t);
|
|
94
|
+
if (v6 === null)
|
|
95
|
+
return null;
|
|
96
|
+
// ::ffff:a.b.c.d là IPv4 viết dạng IPv6.
|
|
97
|
+
if (v6 >> 32n === 0xffffn)
|
|
98
|
+
return { value: v6 & 0xffffffffn, bits: 32 };
|
|
99
|
+
return { value: v6, bits: 128 };
|
|
100
|
+
}
|
|
101
|
+
function parseCidr(entry) {
|
|
102
|
+
const [addr = "", rawPrefix, ...rest] = entry.trim().split("/");
|
|
103
|
+
if (rest.length > 0)
|
|
104
|
+
return null;
|
|
105
|
+
const ip = parseIp(addr);
|
|
106
|
+
if (!ip)
|
|
107
|
+
return null;
|
|
108
|
+
if (rawPrefix === undefined)
|
|
109
|
+
return { ip, prefix: ip.bits };
|
|
110
|
+
if (!/^\d{1,3}$/.test(rawPrefix))
|
|
111
|
+
return null;
|
|
112
|
+
const prefix = Number(rawPrefix);
|
|
113
|
+
if (prefix > ip.bits)
|
|
114
|
+
return null;
|
|
115
|
+
return { ip, prefix };
|
|
116
|
+
}
|
|
117
|
+
export function ipMatches(clientIp, entries) {
|
|
118
|
+
const client = parseIp(clientIp);
|
|
119
|
+
if (!client)
|
|
120
|
+
return false;
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const cidr = parseCidr(entry);
|
|
123
|
+
if (!cidr || cidr.ip.bits !== client.bits)
|
|
124
|
+
continue;
|
|
125
|
+
const shift = BigInt(cidr.ip.bits - cidr.prefix);
|
|
126
|
+
if (client.value >> shift === cidr.ip.value >> shift)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/** true nếu IP bị chặn theo danh sách. IP không xác định được: danh sách cho phép chặn, danh sách chặn cho qua. */
|
|
132
|
+
export function isIpBlocked(clientIp, limit) {
|
|
133
|
+
const listed = clientIp ? ipMatches(clientIp, limit.entries) : false;
|
|
134
|
+
return limit.mode === "whitelist" ? !listed : listed;
|
|
135
|
+
}
|
|
136
|
+
// --- Bot --------------------------------------------------------------------------------------
|
|
137
|
+
const BOT_UA = /bot\b|bot[\/ ;)_-]|crawl|spider|slurp|scrap|curl\/|wget|python-requests|python-urllib|aiohttp|httpclient|go-http-client|java\/|libwww|okhttp|axios|node-fetch|undici|headless|phantomjs|selenium|puppeteer|playwright/i;
|
|
138
|
+
export function isBotUserAgent(userAgent) {
|
|
139
|
+
if (!userAgent || userAgent.trim() === "")
|
|
140
|
+
return true;
|
|
141
|
+
return BOT_UA.test(userAgent);
|
|
142
|
+
}
|
|
143
|
+
// --- PIN --------------------------------------------------------------------------------------
|
|
144
|
+
const encoder = new TextEncoder();
|
|
145
|
+
function toHex(buffer) {
|
|
146
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
147
|
+
}
|
|
148
|
+
export async function sha256Hex(text) {
|
|
149
|
+
return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(text)));
|
|
150
|
+
}
|
|
151
|
+
function timingSafeEqual(a, b) {
|
|
152
|
+
if (a.length !== b.length)
|
|
153
|
+
return false;
|
|
154
|
+
let diff = 0;
|
|
155
|
+
for (let i = 0; i < a.length; i += 1)
|
|
156
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
157
|
+
return diff === 0;
|
|
158
|
+
}
|
|
159
|
+
/** Giá trị cookie đã mở khóa: gắn với PIN hiện tại, đổi PIN thì mọi cookie cũ mất hiệu lực. */
|
|
160
|
+
async function pinCookieValue(pinHash, secret) {
|
|
161
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret || "hodela"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
162
|
+
return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(`pin:${pinHash}`)));
|
|
163
|
+
}
|
|
164
|
+
function readCookie(request, name) {
|
|
165
|
+
const header = request.headers.get("cookie");
|
|
166
|
+
if (!header)
|
|
167
|
+
return null;
|
|
168
|
+
for (const part of header.split(";")) {
|
|
169
|
+
const index = part.indexOf("=");
|
|
170
|
+
if (index > 0 && part.slice(0, index).trim() === name)
|
|
171
|
+
return part.slice(index + 1).trim();
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const failures = new Map();
|
|
176
|
+
function safeNext(value) {
|
|
177
|
+
return value && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\") ? value : "/";
|
|
178
|
+
}
|
|
179
|
+
function escapeHtml(text) {
|
|
180
|
+
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
181
|
+
}
|
|
182
|
+
function pinPage(next, error, status) {
|
|
183
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Protected website</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;background:#f6f6f4;color:#161616}form{width:min(92vw,20rem);padding:1.5rem;border-radius:12px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.12)}h1{margin:0 0 .25rem;font-size:1.1rem}p{margin:0 0 1rem;color:#555;font-size:.9rem}input{box-sizing:border-box;width:100%;padding:.6rem .75rem;border:1px solid #bbb;border-radius:8px;font:inherit;letter-spacing:.3em;text-align:center}button{margin-top:.75rem;width:100%;padding:.6rem;border:0;border-radius:8px;background:#161616;color:#fff;font:inherit;cursor:pointer}.err{margin:.5rem 0 0;color:#b00020;font-size:.85rem}</style></head><body><form method="post" action="${PIN_PATH}"><h1>This website is protected</h1><p>Enter the PIN to continue.</p><input name="pin" type="password" inputmode="numeric" autocomplete="off" autofocus required maxlength="8" aria-label="PIN"><input type="hidden" name="next" value="${escapeHtml(next)}"><button type="submit">Continue</button>${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}</form></body></html>`;
|
|
184
|
+
return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
185
|
+
}
|
|
186
|
+
async function handlePinSubmit(request, config, secret, clientIp) {
|
|
187
|
+
const form = await request.formData().catch(() => null);
|
|
188
|
+
const next = safeNext(typeof form?.get("next") === "string" ? form?.get("next") : null);
|
|
189
|
+
const key = clientIp ?? "unknown";
|
|
190
|
+
const state = failures.get(key);
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (state && state.lockedUntil > now)
|
|
193
|
+
return pinPage(next, "Too many attempts. Try again in a minute.", 429);
|
|
194
|
+
const pin = typeof form?.get("pin") === "string" ? (form?.get("pin")).trim() : "";
|
|
195
|
+
const ok = /^\d{4,8}$/.test(pin) && timingSafeEqual(await sha256Hex(pin), config.pinHash ?? "");
|
|
196
|
+
if (!ok) {
|
|
197
|
+
const count = (state && state.lockedUntil !== 0 && state.lockedUntil <= now ? 0 : (state?.count ?? 0)) + 1;
|
|
198
|
+
failures.set(key, count >= MAX_PIN_FAILURES ? { count: 0, lockedUntil: now + PIN_LOCK_MS } : { count, lockedUntil: 0 });
|
|
199
|
+
return pinPage(next, "Incorrect PIN.", 401);
|
|
200
|
+
}
|
|
201
|
+
failures.delete(key);
|
|
202
|
+
const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
|
|
203
|
+
const cookie = `${PIN_COOKIE}=${await pinCookieValue(config.pinHash ?? "", secret)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${PIN_COOKIE_MAX_AGE}${secure}`;
|
|
204
|
+
return new Response(null, { status: 303, headers: { location: next, "set-cookie": cookie, "cache-control": "no-store" } });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Trả về phản hồi chặn (403/401/429/303) hoặc null nếu cho qua. Thứ tự: IP, bot, PIN.
|
|
208
|
+
* `secret` là HODELA_SESSION_SECRET dùng ký cookie PIN.
|
|
209
|
+
*/
|
|
210
|
+
export async function enforceAccess(request, config, secret) {
|
|
211
|
+
const clientIp = request.headers.get("x-hodela-client-ip");
|
|
212
|
+
if (config.ipLimit && isIpBlocked(clientIp, config.ipLimit)) {
|
|
213
|
+
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
214
|
+
}
|
|
215
|
+
const url = new URL(request.url);
|
|
216
|
+
if (config.blockBots && url.pathname !== "/robots.txt" && isBotUserAgent(request.headers.get("user-agent"))) {
|
|
217
|
+
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
218
|
+
}
|
|
219
|
+
if (!config.pinHash)
|
|
220
|
+
return null;
|
|
221
|
+
if (url.pathname === PIN_PATH && request.method === "POST")
|
|
222
|
+
return handlePinSubmit(request, config, secret, clientIp);
|
|
223
|
+
const cookie = readCookie(request, PIN_COOKIE);
|
|
224
|
+
if (cookie && timingSafeEqual(cookie, await pinCookieValue(config.pinHash, secret)))
|
|
225
|
+
return null;
|
|
226
|
+
const wantsHtml = request.method === "GET" && (request.headers.get("accept") ?? "").includes("text/html");
|
|
227
|
+
if (wantsHtml)
|
|
228
|
+
return pinPage(`${url.pathname}${url.search}`, null, 401);
|
|
229
|
+
return new Response("PIN required", { status: 401, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
230
|
+
}
|
|
231
|
+
// --- Badge ------------------------------------------------------------------------------------
|
|
232
|
+
// Huy hiệu "♥ Built with" + logo Hodela (SVG nội tuyến, tô bằng màu chữ); aria-label cho trình đọc màn hình.
|
|
233
|
+
const BADGE_HTML = '<a href="https://hodela.com/?utm_source=published_site&utm_medium=badge&utm_campaign=built_with_hodela" target="_blank" rel="noopener noreferrer" data-hodela-badge="" aria-label="Built with Hodela" style="position:fixed;z-index:2147483646;right:12px;bottom:12px;display:inline-flex;align-items:center;gap:5px;padding:6px 10px;border-radius:8px;font:12px/1.2 system-ui,sans-serif;background:rgba(15,23,42,.88);color:#fff;text-decoration:none;box-shadow:0 2px 8px rgba(0,0,0,.2)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="#fb7185" aria-hidden="true" style="display:block;flex:none"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg><span>Built with</span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 121.5 26.35" aria-hidden="true" style="display:block;flex:none;height:10px;width:auto"><g transform="translate(-2.065,0)"><path fill="currentColor" d="M7.42 25L2.94 25Q2.56 25 2.31 24.74Q2.07 24.48 2.07 24.13L2.07 24.13L2.07 1.02Q2.07 0.64 2.31 0.39Q2.56 0.15 2.94 0.15L2.94 0.15L7.42 0.15Q7.81 0.15 8.05 0.39Q8.29 0.64 8.29 1.02L8.29 1.02L8.29 8.83Q9.24 7.74 10.62 7.10Q12.01 6.45 13.83 6.45L13.83 6.45Q15.86 6.45 17.45 7.36Q19.04 8.27 19.95 10.02Q20.86 11.77 20.86 14.36L20.86 14.36L20.86 24.13Q20.86 24.48 20.62 24.74Q20.37 25 19.99 25L19.99 25L15.47 25Q15.12 25 14.86 24.74Q14.60 24.48 14.60 24.13L14.60 24.13L14.60 14.57Q14.60 12.92 13.81 12.03Q13.02 11.14 11.52 11.14L11.52 11.14Q10.05 11.14 9.17 12.03Q8.29 12.92 8.29 14.57L8.29 14.57L8.29 24.13Q8.29 24.48 8.05 24.74Q7.81 25 7.42 25L7.42 25ZM32.38 25.35L32.38 25.35Q29.44 25.35 27.42 24.41Q25.41 23.46 24.34 21.69Q23.28 19.93 23.14 17.48L23.14 17.48Q23.10 16.77 23.10 15.88Q23.10 14.99 23.14 14.32L23.14 14.32Q23.28 11.84 24.40 10.09Q25.52 8.34 27.53 7.39Q29.54 6.45 32.38 6.45L32.38 6.45Q35.25 6.45 37.26 7.39Q39.27 8.34 40.39 10.09Q41.51 11.84 41.65 14.32L41.65 14.32Q41.69 14.99 41.69 15.88Q41.69 16.77 41.65 17.48L41.65 17.48Q41.51 19.93 40.44 21.69Q39.38 23.46 37.36 24.41Q35.35 25.35 32.38 25.35ZM32.38 21.08L32.38 21.08Q33.95 21.08 34.67 20.13Q35.39 19.19 35.49 17.30L35.49 17.30Q35.53 16.77 35.53 15.90Q35.53 15.02 35.49 14.50L35.49 14.50Q35.39 12.64 34.67 11.68Q33.95 10.72 32.38 10.72L32.38 10.72Q30.87 10.72 30.12 11.68Q29.37 12.64 29.30 14.50L29.30 14.50Q29.26 15.02 29.26 15.90Q29.26 16.77 29.30 17.30L29.30 17.30Q29.37 19.19 30.12 20.13Q30.87 21.08 32.38 21.08ZM50.89 25.35L50.89 25.35Q49.21 25.35 47.86 24.81Q46.52 24.27 45.55 23.20Q44.59 22.13 44.07 20.61Q43.54 19.09 43.47 17.13L43.47 17.13Q43.44 16.42 43.44 15.88Q43.44 15.34 43.47 14.64L43.47 14.64Q43.54 12.78 44.07 11.26Q44.59 9.74 45.54 8.67Q46.48 7.60 47.85 7.03Q49.21 6.45 50.89 6.45L50.89 6.45Q52.64 6.45 53.90 7.03Q55.16 7.60 56.04 8.51L56.04 8.51L56.04 1.02Q56.04 0.64 56.28 0.39Q56.53 0.15 56.91 0.15L56.91 0.15L61.18 0.15Q61.53 0.15 61.79 0.39Q62.06 0.64 62.06 1.02L62.06 1.02L62.06 24.13Q62.06 24.48 61.79 24.74Q61.53 25 61.18 25L61.18 25L57.23 25Q56.84 25 56.60 24.74Q56.35 24.48 56.35 24.13L56.35 24.13L56.35 23.00Q55.51 24.02 54.18 24.68Q52.85 25.35 50.89 25.35ZM52.82 20.66L52.82 20.66Q53.97 20.66 54.65 20.15Q55.34 19.64 55.67 18.84Q56.00 18.04 56.04 17.09L56.04 17.09Q56.07 16.39 56.07 15.76Q56.07 15.13 56.04 14.46L56.04 14.46Q56.00 13.59 55.65 12.84Q55.30 12.08 54.60 11.61Q53.90 11.14 52.82 11.14L52.82 11.14Q51.66 11.14 50.98 11.63Q50.30 12.12 50.00 12.96Q49.70 13.80 49.63 14.81L49.63 14.81Q49.53 15.90 49.63 16.98L49.63 16.98Q49.70 18 50.00 18.84Q50.30 19.68 50.98 20.17Q51.66 20.66 52.82 20.66ZM73.54 25.35L73.54 25.35Q69.41 25.35 66.96 23.11Q64.51 20.87 64.40 16.46L64.40 16.46Q64.40 16.21 64.40 15.83Q64.40 15.44 64.40 15.23L64.40 15.23Q64.51 12.47 65.64 10.49Q66.78 8.51 68.79 7.48Q70.81 6.45 73.50 6.45L73.50 6.45Q76.58 6.45 78.59 7.67Q80.61 8.90 81.62 11.00Q82.64 13.10 82.64 15.79L82.64 15.79L82.64 16.63Q82.64 17.02 82.39 17.27Q82.15 17.51 81.76 17.51L81.76 17.51L70.56 17.51Q70.56 17.54 70.56 17.61Q70.56 17.68 70.56 17.75L70.56 17.75Q70.60 18.77 70.93 19.61Q71.26 20.45 71.91 20.94Q72.56 21.43 73.50 21.43L73.50 21.43Q74.20 21.43 74.67 21.20Q75.15 20.98 75.46 20.68Q75.78 20.38 75.95 20.17L75.95 20.17Q76.27 19.79 76.46 19.70Q76.65 19.61 77.07 19.61L77.07 19.61L81.41 19.61Q81.76 19.61 81.99 19.82Q82.22 20.03 82.18 20.38L82.18 20.38Q82.15 20.98 81.59 21.81Q81.03 22.66 79.96 23.48Q78.89 24.30 77.28 24.82Q75.67 25.35 73.54 25.35ZM70.56 13.97L70.56 14.04L76.48 14.04L76.48 13.97Q76.48 12.82 76.14 12.00Q75.81 11.17 75.13 10.72Q74.45 10.26 73.50 10.26L73.50 10.26Q72.59 10.26 71.93 10.72Q71.26 11.17 70.91 12.00Q70.56 12.82 70.56 13.97L70.56 13.97ZM89.88 25L85.75 25Q85.37 25 85.12 24.74Q84.88 24.48 84.88 24.13L84.88 24.13L84.88 1.02Q84.88 0.64 85.12 0.39Q85.37 0.15 85.75 0.15L85.75 0.15L89.88 0.15Q90.27 0.15 90.51 0.39Q90.76 0.64 90.76 1.02L90.76 1.02L90.76 24.13Q90.76 24.48 90.51 24.74Q90.27 25 89.88 25L89.88 25ZM99.30 25.35L99.30 25.35Q97.41 25.35 95.92 24.63Q94.43 23.91 93.56 22.69Q92.68 21.46 92.68 19.93L92.68 19.93Q92.68 17.44 94.71 15.99Q96.74 14.53 100.10 14.01L100.10 14.01L104.48 13.34L104.48 12.85Q104.48 11.59 103.95 10.93Q103.43 10.26 102.03 10.26L102.03 10.26Q101.05 10.26 100.43 10.65Q99.82 11.03 99.47 11.70L99.47 11.70Q99.16 12.15 98.60 12.15L98.60 12.15L94.68 12.15Q94.29 12.15 94.08 11.93Q93.87 11.70 93.91 11.38L93.91 11.38Q93.91 10.75 94.38 9.93Q94.85 9.11 95.83 8.30Q96.81 7.50 98.35 6.97Q99.89 6.45 102.10 6.45L102.10 6.45Q104.34 6.45 105.95 6.97Q107.56 7.50 108.57 8.44Q109.59 9.39 110.06 10.67Q110.53 11.94 110.53 13.48L110.53 13.48L110.53 24.13Q110.53 24.48 110.27 24.74Q110.01 25 109.66 25L109.66 25L105.60 25Q105.21 25 104.97 24.74Q104.72 24.48 104.72 24.13L104.72 24.13L104.72 22.90Q104.27 23.57 103.50 24.13Q102.73 24.68 101.69 25.02Q100.66 25.35 99.30 25.35ZM100.94 21.36L100.94 21.36Q101.96 21.36 102.78 20.92Q103.60 20.48 104.07 19.56Q104.55 18.63 104.55 17.23L104.55 17.23L104.55 16.74L101.57 17.27Q99.93 17.54 99.17 18.12Q98.42 18.70 98.42 19.47L98.42 19.47Q98.42 20.06 98.77 20.48Q99.12 20.91 99.70 21.13Q100.28 21.36 100.94 21.36Z"/></g><path d="M116 1.9V8.5M112.7 5.2H119.3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg></a>';
|
|
234
|
+
export function badgeHtml(config) {
|
|
235
|
+
return config.showBadge ? BADGE_HTML : "";
|
|
236
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** So sánh không lộ thời gian: so hai HMAC có độ dài cố định thay vì so chuỗi gốc. */
|
|
2
|
+
export declare function safeEqual(secret: string, a: string, b: string): Promise<boolean>;
|
|
3
|
+
/** Dấu vân tay của mật khẩu: đổi mật khẩu thì mọi phiên cũ mất hiệu lực. */
|
|
4
|
+
export declare function passwordFingerprint(secret: string, password: string): Promise<string>;
|
|
5
|
+
export declare function signAdminToken(secret: string, fingerprint: string, expiresAtMs: number): Promise<string>;
|
|
6
|
+
export declare function verifyAdminToken(secret: string, fingerprint: string, token: string | undefined, nowMs: number): Promise<boolean>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Mã phiên cho khu vực quản lý của chủ website. Thuần (không đọc env/cookie) nên kiểm thử được.
|
|
2
|
+
// Hodela quản lý file này: không sửa.
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
async function hmacHex(secret, message) {
|
|
5
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
6
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
7
|
+
return Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
8
|
+
}
|
|
9
|
+
/** So sánh không lộ thời gian: so hai HMAC có độ dài cố định thay vì so chuỗi gốc. */
|
|
10
|
+
export async function safeEqual(secret, a, b) {
|
|
11
|
+
const [ha, hb] = await Promise.all([hmacHex(secret, `cmp:${a}`), hmacHex(secret, `cmp:${b}`)]);
|
|
12
|
+
let diff = 0;
|
|
13
|
+
for (let i = 0; i < ha.length; i++)
|
|
14
|
+
diff |= ha.charCodeAt(i) ^ hb.charCodeAt(i);
|
|
15
|
+
return diff === 0;
|
|
16
|
+
}
|
|
17
|
+
/** Dấu vân tay của mật khẩu: đổi mật khẩu thì mọi phiên cũ mất hiệu lực. */
|
|
18
|
+
export async function passwordFingerprint(secret, password) {
|
|
19
|
+
return (await hmacHex(secret, `pw:${password}`)).slice(0, 32);
|
|
20
|
+
}
|
|
21
|
+
export async function signAdminToken(secret, fingerprint, expiresAtMs) {
|
|
22
|
+
return `${expiresAtMs}.${await hmacHex(secret, `admin:${expiresAtMs}:${fingerprint}`)}`;
|
|
23
|
+
}
|
|
24
|
+
export async function verifyAdminToken(secret, fingerprint, token, nowMs) {
|
|
25
|
+
if (!token)
|
|
26
|
+
return false;
|
|
27
|
+
const dot = token.indexOf(".");
|
|
28
|
+
if (dot < 1)
|
|
29
|
+
return false;
|
|
30
|
+
const exp = Number(token.slice(0, dot));
|
|
31
|
+
if (!Number.isFinite(exp) || exp <= nowMs)
|
|
32
|
+
return false;
|
|
33
|
+
return safeEqual(secret, token, await signAdminToken(secret, fingerprint, exp));
|
|
34
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type AnalyticsConfig = {
|
|
2
|
+
googleAnalytics?: string;
|
|
3
|
+
plausible?: string;
|
|
4
|
+
metaPixel?: string;
|
|
5
|
+
};
|
|
6
|
+
/** Đọc cấu hình từ binding HODELA_ANALYTICS (JSON). Giá trị sai định dạng bị bỏ, không bao giờ ném lỗi. */
|
|
7
|
+
export declare function parseAnalyticsConfig(raw: unknown): AnalyticsConfig;
|
|
8
|
+
/** HTML chèn vào cuối <head>. Rỗng nếu chưa bật dịch vụ nào. Mọi ID đã qua kiểm tra định dạng. */
|
|
9
|
+
export declare function analyticsHeadSnippet(config: AnalyticsConfig): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Mã đo lường do chủ web bật trong Cài đặt → Analytics. Hodela chèn vào <head> của mọi trang HTML
|
|
2
|
+
// khi website đã xuất bản (không chèn ở bản xem trước). Không tự thêm Google Analytics,
|
|
3
|
+
// Plausible hay Meta Pixel vào code của website: sẽ bị đếm hai lần.
|
|
4
|
+
//
|
|
5
|
+
// File này do Hodela quản lý (cùng src/server.ts), đừng sửa.
|
|
6
|
+
const GA_ID = /^G-[A-Z0-9]{4,20}$/;
|
|
7
|
+
const PLAUSIBLE_DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
8
|
+
const PIXEL_ID = /^\d{5,20}$/;
|
|
9
|
+
/** Đọc cấu hình từ binding HODELA_ANALYTICS (JSON). Giá trị sai định dạng bị bỏ, không bao giờ ném lỗi. */
|
|
10
|
+
export function parseAnalyticsConfig(raw) {
|
|
11
|
+
if (typeof raw !== "string" || raw === "")
|
|
12
|
+
return {};
|
|
13
|
+
let json;
|
|
14
|
+
try {
|
|
15
|
+
json = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
if (!json || typeof json !== "object")
|
|
21
|
+
return {};
|
|
22
|
+
const o = json;
|
|
23
|
+
const out = {};
|
|
24
|
+
if (typeof o["googleAnalytics"] === "string" && GA_ID.test(o["googleAnalytics"])) {
|
|
25
|
+
out.googleAnalytics = o["googleAnalytics"];
|
|
26
|
+
}
|
|
27
|
+
if (typeof o["plausible"] === "string" && PLAUSIBLE_DOMAIN.test(o["plausible"])) {
|
|
28
|
+
out.plausible = o["plausible"];
|
|
29
|
+
}
|
|
30
|
+
if (typeof o["metaPixel"] === "string" && PIXEL_ID.test(o["metaPixel"])) {
|
|
31
|
+
out.metaPixel = o["metaPixel"];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** HTML chèn vào cuối <head>. Rỗng nếu chưa bật dịch vụ nào. Mọi ID đã qua kiểm tra định dạng. */
|
|
36
|
+
export function analyticsHeadSnippet(config) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (config.googleAnalytics && GA_ID.test(config.googleAnalytics)) {
|
|
39
|
+
const id = config.googleAnalytics;
|
|
40
|
+
parts.push(`<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>`, `<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","${id}");</script>`);
|
|
41
|
+
}
|
|
42
|
+
if (config.plausible && PLAUSIBLE_DOMAIN.test(config.plausible)) {
|
|
43
|
+
parts.push(`<script defer data-domain="${config.plausible}" src="https://plausible.io/js/script.js"></script>`);
|
|
44
|
+
}
|
|
45
|
+
if (config.metaPixel && PIXEL_ID.test(config.metaPixel)) {
|
|
46
|
+
const id = config.metaPixel;
|
|
47
|
+
parts.push(`<script>!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version="2.0";n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,"script","https://connect.facebook.net/en_US/fbevents.js");fbq("init","${id}");fbq("track","PageView");</script>`);
|
|
48
|
+
}
|
|
49
|
+
return parts.join("");
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Băm mật khẩu để LƯU VÀO DATABASE (cột password_hash). Không bao giờ lưu mật khẩu gốc. */
|
|
2
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
3
|
+
export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
|
|
4
|
+
/** Mật khẩu hợp lệ cho khách: 8–128 ký tự. Trả thông báo lỗi tiếng Việt, hoặc null nếu ổn. */
|
|
5
|
+
export declare function passwordProblem(password: string): string | null;
|
|
6
|
+
/** Mã phiên `<customerId>.<hạn>.<chữ ký>`; khác miền chữ ký với phiên quản lý nên không dùng lẫn được. */
|
|
7
|
+
export declare function signCustomerToken(secret: string, customerId: string, expiresAtMs: number): Promise<string>;
|
|
8
|
+
export declare function verifyCustomerToken(secret: string, token: string | undefined, nowMs: number): Promise<string | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
// Dùng qua "@hodela/site/server/customer-auth".
|
|
3
|
+
const enc = new TextEncoder();
|
|
4
|
+
// Cloudflare Workers giới hạn PBKDF2 tối đa 100.000 vòng.
|
|
5
|
+
const ITERATIONS = 100_000;
|
|
6
|
+
const b64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
7
|
+
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
8
|
+
async function pbkdf2(password, salt, iterations) {
|
|
9
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
10
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256));
|
|
11
|
+
}
|
|
12
|
+
function equalBytes(a, b) {
|
|
13
|
+
if (a.length !== b.length)
|
|
14
|
+
return false;
|
|
15
|
+
let diff = 0;
|
|
16
|
+
for (let i = 0; i < a.length; i++)
|
|
17
|
+
diff |= a[i] ^ b[i];
|
|
18
|
+
return diff === 0;
|
|
19
|
+
}
|
|
20
|
+
/** Băm mật khẩu để LƯU VÀO DATABASE (cột password_hash). Không bao giờ lưu mật khẩu gốc. */
|
|
21
|
+
export async function hashPassword(password) {
|
|
22
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
23
|
+
return `pbkdf2$${ITERATIONS}$${b64(salt)}$${b64(await pbkdf2(password, salt, ITERATIONS))}`;
|
|
24
|
+
}
|
|
25
|
+
export async function verifyPassword(password, stored) {
|
|
26
|
+
const [scheme, iter, salt, hash] = stored.split("$");
|
|
27
|
+
const iterations = Number(iter);
|
|
28
|
+
if (scheme !== "pbkdf2" || !salt || !hash || !Number.isInteger(iterations) || iterations < 1000 || iterations > ITERATIONS)
|
|
29
|
+
return false;
|
|
30
|
+
return equalBytes(await pbkdf2(password, unb64(salt), iterations), unb64(hash));
|
|
31
|
+
}
|
|
32
|
+
/** Mật khẩu hợp lệ cho khách: 8–128 ký tự. Trả thông báo lỗi tiếng Việt, hoặc null nếu ổn. */
|
|
33
|
+
export function passwordProblem(password) {
|
|
34
|
+
if (password.length < 8)
|
|
35
|
+
return "Mật khẩu cần ít nhất 8 ký tự.";
|
|
36
|
+
if (password.length > 128)
|
|
37
|
+
return "Mật khẩu quá dài.";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async function hmacHex(secret, message) {
|
|
41
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
42
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
|
|
43
|
+
return Array.from(sig, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
}
|
|
45
|
+
/** Mã phiên `<customerId>.<hạn>.<chữ ký>`; khác miền chữ ký với phiên quản lý nên không dùng lẫn được. */
|
|
46
|
+
export async function signCustomerToken(secret, customerId, expiresAtMs) {
|
|
47
|
+
return `${customerId}.${expiresAtMs}.${await hmacHex(secret, `customer:${customerId}:${expiresAtMs}`)}`;
|
|
48
|
+
}
|
|
49
|
+
export async function verifyCustomerToken(secret, token, nowMs) {
|
|
50
|
+
if (!token)
|
|
51
|
+
return null;
|
|
52
|
+
const [id, exp, sig] = token.split(".");
|
|
53
|
+
const expiresAt = Number(exp);
|
|
54
|
+
if (!id || !sig || !Number.isFinite(expiresAt) || expiresAt <= nowMs)
|
|
55
|
+
return null;
|
|
56
|
+
const expected = await hmacHex(secret, `customer:${id}:${expiresAt}`);
|
|
57
|
+
return equalBytes(enc.encode(sig), enc.encode(expected)) ? id : null;
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/mailer.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type EmailLogEntry } from "@hodela/email-js";
|
|
2
|
+
export type MailProvider = "hodela" | "resend" | "none";
|
|
3
|
+
export type MailInput = {
|
|
4
|
+
to: string | string[];
|
|
5
|
+
subject: string;
|
|
6
|
+
text?: string | undefined;
|
|
7
|
+
html?: string | undefined;
|
|
8
|
+
/** Người nhận bấm "Trả lời" sẽ viết cho địa chỉ này (ví dụ email khách trong form). */
|
|
9
|
+
replyTo?: string | undefined;
|
|
10
|
+
/** Gửi lại cùng khóa trong 24 giờ không tạo thêm thư (ví dụ mã đơn hàng). */
|
|
11
|
+
idempotencyKey?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
export type MailResult = {
|
|
14
|
+
sent: boolean;
|
|
15
|
+
provider: MailProvider;
|
|
16
|
+
reason?: "not_configured" | "rate_limited" | "failed" | "invalid" | "suppressed";
|
|
17
|
+
};
|
|
18
|
+
type Env = (name: string) => string | undefined;
|
|
19
|
+
type Deps = {
|
|
20
|
+
env: Env;
|
|
21
|
+
mode: "preview" | "production";
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
};
|
|
24
|
+
export declare function mailProvider(env: Env): MailProvider;
|
|
25
|
+
export declare function createMailer(deps: Deps): {
|
|
26
|
+
sendMail: (input: MailInput) => Promise<MailResult>;
|
|
27
|
+
notifyOwner: (input: Omit<MailInput, "to">) => Promise<MailResult>;
|
|
28
|
+
recentMail: (limit?: number) => Promise<EmailLogEntry[]>;
|
|
29
|
+
};
|
|
30
|
+
/** Escape giá trị người dùng nhập trước khi đưa vào html của thư. */
|
|
31
|
+
export declare function escapeMailHtml(value: string): string;
|
|
32
|
+
export {};
|