@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/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
|
+
// Dùng qua "@hodela/site/server/mailer".
|
|
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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type AdminLoginResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: "wrong" | "locked";
|
|
7
|
+
};
|
|
8
|
+
export type AdminSession = {
|
|
9
|
+
/** `loading`: đang hỏi server; `signedOut`: chưa đăng nhập; `signedIn`: đã đăng nhập. */
|
|
10
|
+
state: "loading" | "signedOut" | "signedIn";
|
|
11
|
+
/** `false` khi chủ website chưa đặt biến ADMIN_PASSWORD: khu vực quản lý bị khóa, chưa ai đăng nhập được. */
|
|
12
|
+
configured: boolean;
|
|
13
|
+
/** Đang gửi yêu cầu đăng nhập hoặc đăng xuất. */
|
|
14
|
+
busy: boolean;
|
|
15
|
+
/** `wrong`: sai mật khẩu; `locked`: thử sai quá nhiều lần, chờ ít phút. */
|
|
16
|
+
login: (password: string) => Promise<AdminLoginResult>;
|
|
17
|
+
logout: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/** Phiên quản trị hiện tại. Chỉ dùng bên trong <AdminGate> (cả trong `fallback` và `children`). */
|
|
20
|
+
export declare function useAdminSession(): AdminSession;
|
|
21
|
+
export declare function AdminGate({ children, fallback, loading }: {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
fallback: (session: AdminSession) => ReactNode;
|
|
24
|
+
loading?: ReactNode;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Cổng đăng nhập cho khu vực quản lý của chủ website. Hodela quản lý file này: không sửa.
|
|
3
|
+
//
|
|
4
|
+
// File này chỉ giữ LOGIC phiên quản trị (không vẽ giao diện nào). Giao diện đăng nhập và khung trang do website tự quyết:
|
|
5
|
+
// • mẫu có sẵn, được phép sửa/thay: `@/components/admin-login` (form) và `@/components/admin-layout` (khung);
|
|
6
|
+
// • hoặc tự dựng giao diện riêng bằng `useAdminSession()`.
|
|
7
|
+
//
|
|
8
|
+
// export const Route = createFileRoute("/admin")({
|
|
9
|
+
// component: () => (
|
|
10
|
+
// <AdminGate fallback={(session) => <AdminLogin session={session} />}>
|
|
11
|
+
// <AdminLayout title="Admin" nav={[{ to: "/admin", label: "Overview", exact: true }]}>
|
|
12
|
+
// <Outlet />
|
|
13
|
+
// </AdminLayout>
|
|
14
|
+
// </AdminGate>
|
|
15
|
+
// ),
|
|
16
|
+
// });
|
|
17
|
+
//
|
|
18
|
+
// Chỉ bọc giao diện; dữ liệu vẫn phải được bảo vệ bằng `await assertAdmin()` trong server function.
|
|
19
|
+
import { useRouter } from "@tanstack/react-router";
|
|
20
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
21
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
22
|
+
import { adminLogin, adminLogout, adminStatus } from "../admin.functions.js";
|
|
23
|
+
const SessionContext = createContext(null);
|
|
24
|
+
/** Phiên quản trị hiện tại. Chỉ dùng bên trong <AdminGate> (cả trong `fallback` và `children`). */
|
|
25
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
26
|
+
export function useAdminSession() {
|
|
27
|
+
const session = useContext(SessionContext);
|
|
28
|
+
if (!session)
|
|
29
|
+
throw new Error("useAdminSession must be used inside <AdminGate>.");
|
|
30
|
+
return session;
|
|
31
|
+
}
|
|
32
|
+
export function AdminGate({ children, fallback, loading = null }) {
|
|
33
|
+
const status = useServerFn(adminStatus);
|
|
34
|
+
const loginFn = useServerFn(adminLogin);
|
|
35
|
+
const logoutFn = useServerFn(adminLogout);
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const [info, setInfo] = useState(null);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const refresh = useCallback(() => status().then(setInfo).catch(() => setInfo({ configured: true, authed: false })), [status]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void refresh();
|
|
42
|
+
}, [refresh]);
|
|
43
|
+
const login = useCallback(async (password) => {
|
|
44
|
+
setBusy(true);
|
|
45
|
+
const result = await loginFn({ data: { password } }).catch(() => ({ ok: false, reason: "wrong" }));
|
|
46
|
+
await refresh();
|
|
47
|
+
// Đổi phiên thì dữ liệu route (loader) đã tải khi chưa đăng nhập phải tải lại.
|
|
48
|
+
if (result.ok)
|
|
49
|
+
await router.invalidate();
|
|
50
|
+
setBusy(false);
|
|
51
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.reason === "locked" ? "locked" : "wrong" };
|
|
52
|
+
}, [loginFn, refresh, router]);
|
|
53
|
+
const logout = useCallback(async () => {
|
|
54
|
+
setBusy(true);
|
|
55
|
+
await logoutFn().catch(() => { });
|
|
56
|
+
await refresh();
|
|
57
|
+
await router.invalidate();
|
|
58
|
+
setBusy(false);
|
|
59
|
+
}, [logoutFn, refresh]);
|
|
60
|
+
const session = useMemo(() => ({ state: !info ? "loading" : info.authed ? "signedIn" : "signedOut", configured: info?.configured ?? true, busy, login, logout }), [info, busy, login, logout]);
|
|
61
|
+
return _jsx(SessionContext.Provider, { value: session, children: session.state === "loading" ? loading : session.state === "signedIn" ? children : fallback(session) });
|
|
62
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type HodelaErrorOptions = {
|
|
2
|
+
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
|
3
|
+
handled?: boolean;
|
|
4
|
+
severity?: "error" | "warning" | "info";
|
|
5
|
+
};
|
|
6
|
+
type HodelaEvents = {
|
|
7
|
+
track?: (event: string, properties?: Record<string, unknown>) => string | null;
|
|
8
|
+
captureException?: (error: unknown, context?: Record<string, unknown>, options?: HodelaErrorOptions) => void;
|
|
9
|
+
};
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__hodelaEvents?: HodelaEvents;
|
|
13
|
+
__hodelaReportRuntimeError?: (payload: {
|
|
14
|
+
message: string;
|
|
15
|
+
stack?: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export declare function reportHodelaError(error: unknown, context?: Record<string, unknown>): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function reportHodelaError(error, context = {}) {
|
|
2
|
+
if (typeof window === "undefined")
|
|
3
|
+
return;
|
|
4
|
+
window.__hodelaEvents?.captureException?.(error, {
|
|
5
|
+
source: "react_error_boundary",
|
|
6
|
+
route: window.location.pathname,
|
|
7
|
+
...context,
|
|
8
|
+
}, {
|
|
9
|
+
mechanism: "react_error_boundary",
|
|
10
|
+
handled: false,
|
|
11
|
+
severity: "error",
|
|
12
|
+
});
|
|
13
|
+
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
|
14
|
+
// editor's telemetry never sees them. Forward to hodela.js's reporting hook,
|
|
15
|
+
// which is present only inside the editor preview.
|
|
16
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
17
|
+
window.__hodelaReportRuntimeError?.({
|
|
18
|
+
message: describeThrown(error),
|
|
19
|
+
...(stack !== undefined && { stack }),
|
|
20
|
+
filename: window.location.pathname,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const MAX_SERIALIZED_LENGTH = 2000;
|
|
24
|
+
// Loaders and server fns throw raw Responses and plain objects (a Supabase
|
|
25
|
+
// `{ message, code, details }`), which String() reduces to "[object ...]".
|
|
26
|
+
function describeThrown(error) {
|
|
27
|
+
if (error instanceof Response) {
|
|
28
|
+
return `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`;
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof Error)
|
|
31
|
+
return error.message;
|
|
32
|
+
if (typeof error === "string")
|
|
33
|
+
return error;
|
|
34
|
+
const { message } = (error ?? {});
|
|
35
|
+
if (typeof message === "string" && message.length > 0)
|
|
36
|
+
return message;
|
|
37
|
+
try {
|
|
38
|
+
return (JSON.stringify(error)?.slice(0, MAX_SERIALIZED_LENGTH) ?? String(error));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return String(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
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,10 @@
|
|
|
1
|
+
/** Đã có mật khẩu quản lý hợp lệ (≥ 6 ký tự) chưa. Chưa có thì khu vực quản lý bị khóa hoàn toàn. */
|
|
2
|
+
export declare function adminConfigured(): boolean;
|
|
3
|
+
export declare function isAdmin(): Promise<boolean>;
|
|
4
|
+
/** Ném lỗi UNAUTHORIZED nếu người gọi không phải chủ website. Gọi ở đầu mọi handler quản lý. */
|
|
5
|
+
export declare function assertAdmin(): Promise<void>;
|
|
6
|
+
export declare function loginAdmin(input: string): Promise<{
|
|
7
|
+
ok: boolean;
|
|
8
|
+
reason?: "not_configured" | "locked" | "wrong";
|
|
9
|
+
}>;
|
|
10
|
+
export declare function logoutAdmin(): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Chỉ chạy trên server. Khu vực quản lý của chủ website: mật khẩu do chủ web đặt (biến ADMIN_PASSWORD),
|
|
2
|
+
// phiên ký bằng HODELA_SESSION_SECRET do Hodela cấp. Hodela quản lý file này: không sửa.
|
|
3
|
+
//
|
|
4
|
+
// Dùng trong server function: `await assertAdmin()` ở đầu MỌI handler đọc/sửa dữ liệu riêng tư
|
|
5
|
+
// (đơn hàng, liên hệ, thông tin khách...). Logic phiên phía giao diện: <AdminGate> và useAdminSession() ở "@hodela/site/react" (mẫu giao diện: admin-login, admin-layout).
|
|
6
|
+
import { getCookie, getRequestIP, setCookie } from "@tanstack/react-start/server";
|
|
7
|
+
import { serverEnv } from "./env.js";
|
|
8
|
+
import { sessionCookie } from "./session-cookie.js";
|
|
9
|
+
import { passwordFingerprint, safeEqual, signAdminToken, verifyAdminToken } from "../admin-token.js";
|
|
10
|
+
const COOKIE = "hodela_admin";
|
|
11
|
+
const SESSION_MS = 12 * 60 * 60 * 1000;
|
|
12
|
+
const MAX_FAILS = 6;
|
|
13
|
+
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
|
14
|
+
const fails = new Map();
|
|
15
|
+
function password() {
|
|
16
|
+
const p = serverEnv("ADMIN_PASSWORD");
|
|
17
|
+
return p && p.length >= 6 ? p : undefined;
|
|
18
|
+
}
|
|
19
|
+
function secret(pw) {
|
|
20
|
+
return serverEnv("HODELA_SESSION_SECRET") ?? `dev-${pw}`;
|
|
21
|
+
}
|
|
22
|
+
/** Đã có mật khẩu quản lý hợp lệ (≥ 6 ký tự) chưa. Chưa có thì khu vực quản lý bị khóa hoàn toàn. */
|
|
23
|
+
export function adminConfigured() {
|
|
24
|
+
return password() !== undefined;
|
|
25
|
+
}
|
|
26
|
+
export async function isAdmin() {
|
|
27
|
+
const pw = password();
|
|
28
|
+
if (!pw)
|
|
29
|
+
return false;
|
|
30
|
+
const s = secret(pw);
|
|
31
|
+
return verifyAdminToken(s, await passwordFingerprint(s, pw), getCookie(COOKIE), Date.now());
|
|
32
|
+
}
|
|
33
|
+
/** Ném lỗi UNAUTHORIZED nếu người gọi không phải chủ website. Gọi ở đầu mọi handler quản lý. */
|
|
34
|
+
export async function assertAdmin() {
|
|
35
|
+
if (!(await isAdmin()))
|
|
36
|
+
throw new Error("UNAUTHORIZED");
|
|
37
|
+
}
|
|
38
|
+
export async function loginAdmin(input) {
|
|
39
|
+
const pw = password();
|
|
40
|
+
if (!pw)
|
|
41
|
+
return { ok: false, reason: "not_configured" };
|
|
42
|
+
const key = getRequestIP({ xForwardedFor: true }) ?? "unknown";
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const rec = fails.get(key);
|
|
45
|
+
if (rec && now - rec.first < FAIL_WINDOW_MS && rec.count >= MAX_FAILS)
|
|
46
|
+
return { ok: false, reason: "locked" };
|
|
47
|
+
const s = secret(pw);
|
|
48
|
+
if (!(await safeEqual(s, input, pw))) {
|
|
49
|
+
const fresh = !rec || now - rec.first >= FAIL_WINDOW_MS;
|
|
50
|
+
fails.set(key, { count: fresh ? 1 : rec.count + 1, first: fresh ? now : rec.first });
|
|
51
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
52
|
+
return { ok: false, reason: "wrong" };
|
|
53
|
+
}
|
|
54
|
+
fails.delete(key);
|
|
55
|
+
const token = await signAdminToken(s, await passwordFingerprint(s, pw), now + SESSION_MS);
|
|
56
|
+
setCookie(COOKIE, token, sessionCookie(SESSION_MS / 1000));
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
export function logoutAdmin() {
|
|
60
|
+
setCookie(COOKIE, "", sessionCookie(0));
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { hashPassword, passwordProblem, verifyPassword } from "../customer-auth.js";
|
|
2
|
+
export declare function startCustomerSession(customerId: string): Promise<void>;
|
|
3
|
+
export declare function endCustomerSession(): void;
|
|
4
|
+
/** Id khách đang đăng nhập, hoặc null. Luôn kiểm tra chữ ký; id này vẫn phải được đối chiếu với database khi cần. */
|
|
5
|
+
export declare function currentCustomerId(): Promise<string | null>;
|
|
6
|
+
/**
|
|
7
|
+
* Chặn dò mật khẩu: gọi trước khi kiểm tra mật khẩu (`tooManyLoginAttempts()`), và `recordLoginFailure()` khi sai.
|
|
8
|
+
* Giới hạn theo IP trong bộ nhớ của instance; đủ để chặn dò đơn giản, không thay cho việc dùng mật khẩu mạnh.
|
|
9
|
+
*/
|
|
10
|
+
export declare function tooManyLoginAttempts(): boolean;
|
|
11
|
+
export declare function recordLoginFailure(): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Chỉ chạy trên server. Phiên đăng nhập của khách hàng cuối. Hodela quản lý file này: không sửa.
|
|
2
|
+
//
|
|
3
|
+
// Mẫu dùng (bảng `customers` do bạn tạo trong db/migrations, cột password_hash lưu kết quả hashPassword):
|
|
4
|
+
// register: kiểm tra email chưa tồn tại → hashPassword → INSERT vào database → startCustomerSession(id)
|
|
5
|
+
// login : SELECT theo email → verifyPassword → startCustomerSession(id) (sai thì báo chung "Email hoặc mật khẩu không đúng")
|
|
6
|
+
// trang cần đăng nhập: const id = await currentCustomerId(); if (!id) → yêu cầu đăng nhập
|
|
7
|
+
import { getCookie, getRequestIP, setCookie } from "@tanstack/react-start/server";
|
|
8
|
+
import { serverEnv } from "./env.js";
|
|
9
|
+
import { sessionCookie } from "./session-cookie.js";
|
|
10
|
+
import { signCustomerToken, verifyCustomerToken } from "../customer-auth.js";
|
|
11
|
+
export { hashPassword, passwordProblem, verifyPassword } from "../customer-auth.js";
|
|
12
|
+
const COOKIE = "hodela_customer";
|
|
13
|
+
const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const MAX_FAILS = 8;
|
|
15
|
+
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
|
16
|
+
const fails = new Map();
|
|
17
|
+
function secret() {
|
|
18
|
+
return serverEnv("HODELA_SESSION_SECRET") ?? "dev-customer-session";
|
|
19
|
+
}
|
|
20
|
+
export async function startCustomerSession(customerId) {
|
|
21
|
+
const token = await signCustomerToken(secret(), customerId, Date.now() + SESSION_MS);
|
|
22
|
+
setCookie(COOKIE, token, sessionCookie(SESSION_MS / 1000));
|
|
23
|
+
}
|
|
24
|
+
export function endCustomerSession() {
|
|
25
|
+
setCookie(COOKIE, "", sessionCookie(0));
|
|
26
|
+
}
|
|
27
|
+
/** Id khách đang đăng nhập, hoặc null. Luôn kiểm tra chữ ký; id này vẫn phải được đối chiếu với database khi cần. */
|
|
28
|
+
export async function currentCustomerId() {
|
|
29
|
+
return verifyCustomerToken(secret(), getCookie(COOKIE), Date.now());
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Chặn dò mật khẩu: gọi trước khi kiểm tra mật khẩu (`tooManyLoginAttempts()`), và `recordLoginFailure()` khi sai.
|
|
33
|
+
* Giới hạn theo IP trong bộ nhớ của instance; đủ để chặn dò đơn giản, không thay cho việc dùng mật khẩu mạnh.
|
|
34
|
+
*/
|
|
35
|
+
export function tooManyLoginAttempts() {
|
|
36
|
+
const rec = fails.get(getRequestIP({ xForwardedFor: true }) ?? "unknown");
|
|
37
|
+
return Boolean(rec && Date.now() - rec.first < FAIL_WINDOW_MS && rec.count >= MAX_FAILS);
|
|
38
|
+
}
|
|
39
|
+
export function recordLoginFailure() {
|
|
40
|
+
const key = getRequestIP({ xForwardedFor: true }) ?? "unknown";
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const rec = fails.get(key);
|
|
43
|
+
const fresh = !rec || now - rec.first >= FAIL_WINDOW_MS;
|
|
44
|
+
fails.set(key, { count: fresh ? 1 : rec.count + 1, first: fresh ? now : rec.first });
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MySqlDatabase } from "drizzle-orm/mysql-core";
|
|
2
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
|
+
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
|
|
4
|
+
export type Dialect = "sqlite" | "postgres" | "mysql";
|
|
5
|
+
export type DbFor<D extends Dialect> = D extends "postgres" ? PgDatabase<any, any, any> : D extends "mysql" ? MySqlDatabase<any, any, any, any> : BaseSQLiteDatabase<"async", any, any>;
|
|
6
|
+
/** Dialect đang được cấu hình cho website này. Viết schema bằng đúng dialect này. */
|
|
7
|
+
export declare function databaseDialect(): Dialect;
|
|
8
|
+
/** Gọi một lần trong `src/lib/db.server.ts` của website; trả về `getDb` và `databaseDialect` đã gắn migration của website đó. */
|
|
9
|
+
export declare function createDbAccess(files: Record<string, string>): {
|
|
10
|
+
getDb: typeof getDb;
|
|
11
|
+
databaseDialect: typeof databaseDialect;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Trả về đối tượng Drizzle đã kết nối. Gọi một lần trong mỗi server function (đừng giữ ở biến module:
|
|
15
|
+
* kết nối TCP không dùng lại giữa các request trên Cloudflare Workers).
|
|
16
|
+
*
|
|
17
|
+
* const db = await getDb(); // sqlite: D1 hoặc libsql
|
|
18
|
+
* const db = await getDb<"postgres">(); // khi website dùng Postgres
|
|
19
|
+
*/
|
|
20
|
+
export declare function getDb<D extends Dialect = "sqlite">(): Promise<DbFor<D>>;
|