@hodela/site 0.0.0-stage → 0.1.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/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/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/mailer.d.ts +32 -0
- package/dist/mailer.js +82 -0
- package/dist/seo.d.ts +60 -0
- package/dist/seo.js +99 -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/package.json +64 -4
- package/README.md +0 -3
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,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
|
+
// Hodela quản lý file này: không sửa. Dùng qua "@/lib/customer-auth.server".
|
|
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
|
+
}
|
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 {};
|
package/dist/mailer.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
// Hodela quản lý file này: không sửa. Dùng qua "@/lib/mailer.server".
|
|
3
|
+
//
|
|
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
|
+
// • 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
|
+
// • 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
|
+
// • 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
|
+
// 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 "@hodela/email-js";
|
|
10
|
+
export function mailProvider(env) {
|
|
11
|
+
if (env("RESEND_API_KEY") && env("MAIL_FROM"))
|
|
12
|
+
return "resend";
|
|
13
|
+
if (env("NOTIFY_EMAIL") && env("HODELA_API_URL") && env("HODELA_API_KEY"))
|
|
14
|
+
return "hodela";
|
|
15
|
+
return "none";
|
|
16
|
+
}
|
|
17
|
+
const EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
|
|
18
|
+
export function createMailer(deps) {
|
|
19
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
20
|
+
// Bản xem thử trong editor cũng gửi thật để chủ web thử luồng, nhưng đánh dấu rõ.
|
|
21
|
+
const subjectOf = (s) => (deps.mode === "preview" ? `[Bản thử] ${s}` : s).replace(/[\r\n]+/g, " ").slice(0, 200);
|
|
22
|
+
const hodelaClient = () => createHodelaEmailClient({ apiUrl: deps.env("HODELA_API_URL"), apiKey: deps.env("HODELA_API_KEY"), fetch: doFetch });
|
|
23
|
+
/** Thư gần đây đã gửi qua Hodela (để trang quản lý hiện trạng thái: đã gửi, trả về, bị chặn). Trả [] khi không dùng Hodela hoặc lỗi. */
|
|
24
|
+
async function recentMail(limit = 20) {
|
|
25
|
+
if (mailProvider(deps.env) !== "hodela")
|
|
26
|
+
return [];
|
|
27
|
+
try {
|
|
28
|
+
return await hodelaClient().logs(limit);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function sendMail(input) {
|
|
35
|
+
const provider = mailProvider(deps.env);
|
|
36
|
+
if (provider === "none")
|
|
37
|
+
return { sent: false, provider, reason: "not_configured" };
|
|
38
|
+
const to = (Array.isArray(input.to) ? input.to : [input.to]).map((a) => a.trim()).filter(Boolean);
|
|
39
|
+
if (to.length === 0 || to.some((a) => !EMAIL_RE.test(a)) || (!input.text?.trim() && !input.html?.trim())) {
|
|
40
|
+
return { sent: false, provider, reason: "invalid" };
|
|
41
|
+
}
|
|
42
|
+
const replyTo = input.replyTo && EMAIL_RE.test(input.replyTo.trim()) ? input.replyTo.trim() : undefined;
|
|
43
|
+
try {
|
|
44
|
+
if (provider === "hodela") {
|
|
45
|
+
const sent = await hodelaClient().send({ to, subject: subjectOf(input.subject), text: input.text, html: input.html, replyTo, idempotencyKey: input.idempotencyKey });
|
|
46
|
+
// Địa chỉ từng trả thư về hoặc báo spam: nền tảng không gửi nữa.
|
|
47
|
+
if (sent.status === "suppressed")
|
|
48
|
+
return { sent: false, provider, reason: "suppressed" };
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const res = await doFetch("https://api.resend.com/emails", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${deps.env("RESEND_API_KEY")}` },
|
|
54
|
+
body: JSON.stringify({ from: deps.env("MAIL_FROM"), to, subject: subjectOf(input.subject), text: input.text, html: input.html, reply_to: replyTo }),
|
|
55
|
+
signal: AbortSignal.timeout(10_000),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
console.warn(`[mailer] Resend trả HTTP ${res.status}`);
|
|
59
|
+
return { sent: false, provider, reason: res.status === 429 ? "rate_limited" : "failed" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { sent: true, provider };
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
const limited = e instanceof HodelaEmailError && e.code === "rate_limited";
|
|
66
|
+
console.warn(`[mailer] không gửi được (${provider}): ${e instanceof Error ? e.message : String(e)}`);
|
|
67
|
+
return { sent: false, provider, reason: limited ? "rate_limited" : "failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Gửi tới hộp thư của chủ web (NOTIFY_EMAIL). Chưa đặt NOTIFY_EMAIL thì bỏ qua êm. */
|
|
71
|
+
async function notifyOwner(input) {
|
|
72
|
+
const to = deps.env("NOTIFY_EMAIL");
|
|
73
|
+
if (!to)
|
|
74
|
+
return { sent: false, provider: mailProvider(deps.env), reason: "not_configured" };
|
|
75
|
+
return sendMail({ ...input, to });
|
|
76
|
+
}
|
|
77
|
+
return { sendMail, notifyOwner, recentMail };
|
|
78
|
+
}
|
|
79
|
+
/** Escape giá trị người dùng nhập trước khi đưa vào html của thư. */
|
|
80
|
+
export function escapeMailHtml(value) {
|
|
81
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
82
|
+
}
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata SEO cho `head()` của mỗi route: title, description, Open Graph, Twitter, canonical.
|
|
3
|
+
* Dùng một lời gọi thay vì tự viết từng thẻ để mọi trang nhất quán và không sót thẻ nào.
|
|
4
|
+
*
|
|
5
|
+
* head: () => seo({ title: "Thực đơn | Quán Mây", description: "…", path: "/thuc-don" })
|
|
6
|
+
*
|
|
7
|
+
* Mọi thuộc tính tùy chọn nhận được cả `undefined` (tsconfig bật exactOptionalPropertyTypes), nên truyền thẳng
|
|
8
|
+
* `image: item.image` với `item.image: string | undefined` là hợp lệ.
|
|
9
|
+
*/
|
|
10
|
+
export type SeoInput = {
|
|
11
|
+
/** Tiêu đề trang, ≤ ~60 ký tự, riêng cho từng trang. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Mô tả trang, ~70–160 ký tự, riêng cho từng trang. */
|
|
14
|
+
description: string;
|
|
15
|
+
/** Đường dẫn của route (vd. "/thuc-don"); dùng cho canonical và og:url khi biết địa chỉ website. */
|
|
16
|
+
path?: string | undefined;
|
|
17
|
+
/** Ảnh chia sẻ (đường dẫn trong website hoặc URL tuyệt đối). */
|
|
18
|
+
image?: string | undefined;
|
|
19
|
+
type?: "website" | "article" | undefined;
|
|
20
|
+
siteName?: string | undefined;
|
|
21
|
+
/** Ví dụ "vi_VN". */
|
|
22
|
+
locale?: string | undefined;
|
|
23
|
+
/** Trang không để công cụ tìm kiếm lập chỉ mục (khu vực quản lý, trang cảm ơn). */
|
|
24
|
+
noindex?: boolean | undefined;
|
|
25
|
+
};
|
|
26
|
+
type Meta = {
|
|
27
|
+
title?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
property?: string;
|
|
30
|
+
content?: string;
|
|
31
|
+
};
|
|
32
|
+
type Link = {
|
|
33
|
+
rel: string;
|
|
34
|
+
href: string;
|
|
35
|
+
};
|
|
36
|
+
/** URL tuyệt đối cho đường dẫn trong website, dùng khi tự viết thẻ meta thô (vd. `og:image` trong `__root.tsx`). */
|
|
37
|
+
export declare function absoluteUrl(value: string): string;
|
|
38
|
+
export declare function seo(input: SeoInput): {
|
|
39
|
+
meta: Meta[];
|
|
40
|
+
links: Link[];
|
|
41
|
+
};
|
|
42
|
+
/** Dữ liệu có cấu trúc cho doanh nghiệp địa phương: dùng trong `scripts` của head() ở trang chủ/liên hệ. */
|
|
43
|
+
export declare function localBusinessJsonLd(input: {
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string | undefined;
|
|
46
|
+
telephone?: string | undefined;
|
|
47
|
+
email?: string | undefined;
|
|
48
|
+
address?: {
|
|
49
|
+
street: string;
|
|
50
|
+
city: string;
|
|
51
|
+
region?: string | undefined;
|
|
52
|
+
country?: string | undefined;
|
|
53
|
+
} | undefined;
|
|
54
|
+
openingHours?: string[] | undefined;
|
|
55
|
+
image?: string | undefined;
|
|
56
|
+
}): {
|
|
57
|
+
type: string;
|
|
58
|
+
children: string;
|
|
59
|
+
};
|
|
60
|
+
export {};
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata SEO cho `head()` của mỗi route: title, description, Open Graph, Twitter, canonical.
|
|
3
|
+
* Dùng một lời gọi thay vì tự viết từng thẻ để mọi trang nhất quán và không sót thẻ nào.
|
|
4
|
+
*
|
|
5
|
+
* head: () => seo({ title: "Thực đơn | Quán Mây", description: "…", path: "/thuc-don" })
|
|
6
|
+
*
|
|
7
|
+
* Mọi thuộc tính tùy chọn nhận được cả `undefined` (tsconfig bật exactOptionalPropertyTypes), nên truyền thẳng
|
|
8
|
+
* `image: item.image` với `item.image: string | undefined` là hợp lệ.
|
|
9
|
+
*/
|
|
10
|
+
import { createIsomorphicFn } from "@tanstack/react-start";
|
|
11
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
12
|
+
/** Địa chỉ của request hiện tại: phía server lấy từ request (SSR, thứ mà trình thu thập đọc), phía client từ trình duyệt. */
|
|
13
|
+
const requestOrigin = createIsomorphicFn()
|
|
14
|
+
.server(() => {
|
|
15
|
+
try {
|
|
16
|
+
return getRequestUrl().origin;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.client(() => window.location.origin);
|
|
23
|
+
/**
|
|
24
|
+
* Địa chỉ gốc công khai của website (không có "/" cuối), hoặc "" nếu chưa biết. `VITE_PUBLIC_SITE_URL` (nếu chủ web đặt) thắng;
|
|
25
|
+
* không thì dùng địa chỉ request, nên `og:image`, canonical, `og:url` luôn là URL tuyệt đối đúng tên miền đang chạy (kể cả tên miền riêng
|
|
26
|
+
* thêm sau khi build). Mạng xã hội (Facebook, Zalo) không hiển thị `og:image` là đường dẫn tương đối.
|
|
27
|
+
*/
|
|
28
|
+
function siteOrigin() {
|
|
29
|
+
const raw = import.meta.env?.["VITE_PUBLIC_SITE_URL"] ?? "";
|
|
30
|
+
const fromEnv = raw.trim().replace(/\/+$/, "");
|
|
31
|
+
if (fromEnv)
|
|
32
|
+
return fromEnv;
|
|
33
|
+
return requestOrigin().replace(/\/+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function absolute(origin, value) {
|
|
36
|
+
if (/^https?:\/\//i.test(value))
|
|
37
|
+
return value;
|
|
38
|
+
return origin ? `${origin}${value.startsWith("/") ? "" : "/"}${value}` : value;
|
|
39
|
+
}
|
|
40
|
+
/** URL tuyệt đối cho đường dẫn trong website, dùng khi tự viết thẻ meta thô (vd. `og:image` trong `__root.tsx`). */
|
|
41
|
+
export function absoluteUrl(value) {
|
|
42
|
+
return absolute(siteOrigin(), value);
|
|
43
|
+
}
|
|
44
|
+
export function seo(input) {
|
|
45
|
+
const origin = siteOrigin();
|
|
46
|
+
const meta = [
|
|
47
|
+
{ title: input.title },
|
|
48
|
+
{ name: "description", content: input.description },
|
|
49
|
+
{ property: "og:title", content: input.title },
|
|
50
|
+
{ property: "og:description", content: input.description },
|
|
51
|
+
{ property: "og:type", content: input.type ?? "website" },
|
|
52
|
+
{ name: "twitter:card", content: input.image ? "summary_large_image" : "summary" },
|
|
53
|
+
{ name: "twitter:title", content: input.title },
|
|
54
|
+
{ name: "twitter:description", content: input.description },
|
|
55
|
+
];
|
|
56
|
+
if (input.noindex)
|
|
57
|
+
meta.push({ name: "robots", content: "noindex, nofollow" });
|
|
58
|
+
if (input.siteName)
|
|
59
|
+
meta.push({ property: "og:site_name", content: input.siteName });
|
|
60
|
+
if (input.locale)
|
|
61
|
+
meta.push({ property: "og:locale", content: input.locale });
|
|
62
|
+
if (input.image) {
|
|
63
|
+
const image = absolute(origin, input.image);
|
|
64
|
+
meta.push({ property: "og:image", content: image }, { name: "twitter:image", content: image });
|
|
65
|
+
}
|
|
66
|
+
const links = [];
|
|
67
|
+
if (origin && input.path) {
|
|
68
|
+
const url = `${origin}${input.path === "/" ? "" : input.path}`;
|
|
69
|
+
links.push({ rel: "canonical", href: url || origin });
|
|
70
|
+
meta.push({ property: "og:url", content: url || origin });
|
|
71
|
+
}
|
|
72
|
+
return { meta, links };
|
|
73
|
+
}
|
|
74
|
+
/** Dữ liệu có cấu trúc cho doanh nghiệp địa phương: dùng trong `scripts` của head() ở trang chủ/liên hệ. */
|
|
75
|
+
export function localBusinessJsonLd(input) {
|
|
76
|
+
const data = {
|
|
77
|
+
"@context": "https://schema.org",
|
|
78
|
+
"@type": "LocalBusiness",
|
|
79
|
+
name: input.name,
|
|
80
|
+
...(input.description ? { description: input.description } : {}),
|
|
81
|
+
...(input.telephone ? { telephone: input.telephone } : {}),
|
|
82
|
+
...(input.email ? { email: input.email } : {}),
|
|
83
|
+
...(input.image ? { image: input.image } : {}),
|
|
84
|
+
...(input.openingHours?.length ? { openingHours: input.openingHours } : {}),
|
|
85
|
+
...(input.address
|
|
86
|
+
? {
|
|
87
|
+
address: {
|
|
88
|
+
"@type": "PostalAddress",
|
|
89
|
+
streetAddress: input.address.street,
|
|
90
|
+
addressLocality: input.address.city,
|
|
91
|
+
...(input.address.region ? { addressRegion: input.address.region } : {}),
|
|
92
|
+
addressCountry: input.address.country ?? "VN",
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
// `<` được thoát để chuỗi JSON không đóng thẻ script sớm.
|
|
98
|
+
return { type: "application/ld+json", children: JSON.stringify(data).replace(/</g, "\\u003c") };
|
|
99
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`: trình duyệt chấp nhận cookie `Secure` kể cả qua http. */
|
|
2
|
+
export declare function isLocalHost(hostname: string): boolean;
|
|
3
|
+
export type SessionCookieOptions = {
|
|
4
|
+
httpOnly: true;
|
|
5
|
+
path: "/";
|
|
6
|
+
maxAge: number;
|
|
7
|
+
secure: boolean;
|
|
8
|
+
sameSite: "lax" | "none";
|
|
9
|
+
partitioned?: true;
|
|
10
|
+
};
|
|
11
|
+
/** Thuần (dễ kiểm thử): chọn thuộc tính theo môi trường, giao thức và host của request. */
|
|
12
|
+
export declare function sessionCookieOptions(env: "preview" | "production", url: {
|
|
13
|
+
protocol: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
}, maxAgeSeconds: number): SessionCookieOptions;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Thuộc tính cookie phiên dùng chung cho quản lý và khách. Hodela quản lý file này: không sửa.
|
|
2
|
+
//
|
|
3
|
+
// Preview chạy trong iframe của editor (khác site với editor nên cookie `lax` bị coi là bên thứ ba và bị chặn: đăng nhập
|
|
4
|
+
// báo đúng nhưng không giữ được phiên, trong khi mở tab riêng thì được). Vì vậy preview dùng `SameSite=None; Secure;
|
|
5
|
+
// Partitioned` (CHIPS: cookie gắn với trang editor chứa iframe nên không phải cookie theo dõi xuyên site). Production không
|
|
6
|
+
// bị nhúng nên giữ `lax`, đây là mặc định an toàn nhất chống CSRF.
|
|
7
|
+
/** `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`: trình duyệt chấp nhận cookie `Secure` kể cả qua http. */
|
|
8
|
+
export function isLocalHost(hostname) {
|
|
9
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
10
|
+
}
|
|
11
|
+
/** Thuần (dễ kiểm thử): chọn thuộc tính theo môi trường, giao thức và host của request. */
|
|
12
|
+
export function sessionCookieOptions(env, url, maxAgeSeconds) {
|
|
13
|
+
const secureContext = url.protocol === "https:" || isLocalHost(url.hostname);
|
|
14
|
+
if (env !== "production" && secureContext) {
|
|
15
|
+
return { httpOnly: true, path: "/", maxAge: maxAgeSeconds, secure: true, sameSite: "none", partitioned: true };
|
|
16
|
+
}
|
|
17
|
+
return { httpOnly: true, path: "/", maxAge: maxAgeSeconds, secure: env === "production", sameSite: "lax" };
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tách câu lệnh SQL cho migration. MỘT thuật toán duy nhất cho mọi nơi: kiểm tra, áp lên D1, áp lên
|
|
3
|
+
* database ngoài. File này được sao nguyên văn vào starter (`src/lib/sql-split.ts`) để website tự áp
|
|
4
|
+
* migration lúc chạy; test so khớp hai bản trên cùng một bộ mẫu. Không import gì để sao chép được.
|
|
5
|
+
*/
|
|
6
|
+
export type SqlDialect = "sqlite" | "postgres" | "mysql";
|
|
7
|
+
export type SqlToken = {
|
|
8
|
+
kind: "word" | "ident" | "string" | "number" | "punct";
|
|
9
|
+
/** word: chữ HOA; ident: tên đã bỏ dấu nháy (giữ nguyên chữ hoa/thường); còn lại: văn bản gốc. */
|
|
10
|
+
text: string;
|
|
11
|
+
line: number;
|
|
12
|
+
};
|
|
13
|
+
export type SqlStatement = {
|
|
14
|
+
/** Câu lệnh đã bỏ chú thích, không có dấu `;` cuối. Đây chính là chuỗi được chạy. */
|
|
15
|
+
sql: string;
|
|
16
|
+
tokens: SqlToken[];
|
|
17
|
+
/** Dòng bắt đầu (1-based) trong file gốc, để báo lỗi. */
|
|
18
|
+
line: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class SqlSyntaxError extends Error {
|
|
21
|
+
readonly line: number;
|
|
22
|
+
constructor(message: string, line: number);
|
|
23
|
+
}
|
|
24
|
+
export declare function splitSqlStatements(source: string, dialect?: SqlDialect): SqlStatement[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tách câu lệnh SQL cho migration. MỘT thuật toán duy nhất cho mọi nơi: kiểm tra, áp lên D1, áp lên
|
|
3
|
+
* database ngoài. File này được sao nguyên văn vào starter (`src/lib/sql-split.ts`) để website tự áp
|
|
4
|
+
* migration lúc chạy; test so khớp hai bản trên cùng một bộ mẫu. Không import gì để sao chép được.
|
|
5
|
+
*/
|
|
6
|
+
export class SqlSyntaxError extends Error {
|
|
7
|
+
line;
|
|
8
|
+
constructor(message, line) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.line = line;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const WORD_START = /[A-Za-z_\u0080-]/;
|
|
14
|
+
const WORD_PART = /[A-Za-z0-9_$\u0080-]/;
|
|
15
|
+
export function splitSqlStatements(source, dialect = "sqlite") {
|
|
16
|
+
const out = [];
|
|
17
|
+
let text = "";
|
|
18
|
+
let tokens = [];
|
|
19
|
+
let startLine = 0;
|
|
20
|
+
let line = 1;
|
|
21
|
+
let i = 0;
|
|
22
|
+
const n = source.length;
|
|
23
|
+
const push = (t, raw) => {
|
|
24
|
+
if (!startLine)
|
|
25
|
+
startLine = t.line;
|
|
26
|
+
tokens.push(t);
|
|
27
|
+
text += raw;
|
|
28
|
+
};
|
|
29
|
+
const flush = () => {
|
|
30
|
+
const sql = text.trim();
|
|
31
|
+
if (sql)
|
|
32
|
+
out.push({ sql, tokens, line: startLine || line });
|
|
33
|
+
text = "";
|
|
34
|
+
tokens = [];
|
|
35
|
+
startLine = 0;
|
|
36
|
+
};
|
|
37
|
+
const sep = () => {
|
|
38
|
+
if (text && !text.endsWith(" "))
|
|
39
|
+
text += " ";
|
|
40
|
+
};
|
|
41
|
+
while (i < n) {
|
|
42
|
+
const c = source[i];
|
|
43
|
+
const next = source[i + 1];
|
|
44
|
+
if (c === "\n") {
|
|
45
|
+
line += 1;
|
|
46
|
+
sep();
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
51
|
+
sep();
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === "-" && next === "-") {
|
|
56
|
+
while (i < n && source[i] !== "\n")
|
|
57
|
+
i += 1;
|
|
58
|
+
sep();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (c === "/" && next === "*") {
|
|
62
|
+
const startedAt = line;
|
|
63
|
+
i += 2;
|
|
64
|
+
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
|
|
65
|
+
if (source[i] === "\n")
|
|
66
|
+
line += 1;
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
if (i >= n)
|
|
70
|
+
throw new SqlSyntaxError("Chú thích /* không được đóng", startedAt);
|
|
71
|
+
i += 2;
|
|
72
|
+
sep();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c === ";") {
|
|
76
|
+
flush();
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Chuỗi '...' (và E'...' của PostgreSQL; MySQL cho phép \ thoát ký tự).
|
|
81
|
+
if (c === "'") {
|
|
82
|
+
const startedAt = line;
|
|
83
|
+
const prev = source[i - 1];
|
|
84
|
+
const backslash = dialect === "mysql" || (dialect === "postgres" && (prev === "E" || prev === "e"));
|
|
85
|
+
let j = i + 1;
|
|
86
|
+
let closed = false;
|
|
87
|
+
while (j < n) {
|
|
88
|
+
const d = source[j];
|
|
89
|
+
if (d === "\n")
|
|
90
|
+
line += 1;
|
|
91
|
+
if (backslash && d === "\\") {
|
|
92
|
+
j += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (d === "'") {
|
|
96
|
+
if (source[j + 1] === "'") {
|
|
97
|
+
j += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
closed = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
j += 1;
|
|
104
|
+
}
|
|
105
|
+
if (!closed)
|
|
106
|
+
throw new SqlSyntaxError("Chuỗi ' không được đóng", startedAt);
|
|
107
|
+
const raw = source.slice(i, j + 1);
|
|
108
|
+
push({ kind: "string", text: raw, line: startedAt }, raw);
|
|
109
|
+
i = j + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Tên có nháy: "..." (chuẩn), `...` (MySQL).
|
|
113
|
+
if (c === '"' || c === "`") {
|
|
114
|
+
const startedAt = line;
|
|
115
|
+
let j = i + 1;
|
|
116
|
+
let name = "";
|
|
117
|
+
let closed = false;
|
|
118
|
+
while (j < n) {
|
|
119
|
+
const d = source[j];
|
|
120
|
+
if (d === "\n")
|
|
121
|
+
line += 1;
|
|
122
|
+
if (d === c) {
|
|
123
|
+
if (source[j + 1] === c) {
|
|
124
|
+
name += c;
|
|
125
|
+
j += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
closed = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
name += d;
|
|
132
|
+
j += 1;
|
|
133
|
+
}
|
|
134
|
+
if (!closed)
|
|
135
|
+
throw new SqlSyntaxError(`Tên ${c}...${c} không được đóng`, startedAt);
|
|
136
|
+
const raw = source.slice(i, j + 1);
|
|
137
|
+
push({ kind: "ident", text: name, line: startedAt }, raw);
|
|
138
|
+
i = j + 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Chuỗi dollar-quote của PostgreSQL: $$...$$ hoặc $tag$...$tag$.
|
|
142
|
+
if (c === "$" && dialect === "postgres") {
|
|
143
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(source.slice(i, i + 80));
|
|
144
|
+
if (m) {
|
|
145
|
+
const startedAt = line;
|
|
146
|
+
const tag = m[0];
|
|
147
|
+
const end = source.indexOf(tag, i + tag.length);
|
|
148
|
+
if (end < 0)
|
|
149
|
+
throw new SqlSyntaxError(`Chuỗi ${tag} không được đóng`, startedAt);
|
|
150
|
+
const raw = source.slice(i, end + tag.length);
|
|
151
|
+
for (const ch of raw)
|
|
152
|
+
if (ch === "\n")
|
|
153
|
+
line += 1;
|
|
154
|
+
push({ kind: "string", text: raw, line: startedAt }, raw);
|
|
155
|
+
i = end + tag.length;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (WORD_START.test(c)) {
|
|
160
|
+
let j = i + 1;
|
|
161
|
+
while (j < n && WORD_PART.test(source[j]))
|
|
162
|
+
j += 1;
|
|
163
|
+
const raw = source.slice(i, j);
|
|
164
|
+
push({ kind: "word", text: raw.toUpperCase(), line }, raw);
|
|
165
|
+
i = j;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/[0-9]/.test(c)) {
|
|
169
|
+
let j = i + 1;
|
|
170
|
+
while (j < n && /[0-9A-Za-z_.]/.test(source[j]))
|
|
171
|
+
j += 1;
|
|
172
|
+
const raw = source.slice(i, j);
|
|
173
|
+
push({ kind: "number", text: raw, line }, raw);
|
|
174
|
+
i = j;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
push({ kind: "punct", text: c, line }, c);
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
flush();
|
|
181
|
+
return out;
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hodela/site",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"README.md"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
"./access": {
|
|
11
|
+
"types": "./dist/access.d.ts",
|
|
12
|
+
"import": "./dist/access.js"
|
|
13
|
+
},
|
|
14
|
+
"./analytics": {
|
|
15
|
+
"types": "./dist/analytics.d.ts",
|
|
16
|
+
"import": "./dist/analytics.js"
|
|
17
|
+
},
|
|
18
|
+
"./sql-split": {
|
|
19
|
+
"types": "./dist/sql-split.d.ts",
|
|
20
|
+
"import": "./dist/sql-split.js"
|
|
21
|
+
},
|
|
22
|
+
"./admin-token": {
|
|
23
|
+
"types": "./dist/admin-token.d.ts",
|
|
24
|
+
"import": "./dist/admin-token.js"
|
|
25
|
+
},
|
|
26
|
+
"./session-cookie": {
|
|
27
|
+
"types": "./dist/session-cookie.d.ts",
|
|
28
|
+
"import": "./dist/session-cookie.js"
|
|
29
|
+
},
|
|
30
|
+
"./customer-auth": {
|
|
31
|
+
"types": "./dist/customer-auth.d.ts",
|
|
32
|
+
"import": "./dist/customer-auth.js"
|
|
33
|
+
},
|
|
34
|
+
"./mailer": {
|
|
35
|
+
"types": "./dist/mailer.d.ts",
|
|
36
|
+
"import": "./dist/mailer.js"
|
|
37
|
+
},
|
|
38
|
+
"./seo": {
|
|
39
|
+
"types": "./dist/seo.d.ts",
|
|
40
|
+
"import": "./dist/seo.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@tanstack/react-start": ">=1.100.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@tanstack/react-start": "1.169.4",
|
|
53
|
+
"@types/node": "^22.16.5",
|
|
54
|
+
"typescript": "^5.8.3",
|
|
55
|
+
"vite": "^8.3.5",
|
|
56
|
+
"vitest": "^2.1.8"
|
|
57
|
+
},
|
|
58
|
+
"description": "Mã chạy chung của website Hodela (truy cập, analytics, phiên quản trị, tài khoản khách, email, SEO, tách SQL). Starter dùng gói này thay vì chép mã vào từng website.",
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@hodela/email-js": "0.1.1"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/README.md
DELETED