first commit

This commit is contained in:
2026-09-11 15:49:41 +08:00
commit 5b69bc818a
98 changed files with 30551 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# src/lib/AGENTS.md
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。
## Ownership
- 擁有 `src/lib/` 四個檔案。
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## Local Contracts
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
## Work Guidance
- 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts`
- Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。
## Verification
- `npm run build`
- 認證改動:`npm run dev` 測登入/登出/未登入導向。
## Child DOX Index
無。
+67
View File
@@ -0,0 +1,67 @@
export const SESSION_COOKIE = "admin_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 日
const enc = new TextEncoder();
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
function toBase64Url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function sign(data: string, secret: string): Promise<string> {
const key = await hmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
return toBase64Url(sig);
}
/** 長度相等時逐字元 XOR,避免 timing attack */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* 比較密碼。兩邊各自做一次 HMAC 先比,
* 避免直接比原文洩漏長度同前綴(timing attack)。
*/
export async function checkPassword(input: string, secret: string): Promise<boolean> {
return safeEqual(await sign(input, secret), await sign(secret, secret));
}
export async function createSession(secret: string): Promise<string> {
const exp = String(Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SEC);
return `${exp}.${await sign(exp, secret)}`;
}
export async function verifySession(
cookie: string | undefined,
secret: string,
): Promise<boolean> {
if (!cookie) return false;
const [exp, sig] = cookie.split(".");
if (!exp || !sig) return false;
if (Number(exp) * 1000 < Date.now()) return false;
return safeEqual(await sign(exp, secret), sig);
}
export const sessionCookieOptions = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
secure: true,
maxAge: SESSION_MAX_AGE_SEC,
};
+8
View File
@@ -0,0 +1,8 @@
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../db/schema";
export function getDb(env: D1Database): DrizzleD1Database<typeof schema> {
return drizzle(env, { schema });
}
export { schema };
+16
View File
@@ -0,0 +1,16 @@
import { env } from "cloudflare:workers";
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
};
/**
* Astro v6 起,`Astro.locals.runtime.env` 已經移除,
* 一律改用 `cloudflare:workers` 嘅 env。
* 只可以喺 on-demandprerender = false)嘅頁面/endpoint 用。
*/
export function getEnv(): AppEnv {
return env as unknown as AppEnv;
}
+32
View File
@@ -0,0 +1,32 @@
import { marked } from "marked";
marked.setOptions({ gfm: true, breaks: false });
/** markdown → HTML。內容係你自己寫嘅(受信任),所以唔做 sanitize。 */
export function renderMarkdown(md: string): string {
return marked.parse(md ?? "", { async: false }) as string;
}
/** 由標題自動產生 slug:去符號、空格轉 -,保留中文字 */
export function slugify(input: string): string {
return (
input
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || `post-${Date.now()}`
);
}
/** 攞文章開頭做 SEO description(如果冇手填) */
export function autoExcerpt(md: string, len = 150): string {
const plain = md
.replace(/^#+\s.*$/gm, "")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_`>#-]/g, "")
.replace(/\s+/g, " ")
.trim();
return plain.length > len ? plain.slice(0, len) + "…" : plain;
}