first commit
This commit is contained in:
@@ -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
|
||||
|
||||
無。
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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-demand(prerender = false)嘅頁面/endpoint 用。
|
||||
*/
|
||||
export function getEnv(): AppEnv {
|
||||
return env as unknown as AppEnv;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user