@hodela/site 0.1.0 → 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 +7 -0
- package/dist/admin.functions.d.ts +18 -0
- package/dist/admin.functions.js +17 -0
- package/dist/customer-auth.js +1 -1
- package/dist/error-page.d.ts +1 -0
- package/dist/error-page.js +30 -0
- package/dist/mailer.js +1 -1
- package/dist/react/admin-gate.d.ts +25 -0
- package/dist/react/admin-gate.js +62 -0
- package/dist/react/error-reporting.d.ts +21 -0
- package/dist/react/error-reporting.js +43 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +2 -0
- package/dist/server/admin.d.ts +10 -0
- package/dist/server/admin.js +61 -0
- package/dist/server/customer-auth.d.ts +11 -0
- package/dist/server/customer-auth.js +45 -0
- package/dist/server/db.d.ts +20 -0
- package/dist/server/db.js +133 -0
- package/dist/server/entry.d.ts +4 -0
- package/dist/server/entry.js +97 -0
- package/dist/server/env.d.ts +7 -0
- package/dist/server/env.js +14 -0
- package/dist/server/error-capture.d.ts +2 -0
- package/dist/server/error-capture.js +72 -0
- package/dist/server/files.d.ts +17 -0
- package/dist/server/files.js +21 -0
- package/dist/server/mailer.d.ts +10 -0
- package/dist/server/mailer.js +16 -0
- package/dist/server/session-cookie.d.ts +2 -0
- package/dist/server/session-cookie.js +7 -0
- package/dist/start.d.ts +2 -0
- package/dist/start.js +27 -0
- package/dist/testing/cloudflare-workers.d.ts +1 -0
- package/dist/testing/cloudflare-workers.js +3 -0
- package/package.json +70 -4
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @hodela/site
|
|
2
|
+
|
|
3
|
+
Mã chạy chung của website Hodela, để starter không phải chép vào từng website (cập nhật bằng cách nâng phiên bản gói).
|
|
4
|
+
|
|
5
|
+
Subpath: `access` (PIN, danh sách IP, chặn bot, badge), `analytics`, `sql-split` (bộ tách câu lệnh migration, nền tảng cũng dùng), `admin-token`, `session-cookie`, `customer-auth`, `mailer`, `seo`.
|
|
6
|
+
|
|
7
|
+
Quy ước: thay đổi phải tương thích ngược (mọi website dùng chung một bản trong image build). Test nằm cạnh mã (`pnpm --filter @hodela/site test`). Lộ trình chuyển nốt phần còn lại của starter: `docs/development.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const adminStatus: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
|
|
3
|
+
configured: boolean;
|
|
4
|
+
authed: boolean;
|
|
5
|
+
}>>;
|
|
6
|
+
export declare const adminLogin: import("@tanstack/react-start").RequiredFetcher<undefined, z.ZodObject<{
|
|
7
|
+
password: z.ZodString;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
password: string;
|
|
10
|
+
}, {
|
|
11
|
+
password: string;
|
|
12
|
+
}>, Promise<{
|
|
13
|
+
ok: boolean;
|
|
14
|
+
reason?: "not_configured" | "locked" | "wrong";
|
|
15
|
+
}>>;
|
|
16
|
+
export declare const adminLogout: import("@tanstack/react-start").OptionalFetcher<undefined, undefined, Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
}>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Server function cho khu vực quản lý (dùng bởi <AdminGate>/useAdminSession). Hodela quản lý file này: không sửa.
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { adminConfigured, isAdmin, loginAdmin } from "./server/admin.js";
|
|
5
|
+
export const adminStatus = createServerFn({ method: "GET" }).handler(async () => {
|
|
6
|
+
return { configured: adminConfigured(), authed: await isAdmin() };
|
|
7
|
+
});
|
|
8
|
+
export const adminLogin = createServerFn({ method: "POST" })
|
|
9
|
+
.validator(z.object({ password: z.string().min(1).max(200) }))
|
|
10
|
+
.handler(async ({ data }) => {
|
|
11
|
+
return loginAdmin(data.password);
|
|
12
|
+
});
|
|
13
|
+
export const adminLogout = createServerFn({ method: "POST" }).handler(async () => {
|
|
14
|
+
const { logoutAdmin } = await import("./server/admin.js");
|
|
15
|
+
logoutAdmin();
|
|
16
|
+
return { ok: true };
|
|
17
|
+
});
|
package/dist/customer-auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Tài khoản của KHÁCH HÀNG cuối (khác khu vực quản lý của chủ web): băm mật khẩu và mã phiên. Thuần, kiểm thử được.
|
|
2
|
-
//
|
|
2
|
+
// Dùng qua "@hodela/site/server/customer-auth".
|
|
3
3
|
const enc = new TextEncoder();
|
|
4
4
|
// Cloudflare Workers giới hạn PBKDF2 tối đa 100.000 vòng.
|
|
5
5
|
const ITERATIONS = 100_000;
|
|
@@ -0,0 +1 @@
|
|
|
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.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Gửi email thông báo (form liên hệ, đơn hàng, lịch hẹn...). Thuần (nhận env vào) nên kiểm thử được.
|
|
2
|
-
//
|
|
2
|
+
// Dùng qua "@hodela/site/server/mailer".
|
|
3
3
|
//
|
|
4
4
|
// Cách gửi do chủ web chọn, suy ra từ biến môi trường (agent chỉ hỏi chủ web rồi xin đúng các biến này):
|
|
5
5
|
// • Gửi bằng Hodela : đặt NOTIFY_EMAIL (hộp thư nhận thông báo). Hodela cấp sẵn HODELA_API_URL/HODELA_API_KEY.
|
|
@@ -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
|
+
}
|
|
@@ -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>>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Chỉ chạy trên server (đuôi .server.ts). Import trong handler của createServerFn hoặc server route.
|
|
2
|
+
//
|
|
3
|
+
// Kết nối database cho website. Dùng Drizzle ORM; file này chỉ chọn driver theo CẤU HÌNH:
|
|
4
|
+
// - Mặc định: D1 của Hodela (binding PROJECT_DB, dialect sqlite).
|
|
5
|
+
// - Chủ web đổi sang database của họ trong Cài đặt → Cơ sở dữ liệu: Hodela đặt DATABASE_URL
|
|
6
|
+
// (postgresql://…, mysql://…, libsql://… ?authToken=…) và HODELA_DB_DIALECT.
|
|
7
|
+
// Mã nguồn của website KHÔNG đổi khi đổi URL hoặc đổi nhà cung cấp trong cùng dialect.
|
|
8
|
+
// Đổi dialect (sqlite ↔ postgres ↔ mysql) cần viết lại schema, vì mỗi dialect có kiểu bảng riêng.
|
|
9
|
+
import { env } from "cloudflare:workers";
|
|
10
|
+
import { splitSqlStatements } from "../sql-split.js";
|
|
11
|
+
import { sql } from "drizzle-orm";
|
|
12
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
13
|
+
function text(name) {
|
|
14
|
+
const v = env[name];
|
|
15
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
16
|
+
}
|
|
17
|
+
function dialectFromUrl(url) {
|
|
18
|
+
if (/^postgres(ql)?:/i.test(url))
|
|
19
|
+
return "postgres";
|
|
20
|
+
if (/^mysql2?:/i.test(url))
|
|
21
|
+
return "mysql";
|
|
22
|
+
return "sqlite";
|
|
23
|
+
}
|
|
24
|
+
/** Dialect đang được cấu hình cho website này. Viết schema bằng đúng dialect này. */
|
|
25
|
+
export function databaseDialect() {
|
|
26
|
+
const configured = text("HODELA_DB_DIALECT");
|
|
27
|
+
if (configured === "postgres" || configured === "mysql" || configured === "sqlite") {
|
|
28
|
+
return configured;
|
|
29
|
+
}
|
|
30
|
+
const url = text("DATABASE_URL");
|
|
31
|
+
return url ? dialectFromUrl(url) : "sqlite";
|
|
32
|
+
}
|
|
33
|
+
// Migration của website được đóng gói vào bundle lúc build: `src/lib/db.server.ts` của website chạy `import.meta.glob("/db/migrations/*.sql")`
|
|
34
|
+
// rồi đưa vào `createDbAccess`. Với D1, Hodela áp trước mỗi lần build. Với database ngoài, website tự áp lúc chạy (một lần mỗi instance).
|
|
35
|
+
let migrationFiles = {};
|
|
36
|
+
/** 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 đó. */
|
|
37
|
+
export function createDbAccess(files) {
|
|
38
|
+
migrationFiles = files;
|
|
39
|
+
return { getDb, databaseDialect };
|
|
40
|
+
}
|
|
41
|
+
function migrations() {
|
|
42
|
+
return Object.entries(migrationFiles)
|
|
43
|
+
.map(([path, body]) => ({ name: path.split("/").pop() ?? path, sql: body }))
|
|
44
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
}
|
|
46
|
+
async function sha256(textValue) {
|
|
47
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(textValue));
|
|
48
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
49
|
+
}
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
async function execute(db, dialect, query) {
|
|
52
|
+
if (dialect === "sqlite") {
|
|
53
|
+
const out = await db.all(query);
|
|
54
|
+
return Array.isArray(out) ? out : [];
|
|
55
|
+
}
|
|
56
|
+
const out = await db.execute(query);
|
|
57
|
+
if (dialect === "mysql")
|
|
58
|
+
return Array.isArray(out?.[0]) ? out[0] : [];
|
|
59
|
+
return Array.isArray(out) ? out : (out?.rows ?? []);
|
|
60
|
+
}
|
|
61
|
+
let migrated;
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
async function runMigrations(db, dialect) {
|
|
64
|
+
const files = migrations();
|
|
65
|
+
if (files.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
await execute(db, dialect, sql.raw("CREATE TABLE IF NOT EXISTS _hodela_migrations (name VARCHAR(255) PRIMARY KEY, checksum VARCHAR(64) NOT NULL)"));
|
|
68
|
+
const rows = (await execute(db, dialect, sql.raw("SELECT name, checksum FROM _hodela_migrations")));
|
|
69
|
+
const applied = new Map(rows.map((r) => [r.name, r.checksum]));
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const checksum = await sha256(file.sql);
|
|
72
|
+
const prior = applied.get(file.name);
|
|
73
|
+
if (prior !== undefined) {
|
|
74
|
+
if (prior !== checksum) {
|
|
75
|
+
throw new Error(`Migration ${file.name} đã áp rồi nhưng nội dung bị sửa. Tạo file migration mới thay vì sửa file cũ.`);
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const statement of splitSqlStatements(file.sql, dialect)) {
|
|
80
|
+
await execute(db, dialect, sql.raw(statement.sql));
|
|
81
|
+
}
|
|
82
|
+
await execute(db, dialect, sql.raw(`INSERT INTO _hodela_migrations (name, checksum) VALUES ('${file.name.replace(/'/g, "''")}', '${checksum}')`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 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:
|
|
87
|
+
* kết nối TCP không dùng lại giữa các request trên Cloudflare Workers).
|
|
88
|
+
*
|
|
89
|
+
* const db = await getDb(); // sqlite: D1 hoặc libsql
|
|
90
|
+
* const db = await getDb<"postgres">(); // khi website dùng Postgres
|
|
91
|
+
*/
|
|
92
|
+
export async function getDb() {
|
|
93
|
+
const url = text("DATABASE_URL");
|
|
94
|
+
const dialect = databaseDialect();
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
let db;
|
|
97
|
+
if (!url) {
|
|
98
|
+
const d1 = env["PROJECT_DB"];
|
|
99
|
+
if (!d1) {
|
|
100
|
+
throw new Error("Website chưa có database. Thêm file db/migrations/0001_*.sql để Hodela tạo database, hoặc cấu hình database của bạn trong Cài đặt → Cơ sở dữ liệu.");
|
|
101
|
+
}
|
|
102
|
+
const { drizzle } = await import("drizzle-orm/d1");
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
db = drizzle(d1);
|
|
105
|
+
}
|
|
106
|
+
else if (dialect === "postgres") {
|
|
107
|
+
const { default: postgres } = await import("postgres");
|
|
108
|
+
const { drizzle } = await import("drizzle-orm/postgres-js");
|
|
109
|
+
db = drizzle(postgres(url, { max: 1, prepare: false }));
|
|
110
|
+
}
|
|
111
|
+
else if (dialect === "mysql") {
|
|
112
|
+
const mysql = await import("mysql2/promise");
|
|
113
|
+
const { drizzle } = await import("drizzle-orm/mysql2");
|
|
114
|
+
db = drizzle(mysql.createPool(url));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const { createClient } = await import("@libsql/client/web");
|
|
118
|
+
const { drizzle } = await import("drizzle-orm/libsql/web");
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const authToken = parsed.searchParams.get("authToken") ?? undefined;
|
|
121
|
+
parsed.searchParams.delete("authToken");
|
|
122
|
+
db = drizzle(createClient({ url: parsed.toString().replace(/\?$/, ""), ...(authToken ? { authToken } : {}) }));
|
|
123
|
+
}
|
|
124
|
+
// D1 mặc định: Hodela đã áp migration lúc build. Database ngoài: website tự áp.
|
|
125
|
+
if (url) {
|
|
126
|
+
migrated ??= runMigrations(db, dialect).catch((error) => {
|
|
127
|
+
migrated = undefined;
|
|
128
|
+
throw error;
|
|
129
|
+
});
|
|
130
|
+
await migrated;
|
|
131
|
+
}
|
|
132
|
+
return db;
|
|
133
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Điểm vào worker của website: PIN/IP/chặn bot trước khi chạy ứng dụng, chèn analytics và badge sau khi chạy, trang lỗi thống nhất.
|
|
2
|
+
// `src/server.ts` của website chỉ gọi `createHodelaServerEntry(() => import("@tanstack/react-start/server-entry"))`.
|
|
3
|
+
import "./error-capture.js";
|
|
4
|
+
import { consumeLastCapturedError } from "./error-capture.js";
|
|
5
|
+
import { badgeHtml, enforceAccess, parseAccessConfig } from "../access.js";
|
|
6
|
+
import { analyticsHeadSnippet, parseAnalyticsConfig } from "../analytics.js";
|
|
7
|
+
import { renderErrorPage } from "../error-page.js";
|
|
8
|
+
// h3 swallows in-handler throws into a normal 500 Response with body
|
|
9
|
+
// {"unhandled":true,"message":"HTTPError"} — try/catch alone never fires for those.
|
|
10
|
+
async function normalizeCatastrophicSsrResponse(response) {
|
|
11
|
+
if (response.status < 500)
|
|
12
|
+
return response;
|
|
13
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
14
|
+
if (!contentType.includes("application/json"))
|
|
15
|
+
return response;
|
|
16
|
+
const body = await response.clone().text();
|
|
17
|
+
if (!isH3SwallowedErrorBody(body))
|
|
18
|
+
return response;
|
|
19
|
+
console.error(consumeLastCapturedError() ?? new Error(`h3 swallowed SSR error: ${body}`));
|
|
20
|
+
return new Response(renderErrorPage(), {
|
|
21
|
+
status: 500,
|
|
22
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function isH3SwallowedErrorBody(body) {
|
|
26
|
+
try {
|
|
27
|
+
const payload = JSON.parse(body);
|
|
28
|
+
return payload.unhandled === true && payload.message === "HTTPError";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Mã đo lường và badge chủ web bật/được áp trong cài đặt: chỉ chèn khi đã xuất bản, không chèn ở bản xem trước.
|
|
35
|
+
function withInjections(response, env) {
|
|
36
|
+
const bindings = (env ?? {});
|
|
37
|
+
if (bindings["HODELA_ENV"] !== "production")
|
|
38
|
+
return response;
|
|
39
|
+
const type = response.headers.get("content-type") ?? "";
|
|
40
|
+
if (!type.includes("text/html"))
|
|
41
|
+
return response;
|
|
42
|
+
const snippet = analyticsHeadSnippet(parseAnalyticsConfig(bindings["HODELA_ANALYTICS"]));
|
|
43
|
+
const badge = badgeHtml(parseAccessConfig(bindings["HODELA_ACCESS"]));
|
|
44
|
+
const Rewriter = globalThis.HTMLRewriter;
|
|
45
|
+
if ((!snippet && !badge) || !Rewriter)
|
|
46
|
+
return response;
|
|
47
|
+
let rewriter = new Rewriter();
|
|
48
|
+
if (snippet) {
|
|
49
|
+
rewriter = rewriter.on("head", {
|
|
50
|
+
element(el) {
|
|
51
|
+
el.append(snippet, { html: true });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (badge) {
|
|
56
|
+
rewriter = rewriter.on("body", {
|
|
57
|
+
element(el) {
|
|
58
|
+
el.append(badge, { html: true });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return rewriter.transform(response);
|
|
63
|
+
}
|
|
64
|
+
// PIN, danh sách IP và chặn bot của site đã xuất bản; chặn trước khi chạy ứng dụng.
|
|
65
|
+
async function blockedByAccess(request, env) {
|
|
66
|
+
const bindings = (env ?? {});
|
|
67
|
+
if (bindings["HODELA_ENV"] !== "production")
|
|
68
|
+
return null;
|
|
69
|
+
const secret = typeof bindings["HODELA_SESSION_SECRET"] === "string" ? bindings["HODELA_SESSION_SECRET"] : "";
|
|
70
|
+
return enforceAccess(request, parseAccessConfig(bindings["HODELA_ACCESS"]), secret);
|
|
71
|
+
}
|
|
72
|
+
export function createHodelaServerEntry(loadServerEntry) {
|
|
73
|
+
let serverEntryPromise;
|
|
74
|
+
const getServerEntry = () => {
|
|
75
|
+
serverEntryPromise ??= loadServerEntry().then((m) => (m.default ?? m));
|
|
76
|
+
return serverEntryPromise;
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
async fetch(request, env, ctx) {
|
|
80
|
+
try {
|
|
81
|
+
const blocked = await blockedByAccess(request, env);
|
|
82
|
+
if (blocked)
|
|
83
|
+
return blocked;
|
|
84
|
+
const handler = await getServerEntry();
|
|
85
|
+
const response = await handler.fetch(request, env, ctx);
|
|
86
|
+
return withInjections(await normalizeCatastrophicSsrResponse(response), env);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(error);
|
|
90
|
+
return new Response(renderErrorPage(), {
|
|
91
|
+
status: 500,
|
|
92
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Biến môi trường do chủ web cấu hình trong Cài đặt → Environments (ví dụ RESEND_API_KEY).
|
|
3
|
+
* Trả về undefined nếu chưa cấu hình: code phải chạy được khi thiếu, đừng hardcode giá trị.
|
|
4
|
+
*/
|
|
5
|
+
export declare function serverEnv(name: string): string | undefined;
|
|
6
|
+
/** "preview" khi xem thử trong editor, "production" khi đã xuất bản. */
|
|
7
|
+
export declare function hodelaEnv(): "preview" | "production";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Chỉ chạy trên server (đuôi .server.ts). Import trong handler của createServerFn hoặc server route.
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
|
+
/**
|
|
4
|
+
* Biến môi trường do chủ web cấu hình trong Cài đặt → Environments (ví dụ RESEND_API_KEY).
|
|
5
|
+
* Trả về undefined nếu chưa cấu hình: code phải chạy được khi thiếu, đừng hardcode giá trị.
|
|
6
|
+
*/
|
|
7
|
+
export function serverEnv(name) {
|
|
8
|
+
const value = env[name];
|
|
9
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** "preview" khi xem thử trong editor, "production" khi đã xuất bản. */
|
|
12
|
+
export function hodelaEnv() {
|
|
13
|
+
return env["HODELA_ENV"] === "production" ? "production" : "preview";
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Captures the original Error out-of-band so server.ts can recover the stack
|
|
2
|
+
// when h3 has already swallovwed the throw into a generic 500 Response.
|
|
3
|
+
let lastCapturedError;
|
|
4
|
+
const TTL_MS = 5_000;
|
|
5
|
+
function record(error) {
|
|
6
|
+
lastCapturedError = { error, at: Date.now() };
|
|
7
|
+
}
|
|
8
|
+
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
|
|
9
|
+
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
|
|
10
|
+
// the failure detail stripped. Expand Error-like args into a string that keeps the
|
|
11
|
+
// message, stack, and the full cause chain.
|
|
12
|
+
const CAUSE_DEPTH_LIMIT = 5;
|
|
13
|
+
const DESCRIPTION_LENGTH_LIMIT = 8_000;
|
|
14
|
+
export function describeError(error) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
let current = error;
|
|
17
|
+
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
|
|
18
|
+
if (!(current instanceof Error)) {
|
|
19
|
+
parts.push(typeof current === "string" ? current : safeStringify(current));
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
const label = depth === 0 ? "" : "caused by: ";
|
|
23
|
+
const status = describeStatus(current);
|
|
24
|
+
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
|
|
25
|
+
current = current.cause;
|
|
26
|
+
}
|
|
27
|
+
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
|
|
28
|
+
}
|
|
29
|
+
function describeStatus(error) {
|
|
30
|
+
const { status, statusCode } = error;
|
|
31
|
+
const value = status ?? statusCode;
|
|
32
|
+
return typeof value === "number" ? ` (status ${value})` : "";
|
|
33
|
+
}
|
|
34
|
+
function safeStringify(value) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(value) ?? String(value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isErrorLike(value) {
|
|
43
|
+
return value instanceof Error;
|
|
44
|
+
}
|
|
45
|
+
// Wrap console.error so errors logged by any layer — including h3's internal
|
|
46
|
+
// unhandled-error logging, which this file cannot hook directly — are both
|
|
47
|
+
// recorded for consumeLastCapturedError and expanded before serialization.
|
|
48
|
+
const originalConsoleError = console.error.bind(console);
|
|
49
|
+
console.error = (...args) => {
|
|
50
|
+
const expanded = args.map((arg) => {
|
|
51
|
+
if (!isErrorLike(arg))
|
|
52
|
+
return arg;
|
|
53
|
+
record(arg);
|
|
54
|
+
return describeError(arg);
|
|
55
|
+
});
|
|
56
|
+
originalConsoleError(...expanded);
|
|
57
|
+
};
|
|
58
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
59
|
+
globalThis.addEventListener("error", (event) => record(event.error ?? event));
|
|
60
|
+
globalThis.addEventListener("unhandledrejection", (event) => record(event.reason));
|
|
61
|
+
}
|
|
62
|
+
export function consumeLastCapturedError() {
|
|
63
|
+
if (!lastCapturedError)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (Date.now() - lastCapturedError.at > TTL_MS) {
|
|
66
|
+
lastCapturedError = undefined;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const { error } = lastCapturedError;
|
|
70
|
+
lastCapturedError = undefined;
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { describeFileError, HodelaFileError } from "@hodela/file-js";
|
|
2
|
+
export declare function files(): {
|
|
3
|
+
put(body: import("@hodela/file-js").FileBody, opts?: import("@hodela/file-js").PutFileOptions): Promise<import("@hodela/file-js").PutFileResult>;
|
|
4
|
+
get: (id: string) => Promise<import("@hodela/file-js").HodelaFile>;
|
|
5
|
+
list: (opts?: {
|
|
6
|
+
limit?: number;
|
|
7
|
+
before?: string;
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
data: import("@hodela/file-js").HodelaFile[];
|
|
10
|
+
nextBefore: string | null;
|
|
11
|
+
}>;
|
|
12
|
+
usage: () => Promise<import("@hodela/file-js").FileUsage>;
|
|
13
|
+
signedUrl: (id: string, opts?: {
|
|
14
|
+
ttlSec?: number;
|
|
15
|
+
}) => Promise<import("@hodela/file-js").SignedUrl>;
|
|
16
|
+
delete(id: string): Promise<void>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Chỉ chạy trên server. Hodela quản lý file này: không sửa. Dùng trong server function:
|
|
2
|
+
//
|
|
3
|
+
// import { files } from "@hodela/site/server/files";
|
|
4
|
+
// const saved = await files().put(file); // file: File lấy từ FormData
|
|
5
|
+
// // saved.url dùng thẳng trong <img src> hoặc <a href>, lưu vào database cạnh bản ghi.
|
|
6
|
+
//
|
|
7
|
+
// Cùng một đoạn mã chạy ở mọi nơi:
|
|
8
|
+
// • Hodela (xem thử và đã xuất bản): kho file của nền tảng (R2) qua HODELA_API_URL / HODELA_API_KEY do Hodela cấp.
|
|
9
|
+
// • Máy dev (`pnpm dev`, không có hai biến trên): plugin Vite ghi ảnh vào public/hodela-media (@hodela/dev).
|
|
10
|
+
import { createHodelaFileClient } from "@hodela/file-js";
|
|
11
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
12
|
+
import { serverEnv } from "./env.js";
|
|
13
|
+
export { describeFileError, HodelaFileError } from "@hodela/file-js";
|
|
14
|
+
export function files() {
|
|
15
|
+
const apiUrl = serverEnv("HODELA_API_URL");
|
|
16
|
+
const apiKey = serverEnv("HODELA_API_KEY");
|
|
17
|
+
if (apiUrl && apiKey)
|
|
18
|
+
return createHodelaFileClient({ apiUrl, apiKey });
|
|
19
|
+
// Không có khóa của Hodela: đang chạy ở máy dev, dùng kho giả lập của Vite dev server (cùng origin với trang đang mở).
|
|
20
|
+
return createHodelaFileClient({ apiUrl: `${getRequestUrl().origin}/__hodela/dev`, apiKey: "dev" });
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createMailer, type MailProvider } from "../mailer.js";
|
|
2
|
+
export { escapeMailHtml, type MailInput, type MailProvider, type MailResult } from "../mailer.js";
|
|
3
|
+
export declare const sendMail: ReturnType<typeof createMailer>["sendMail"];
|
|
4
|
+
export declare const recentMail: ReturnType<typeof createMailer>["recentMail"];
|
|
5
|
+
export declare const notifyOwner: ReturnType<typeof createMailer>["notifyOwner"];
|
|
6
|
+
/** Cho giao diện quản lý biết thông báo email đang bật hay tắt (không lộ khóa). */
|
|
7
|
+
export declare function mailStatus(): {
|
|
8
|
+
provider: MailProvider;
|
|
9
|
+
notifyEmail: string | null;
|
|
10
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Chỉ chạy trên server. Hodela quản lý file này: không sửa. Xem giải thích trong "../mailer.js".
|
|
2
|
+
//
|
|
3
|
+
// import { notifyOwner, sendMail } from "@hodela/site/server/mailer";
|
|
4
|
+
// // Sau khi ĐÃ lưu dữ liệu vào database (không await lỗi, không để lỗi mail làm hỏng đơn):
|
|
5
|
+
// await notifyOwner({ subject: "Đơn hàng mới #123", text: "...", replyTo: khach.email });
|
|
6
|
+
import { createMailer, mailProvider } from "../mailer.js";
|
|
7
|
+
import { hodelaEnv, serverEnv } from "./env.js";
|
|
8
|
+
export { escapeMailHtml } from "../mailer.js";
|
|
9
|
+
const mailer = () => createMailer({ env: serverEnv, mode: hodelaEnv() });
|
|
10
|
+
export const sendMail = (input) => mailer().sendMail(input);
|
|
11
|
+
export const recentMail = (limit) => mailer().recentMail(limit);
|
|
12
|
+
export const notifyOwner = (input) => mailer().notifyOwner(input);
|
|
13
|
+
/** Cho giao diện quản lý biết thông báo email đang bật hay tắt (không lộ khóa). */
|
|
14
|
+
export function mailStatus() {
|
|
15
|
+
return { provider: mailProvider(serverEnv), notifyEmail: serverEnv("NOTIFY_EMAIL") ?? null };
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Chỉ chạy trên server. Gắn thuộc tính cookie phiên (xem session-cookie.ts) theo request hiện tại. Hodela quản lý file này: không sửa.
|
|
2
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
3
|
+
import { hodelaEnv } from "./env.js";
|
|
4
|
+
import { sessionCookieOptions } from "../session-cookie.js";
|
|
5
|
+
export function sessionCookie(maxAgeSeconds) {
|
|
6
|
+
return sessionCookieOptions(hodelaEnv(), getRequestUrl(), maxAgeSeconds);
|
|
7
|
+
}
|
package/dist/start.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** `src/start.ts` của website: `export const startInstance = createHodelaStart();` */
|
|
2
|
+
export declare const createHodelaStart: () => import("@tanstack/react-start").StartInstance<[], import("@tanstack/router-core").SSROption, readonly [import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>, import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>], []>;
|
package/dist/start.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createStart, createCsrfMiddleware, createMiddleware, } from "@tanstack/react-start";
|
|
2
|
+
import { renderErrorPage } from "./error-page.js";
|
|
3
|
+
const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
|
4
|
+
try {
|
|
5
|
+
return await next();
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error != null && typeof error === "object" && "statusCode" in error) {
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
console.error(error);
|
|
12
|
+
return new Response(renderErrorPage(), {
|
|
13
|
+
status: 500,
|
|
14
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
// Start installs this automatically when src/start.ts is absent; defining the
|
|
19
|
+
// file opts out, so re-add it explicitly to keep server functions protected
|
|
20
|
+
// from cross-site requests.
|
|
21
|
+
const csrfMiddleware = createCsrfMiddleware({
|
|
22
|
+
filter: (ctx) => ctx.handlerType === "serverFn",
|
|
23
|
+
});
|
|
24
|
+
/** `src/start.ts` của website: `export const startInstance = createHodelaStart();` */
|
|
25
|
+
export const createHodelaStart = () => createStart(() => ({
|
|
26
|
+
requestMiddleware: [errorMiddleware, csrfMiddleware],
|
|
27
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const env: Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hodela/site",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -38,6 +38,54 @@
|
|
|
38
38
|
"./seo": {
|
|
39
39
|
"types": "./dist/seo.d.ts",
|
|
40
40
|
"import": "./dist/seo.js"
|
|
41
|
+
},
|
|
42
|
+
"./admin-functions": {
|
|
43
|
+
"types": "./dist/admin.functions.d.ts",
|
|
44
|
+
"import": "./dist/admin.functions.js"
|
|
45
|
+
},
|
|
46
|
+
"./start": {
|
|
47
|
+
"types": "./dist/start.d.ts",
|
|
48
|
+
"import": "./dist/start.js"
|
|
49
|
+
},
|
|
50
|
+
"./react": {
|
|
51
|
+
"types": "./dist/react/index.d.ts",
|
|
52
|
+
"import": "./dist/react/index.js"
|
|
53
|
+
},
|
|
54
|
+
"./server/env": {
|
|
55
|
+
"types": "./dist/server/env.d.ts",
|
|
56
|
+
"import": "./dist/server/env.js"
|
|
57
|
+
},
|
|
58
|
+
"./server/session-cookie": {
|
|
59
|
+
"types": "./dist/server/session-cookie.d.ts",
|
|
60
|
+
"import": "./dist/server/session-cookie.js"
|
|
61
|
+
},
|
|
62
|
+
"./server/admin": {
|
|
63
|
+
"types": "./dist/server/admin.d.ts",
|
|
64
|
+
"import": "./dist/server/admin.js"
|
|
65
|
+
},
|
|
66
|
+
"./server/customer-auth": {
|
|
67
|
+
"types": "./dist/server/customer-auth.d.ts",
|
|
68
|
+
"import": "./dist/server/customer-auth.js"
|
|
69
|
+
},
|
|
70
|
+
"./server/mailer": {
|
|
71
|
+
"types": "./dist/server/mailer.d.ts",
|
|
72
|
+
"import": "./dist/server/mailer.js"
|
|
73
|
+
},
|
|
74
|
+
"./server/files": {
|
|
75
|
+
"types": "./dist/server/files.d.ts",
|
|
76
|
+
"import": "./dist/server/files.js"
|
|
77
|
+
},
|
|
78
|
+
"./server/db": {
|
|
79
|
+
"types": "./dist/server/db.d.ts",
|
|
80
|
+
"import": "./dist/server/db.js"
|
|
81
|
+
},
|
|
82
|
+
"./server/entry": {
|
|
83
|
+
"types": "./dist/server/entry.d.ts",
|
|
84
|
+
"import": "./dist/server/entry.js"
|
|
85
|
+
},
|
|
86
|
+
"./testing/cloudflare-workers": {
|
|
87
|
+
"types": "./dist/testing/cloudflare-workers.d.ts",
|
|
88
|
+
"import": "./dist/testing/cloudflare-workers.js"
|
|
41
89
|
}
|
|
42
90
|
},
|
|
43
91
|
"scripts": {
|
|
@@ -46,14 +94,26 @@
|
|
|
46
94
|
"test": "vitest run"
|
|
47
95
|
},
|
|
48
96
|
"peerDependencies": {
|
|
49
|
-
"@tanstack/react-
|
|
97
|
+
"@tanstack/react-router": ">=1.100.0",
|
|
98
|
+
"@tanstack/react-start": ">=1.100.0",
|
|
99
|
+
"drizzle-orm": ">=0.39.0",
|
|
100
|
+
"react": ">=19.0.0",
|
|
101
|
+
"zod": ">=3.25.0"
|
|
50
102
|
},
|
|
51
103
|
"devDependencies": {
|
|
104
|
+
"@libsql/client": "^0.18.0",
|
|
105
|
+
"@tanstack/react-router": "1.170.46",
|
|
52
106
|
"@tanstack/react-start": "1.169.4",
|
|
53
107
|
"@types/node": "^22.16.5",
|
|
108
|
+
"@types/react": "^19.2.0",
|
|
109
|
+
"drizzle-orm": "^0.39.1",
|
|
110
|
+
"mysql2": "^3.24.6",
|
|
111
|
+
"postgres": "^4.0.0",
|
|
112
|
+
"react": "^19.2.0",
|
|
54
113
|
"typescript": "^5.8.3",
|
|
55
114
|
"vite": "^8.3.5",
|
|
56
|
-
"vitest": "^2.1.8"
|
|
115
|
+
"vitest": "^2.1.8",
|
|
116
|
+
"zod": "^3.25.76"
|
|
57
117
|
},
|
|
58
118
|
"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
119
|
"license": "MIT",
|
|
@@ -61,6 +121,12 @@
|
|
|
61
121
|
"access": "public"
|
|
62
122
|
},
|
|
63
123
|
"dependencies": {
|
|
64
|
-
"@hodela/email-js": "0.1.1"
|
|
124
|
+
"@hodela/email-js": "0.1.1",
|
|
125
|
+
"@hodela/file-js": "0.1.0"
|
|
126
|
+
},
|
|
127
|
+
"peerDependenciesMeta": {
|
|
128
|
+
"drizzle-orm": {
|
|
129
|
+
"optional": true
|
|
130
|
+
}
|
|
65
131
|
}
|
|
66
132
|
}
|