@hodela/site 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/admin.functions.d.ts +18 -0
- package/dist/admin.functions.js +17 -0
- package/dist/customer-auth.js +1 -1
- package/dist/email-client.d.ts +55 -0
- package/dist/email-client.js +108 -0
- package/dist/error-page.d.ts +1 -0
- package/dist/error-page.js +30 -0
- package/dist/file-client.d.ts +87 -0
- package/dist/file-client.js +117 -0
- package/dist/mailer.d.ts +1 -1
- package/dist/mailer.js +2 -2
- package/dist/react/admin-gate.d.ts +25 -0
- package/dist/react/admin-gate.js +62 -0
- package/dist/react/error-reporting.d.ts +21 -0
- package/dist/react/error-reporting.js +43 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +2 -0
- package/dist/server/admin.d.ts +10 -0
- package/dist/server/admin.js +61 -0
- package/dist/server/customer-auth.d.ts +11 -0
- package/dist/server/customer-auth.js +45 -0
- package/dist/server/db.d.ts +20 -0
- package/dist/server/db.js +133 -0
- package/dist/server/entry.d.ts +4 -0
- package/dist/server/entry.js +97 -0
- package/dist/server/env.d.ts +7 -0
- package/dist/server/env.js +14 -0
- package/dist/server/error-capture.d.ts +2 -0
- package/dist/server/error-capture.js +72 -0
- package/dist/server/files.d.ts +17 -0
- package/dist/server/files.js +21 -0
- package/dist/server/mailer.d.ts +10 -0
- package/dist/server/mailer.js +16 -0
- package/dist/server/session-cookie.d.ts +2 -0
- package/dist/server/session-cookie.js +7 -0
- package/dist/start.d.ts +2 -0
- package/dist/start.js +27 -0
- package/dist/testing/cloudflare-workers.d.ts +1 -0
- package/dist/testing/cloudflare-workers.js +3 -0
- package/package.json +75 -5
|
@@ -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 "../file-client.js";
|
|
2
|
+
export declare function files(): {
|
|
3
|
+
put(body: import("../file-client.js").FileBody, opts?: import("../file-client.js").PutFileOptions): Promise<import("../file-client.js").PutFileResult>;
|
|
4
|
+
get: (id: string) => Promise<import("../file-client.js").HodelaFile>;
|
|
5
|
+
list: (opts?: {
|
|
6
|
+
limit?: number;
|
|
7
|
+
before?: string;
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
data: import("../file-client.js").HodelaFile[];
|
|
10
|
+
nextBefore: string | null;
|
|
11
|
+
}>;
|
|
12
|
+
usage: () => Promise<import("../file-client.js").FileUsage>;
|
|
13
|
+
signedUrl: (id: string, opts?: {
|
|
14
|
+
ttlSec?: number;
|
|
15
|
+
}) => Promise<import("../file-client.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 "../file-client.js";
|
|
11
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
12
|
+
import { serverEnv } from "./env.js";
|
|
13
|
+
export { describeFileError, HodelaFileError } from "../file-client.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>;
|