@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`: trình duyệt chấp nhận cookie `Secure` kể cả qua http. */
|
|
2
|
+
export declare function isLocalHost(hostname: string): boolean;
|
|
3
|
+
export type SessionCookieOptions = {
|
|
4
|
+
httpOnly: true;
|
|
5
|
+
path: "/";
|
|
6
|
+
maxAge: number;
|
|
7
|
+
secure: boolean;
|
|
8
|
+
sameSite: "lax" | "none";
|
|
9
|
+
partitioned?: true;
|
|
10
|
+
};
|
|
11
|
+
/** Thuần (dễ kiểm thử): chọn thuộc tính theo môi trường, giao thức và host của request. */
|
|
12
|
+
export declare function sessionCookieOptions(env: "preview" | "production", url: {
|
|
13
|
+
protocol: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
}, maxAgeSeconds: number): SessionCookieOptions;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Thuộc tính cookie phiên dùng chung cho quản lý và khách. Hodela quản lý file này: không sửa.
|
|
2
|
+
//
|
|
3
|
+
// Preview chạy trong iframe của editor (khác site với editor nên cookie `lax` bị coi là bên thứ ba và bị chặn: đăng nhập
|
|
4
|
+
// báo đúng nhưng không giữ được phiên, trong khi mở tab riêng thì được). Vì vậy preview dùng `SameSite=None; Secure;
|
|
5
|
+
// Partitioned` (CHIPS: cookie gắn với trang editor chứa iframe nên không phải cookie theo dõi xuyên site). Production không
|
|
6
|
+
// bị nhúng nên giữ `lax`, đây là mặc định an toàn nhất chống CSRF.
|
|
7
|
+
/** `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`: trình duyệt chấp nhận cookie `Secure` kể cả qua http. */
|
|
8
|
+
export function isLocalHost(hostname) {
|
|
9
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
10
|
+
}
|
|
11
|
+
/** Thuần (dễ kiểm thử): chọn thuộc tính theo môi trường, giao thức và host của request. */
|
|
12
|
+
export function sessionCookieOptions(env, url, maxAgeSeconds) {
|
|
13
|
+
const secureContext = url.protocol === "https:" || isLocalHost(url.hostname);
|
|
14
|
+
if (env !== "production" && secureContext) {
|
|
15
|
+
return { httpOnly: true, path: "/", maxAge: maxAgeSeconds, secure: true, sameSite: "none", partitioned: true };
|
|
16
|
+
}
|
|
17
|
+
return { httpOnly: true, path: "/", maxAge: maxAgeSeconds, secure: env === "production", sameSite: "lax" };
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tách câu lệnh SQL cho migration. MỘT thuật toán duy nhất cho mọi nơi: kiểm tra, áp lên D1, áp lên
|
|
3
|
+
* database ngoài. File này được sao nguyên văn vào starter (`src/lib/sql-split.ts`) để website tự áp
|
|
4
|
+
* migration lúc chạy; test so khớp hai bản trên cùng một bộ mẫu. Không import gì để sao chép được.
|
|
5
|
+
*/
|
|
6
|
+
export type SqlDialect = "sqlite" | "postgres" | "mysql";
|
|
7
|
+
export type SqlToken = {
|
|
8
|
+
kind: "word" | "ident" | "string" | "number" | "punct";
|
|
9
|
+
/** word: chữ HOA; ident: tên đã bỏ dấu nháy (giữ nguyên chữ hoa/thường); còn lại: văn bản gốc. */
|
|
10
|
+
text: string;
|
|
11
|
+
line: number;
|
|
12
|
+
};
|
|
13
|
+
export type SqlStatement = {
|
|
14
|
+
/** Câu lệnh đã bỏ chú thích, không có dấu `;` cuối. Đây chính là chuỗi được chạy. */
|
|
15
|
+
sql: string;
|
|
16
|
+
tokens: SqlToken[];
|
|
17
|
+
/** Dòng bắt đầu (1-based) trong file gốc, để báo lỗi. */
|
|
18
|
+
line: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class SqlSyntaxError extends Error {
|
|
21
|
+
readonly line: number;
|
|
22
|
+
constructor(message: string, line: number);
|
|
23
|
+
}
|
|
24
|
+
export declare function splitSqlStatements(source: string, dialect?: SqlDialect): SqlStatement[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tách câu lệnh SQL cho migration. MỘT thuật toán duy nhất cho mọi nơi: kiểm tra, áp lên D1, áp lên
|
|
3
|
+
* database ngoài. File này được sao nguyên văn vào starter (`src/lib/sql-split.ts`) để website tự áp
|
|
4
|
+
* migration lúc chạy; test so khớp hai bản trên cùng một bộ mẫu. Không import gì để sao chép được.
|
|
5
|
+
*/
|
|
6
|
+
export class SqlSyntaxError extends Error {
|
|
7
|
+
line;
|
|
8
|
+
constructor(message, line) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.line = line;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const WORD_START = /[A-Za-z_\u0080-]/;
|
|
14
|
+
const WORD_PART = /[A-Za-z0-9_$\u0080-]/;
|
|
15
|
+
export function splitSqlStatements(source, dialect = "sqlite") {
|
|
16
|
+
const out = [];
|
|
17
|
+
let text = "";
|
|
18
|
+
let tokens = [];
|
|
19
|
+
let startLine = 0;
|
|
20
|
+
let line = 1;
|
|
21
|
+
let i = 0;
|
|
22
|
+
const n = source.length;
|
|
23
|
+
const push = (t, raw) => {
|
|
24
|
+
if (!startLine)
|
|
25
|
+
startLine = t.line;
|
|
26
|
+
tokens.push(t);
|
|
27
|
+
text += raw;
|
|
28
|
+
};
|
|
29
|
+
const flush = () => {
|
|
30
|
+
const sql = text.trim();
|
|
31
|
+
if (sql)
|
|
32
|
+
out.push({ sql, tokens, line: startLine || line });
|
|
33
|
+
text = "";
|
|
34
|
+
tokens = [];
|
|
35
|
+
startLine = 0;
|
|
36
|
+
};
|
|
37
|
+
const sep = () => {
|
|
38
|
+
if (text && !text.endsWith(" "))
|
|
39
|
+
text += " ";
|
|
40
|
+
};
|
|
41
|
+
while (i < n) {
|
|
42
|
+
const c = source[i];
|
|
43
|
+
const next = source[i + 1];
|
|
44
|
+
if (c === "\n") {
|
|
45
|
+
line += 1;
|
|
46
|
+
sep();
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
51
|
+
sep();
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === "-" && next === "-") {
|
|
56
|
+
while (i < n && source[i] !== "\n")
|
|
57
|
+
i += 1;
|
|
58
|
+
sep();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (c === "/" && next === "*") {
|
|
62
|
+
const startedAt = line;
|
|
63
|
+
i += 2;
|
|
64
|
+
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
|
|
65
|
+
if (source[i] === "\n")
|
|
66
|
+
line += 1;
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
if (i >= n)
|
|
70
|
+
throw new SqlSyntaxError("Chú thích /* không được đóng", startedAt);
|
|
71
|
+
i += 2;
|
|
72
|
+
sep();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c === ";") {
|
|
76
|
+
flush();
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Chuỗi '...' (và E'...' của PostgreSQL; MySQL cho phép \ thoát ký tự).
|
|
81
|
+
if (c === "'") {
|
|
82
|
+
const startedAt = line;
|
|
83
|
+
const prev = source[i - 1];
|
|
84
|
+
const backslash = dialect === "mysql" || (dialect === "postgres" && (prev === "E" || prev === "e"));
|
|
85
|
+
let j = i + 1;
|
|
86
|
+
let closed = false;
|
|
87
|
+
while (j < n) {
|
|
88
|
+
const d = source[j];
|
|
89
|
+
if (d === "\n")
|
|
90
|
+
line += 1;
|
|
91
|
+
if (backslash && d === "\\") {
|
|
92
|
+
j += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (d === "'") {
|
|
96
|
+
if (source[j + 1] === "'") {
|
|
97
|
+
j += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
closed = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
j += 1;
|
|
104
|
+
}
|
|
105
|
+
if (!closed)
|
|
106
|
+
throw new SqlSyntaxError("Chuỗi ' không được đóng", startedAt);
|
|
107
|
+
const raw = source.slice(i, j + 1);
|
|
108
|
+
push({ kind: "string", text: raw, line: startedAt }, raw);
|
|
109
|
+
i = j + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Tên có nháy: "..." (chuẩn), `...` (MySQL).
|
|
113
|
+
if (c === '"' || c === "`") {
|
|
114
|
+
const startedAt = line;
|
|
115
|
+
let j = i + 1;
|
|
116
|
+
let name = "";
|
|
117
|
+
let closed = false;
|
|
118
|
+
while (j < n) {
|
|
119
|
+
const d = source[j];
|
|
120
|
+
if (d === "\n")
|
|
121
|
+
line += 1;
|
|
122
|
+
if (d === c) {
|
|
123
|
+
if (source[j + 1] === c) {
|
|
124
|
+
name += c;
|
|
125
|
+
j += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
closed = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
name += d;
|
|
132
|
+
j += 1;
|
|
133
|
+
}
|
|
134
|
+
if (!closed)
|
|
135
|
+
throw new SqlSyntaxError(`Tên ${c}...${c} không được đóng`, startedAt);
|
|
136
|
+
const raw = source.slice(i, j + 1);
|
|
137
|
+
push({ kind: "ident", text: name, line: startedAt }, raw);
|
|
138
|
+
i = j + 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Chuỗi dollar-quote của PostgreSQL: $$...$$ hoặc $tag$...$tag$.
|
|
142
|
+
if (c === "$" && dialect === "postgres") {
|
|
143
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(source.slice(i, i + 80));
|
|
144
|
+
if (m) {
|
|
145
|
+
const startedAt = line;
|
|
146
|
+
const tag = m[0];
|
|
147
|
+
const end = source.indexOf(tag, i + tag.length);
|
|
148
|
+
if (end < 0)
|
|
149
|
+
throw new SqlSyntaxError(`Chuỗi ${tag} không được đóng`, startedAt);
|
|
150
|
+
const raw = source.slice(i, end + tag.length);
|
|
151
|
+
for (const ch of raw)
|
|
152
|
+
if (ch === "\n")
|
|
153
|
+
line += 1;
|
|
154
|
+
push({ kind: "string", text: raw, line: startedAt }, raw);
|
|
155
|
+
i = end + tag.length;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (WORD_START.test(c)) {
|
|
160
|
+
let j = i + 1;
|
|
161
|
+
while (j < n && WORD_PART.test(source[j]))
|
|
162
|
+
j += 1;
|
|
163
|
+
const raw = source.slice(i, j);
|
|
164
|
+
push({ kind: "word", text: raw.toUpperCase(), line }, raw);
|
|
165
|
+
i = j;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/[0-9]/.test(c)) {
|
|
169
|
+
let j = i + 1;
|
|
170
|
+
while (j < n && /[0-9A-Za-z_.]/.test(source[j]))
|
|
171
|
+
j += 1;
|
|
172
|
+
const raw = source.slice(i, j);
|
|
173
|
+
push({ kind: "number", text: raw, line }, raw);
|
|
174
|
+
i = j;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
push({ kind: "punct", text: c, line }, c);
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
flush();
|
|
181
|
+
return out;
|
|
182
|
+
}
|