Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8daf341
fix(init): allow first-time setup on an empty but readable backend
PIKACHUIM Sep 17, 2026
1dba9ad
feat(init): surface storage problems and reject invalid driver/format…
PIKACHUIM Sep 17, 2026
75de3fe
Merge origin/main into fix/init-setup-empty-store
PIKACHUIM Sep 18, 2026
d927366
chore(edgeone): refresh cloud-functions artifact after merging main
PIKACHUIM Sep 18, 2026
5561120
feat(store): report which driver auto-detection would pick
PIKACHUIM Sep 18, 2026
7e9b83a
fix(store): degrade to the auto-detected backend instead of 503-ing t…
PIKACHUIM Sep 18, 2026
2a50902
chore(jwt): drop the noisy short-secret warning
PIKACHUIM Sep 18, 2026
c07d51b
fix(store): honour DB_DRIVER_STRICT from process.env too
PIKACHUIM Sep 18, 2026
fd4ee8a
chore(edgeone): refresh cloud-functions artifact for the driver fallback
PIKACHUIM Sep 18, 2026
2df1866
refactor(store): an explicitly configured driver never falls back
PIKACHUIM Sep 18, 2026
46ab64e
chore(edgeone): refresh cloud-functions artifact after removing the f…
PIKACHUIM Sep 18, 2026
38032d6
fix(worker): break the KV proxy probe self-call loop
PIKACHUIM Sep 18, 2026
8faa483
refactor(diagnostics): a one-line reason and one fix line in the setu…
PIKACHUIM Sep 18, 2026
160a6d4
chore(edgeone): refresh cloud-functions artifact
PIKACHUIM Sep 18, 2026
b26a372
Merge branch 'main' into fix/init-setup-empty-store
PIKACHUIM Sep 18, 2026
cfd4568
fix(storage): 精确化存储组合校验、统一 JWT 密钥策略并修复自动生成不生效
PIKACHUIM Sep 18, 2026
3d3dae3
fix(ci): remove docs
PIKACHUIM Sep 18, 2026
e2dfc09
Merge remote-tracking branch 'origin/main' into _conflict-check
PIKACHUIM Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,29 +171,40 @@ pnpm run deploy:worker
**推荐配置组合:**
```bash
# Cloudflare Workers + D1(推荐)
DB_FORMAT=sql
DB_DRIVER=d1
DB_FORMAT=sql # 也可用 map / key
DB_DRIVER=d1 # 需在 wrangler.jsonc 的 d1_databases 里绑定名为 DB

# EdgeOne + Blob(推荐,零配置)
DB_FORMAT=map
DB_FORMAT=map # 或 key
DB_DRIVER=blob

# EdgeOne / Cloudflare KV(自动适配环境)
DB_FORMAT=map
DB_DRIVER=kv
# Cloudflare Workers + KV(必须先绑定 KV,见下方【方案 A】)
DB_FORMAT=map # 或 key
DB_DRIVER=kv # 需打开 wrangler.jsonc 的 kv_namespaces,绑定名必须恰好是 KV;
# 未绑定却显式写 kv 会直接报错(不做回退)

# Cloudflare KV(高频读写,需绑定
DB_FORMAT=key
# EdgeOne Node 云函数 + KV(还需额外部署 Edge Function 代理
DB_FORMAT=map # 或 key
DB_DRIVER=kv
EO_KV_URLS=https://<你的部署域名> # 代理地址(也可由请求 origin 自动注入)
JWT_SECRET=<32 字符以上> # 代理鉴权,需与 Edge Function 侧一致

# 远程访问 Cloudflare KV
# 远程访问 Cloudflare KV(HTTP API,无需 binding)
DB_FORMAT=key
DB_DRIVER=cfkv
CF_ACCOUNT=your_account_id
CF_KV_UUID=your_namespace_id
CF_API_KEY=your_api_token
```

> 不确定用哪个就保持 `DB_DRIVER=auto`(默认,自动探测)。
> 显式指定驱动时**不做回退**:该驱动不可用会直接拒绝请求并给出可操作原因(含
> 「自动探测会选哪个驱动」,照抄即可),`/api/public/env_check` 与
> `/api/public/init_status` 也会显示同样的原因和一行修复建议,
> 避免「以为在用 KV、实际写进了别的后端」。
> 非法「驱动 × 格式」组合(如 `DB_FORMAT=sql` + `DB_DRIVER=kv`)同样只报错,
> 不会自动改驱动或格式。

**向后兼容:**
- `DB_DRIVER=json` 自动转换为 `DB_FORMAT=map` + 自动检测驱动

Expand Down
209 changes: 113 additions & 96 deletions cloud-functions/[[default]].js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"description": "Storage format: `map` (whole JSON), `key` (per-key), `sql` (relational, Go-compatible). Defaults to `map`."
},
"DB_DRIVER": {
"description": "Storage driver: `auto` (default), `blob`, `cfkv`, `kv`, `d1`, `do`, `mysql`. Leave as `auto` unless you need to force one."
"description": "Storage driver: `auto` (default), `blob`, `cfkv`, `kv`, `d1`, `do`, `mysql`. Leave as `auto` unless you need to force one. An explicitly configured driver is never replaced automatically: if it is unavailable, the app reports a configuration error (including which driver auto-detection would have picked) instead of writing to another backend. Invalid driver/format pairs are reported the same way."
}
}
},
Expand Down
10 changes: 9 additions & 1 deletion readmes/README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ pnpm run deploy:worker
- `sql`: Relational database table format, fully compatible with Go backend, suitable for D1/MySQL

**DB_DRIVER** (Database Driver)
- `auto` (default): Auto-detect available drivers (priority: blobcfkv → kv → d1)
- `auto` (default): Auto-detect available drivers (priority: mysqld1 → kv → cfkv → blob → do)
- `blob`: Tencent EdgeOne Blob / Alibaba ESA Blob
- `cfkv`: Cloudflare KV REST API (requires `CF_ACCOUNT`, `CF_KV_UUID`, `CF_API_KEY`)
- `kv`: Cloudflare KV binding (binding name is fixed to `KV`)
Expand Down Expand Up @@ -190,6 +190,14 @@ CF_KV_UUID=your_namespace_id
CF_API_KEY=your_api_token
```

> An explicitly configured driver is **never replaced automatically**. If it is
> unavailable the request is rejected with an actionable reason (including which
> driver auto-detection would have picked); `/api/public/env_check` and
> `/api/public/init_status` report the same reason plus a one-line fix. This
> prevents "I thought it was KV, but writes went to another backend".
> Invalid driver/format pairs (e.g. `DB_FORMAT=sql` + `DB_DRIVER=kv`) are
> reported the same way — the app never rewrites your configuration.

**Backward Compatibility:**
- `DB_DRIVER=json` auto-converts to `DB_FORMAT=map` + auto-detect driver

Expand Down
70 changes: 64 additions & 6 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { assetsRouter } from "./server/assets"
import { webdavRouter } from "./server/webdav"
import { s3Router } from "./server/s3"
import { setEnvCtx } from "./internal/model/db"
import { getStoreConfigError } from "./internal/model/store/backend"
import { getStoreConfigErrorDetail } from "./internal/model/store/backend"
import { storageErrorSummary, uiStorageError } from "./server/storage-error"

const app = new Hono()

Expand Down Expand Up @@ -48,6 +49,27 @@ function isDiagnosticPath(pathname: string): boolean {
return DIAGNOSTIC_PATHS.includes(pathname)
}

/**
* KV 代理传输端点(EdgeOne Edge Function 侧由 functions/kv-* 提供)。
*
* 这些路径是「驱动探测/读写」的传输层,不是业务请求,本 Worker 也不提供它们
* (只有 EdgeOne Edge Function / CF Pages Functions 才有)。必须两种情况都避开:
*
* 1. **不走存储配置拦截**:kv 驱动的可用性判定就是向 `{origin}/kv-list` 发一次
* 探测,而拦截逻辑会解析驱动 —— 若这些请求落进拦截,就会「探测请求 → 解析
* 驱动 → 再探测自身」无限自调用(本地 `wrangler dev` 表现为满屏
* `GET /kv-list 503`,且耗时随嵌套层数不断增长)。
* 2. **不能落进 SPA 兜底**:兜底会返回 index.html(HTTP 200),探测会把
* 「拿到 HTML」误判成「KV 可用」,之后读写全部拿到 HTML 而报错。
*
* 因此这里显式回 410:既明确说明本部署不提供该端点,也让探测得到干净的失败。
*/
const KV_PROXY_PATHS = ["/kv-list", "/kv-get", "/kv-put", "/kv-delete"]

function isKvProxyPath(pathname: string): boolean {
return KV_PROXY_PATHS.includes(pathname)
}

app.use("*", async (c, next) => {
// 关键:每个请求注入 KV binding 上下文(CF Workers 多实例/冷启动时
// 模块级 globalEnvCtx 为 null,会导致 getDb()/saveDb() 退回内存模式,
Expand All @@ -74,15 +96,31 @@ app.use("*", async (c, next) => {
const { pathname } = new URL(c.req.url)
const exempt =
isStaticOrShell(pathname, c.req.header("accept") || "", c.req.method) ||
isDiagnosticPath(pathname)
isDiagnosticPath(pathname) ||
// KV 代理传输端点:解析驱动会再探测自身,必须绕开(见 isKvProxyPath)
isKvProxyPath(pathname)
if (!exempt) {
const configError = await getStoreConfigError(env)
if (configError) {
// 用 Detail 版本:除完整原因外还带分类码与一句话修复建议,前端据此
// 展示「哪里错了 + 该改成什么」。只给一段长文本时,用户(和日志读者)
// 能看到的只是「驱动不可用」,不知道该把 DB_DRIVER 改成什么。
const detail = await getStoreConfigErrorDetail(env)
if (detail.message) {
const ui = uiStorageError(detail)
return c.json(
{
code: 503,
message: configError,
data: { error: "STORAGE_CONFIG_ERROR", configError },
// message 保持完整原因(兼容既有客户端与日志排查)
message: detail.message,
data: {
error: "STORAGE_CONFIG_ERROR",
code: detail.code,
configError: detail.message,
// 界面展示 summary(完整一句短原因)+ suggestion(怎么改);
// reason 是截断后的完整说明,留给工具与日志阅读。
summary: storageErrorSummary(detail.message),
reason: ui.reason,
suggestion: ui.suggestion,
},
},
503,
)
Expand All @@ -95,6 +133,26 @@ app.use("*", async (c, next) => {
// 在 Serverless 环境中,所有逻辑都是无状态的且由请求触发。
// 这里不应该初始化任何常驻的后台任务 (如 Cron 或 线程池)。

// KV 代理传输端点:本部署不提供(只有 EdgeOne Edge Function / CF Pages
// Functions 的 functions/kv-* 提供)。显式回 410,避免这些请求落进 SPA 兜底
// 被当成「HTTP 200」而让 kv 驱动误判为可用(详见 isKvProxyPath 注释)。
for (const path of KV_PROXY_PATHS) {
app.all(path, (c) =>
c.json(
{
code: 410,
message:
`KV proxy endpoint "${path}" is not served by this deployment. ` +
`It only exists on EdgeOne Edge Functions (functions/kv-*) or ` +
`Cloudflare Pages Functions. See /api/public/env_check for the ` +
`storage backend actually in use.`,
data: null,
},
410,
),
)
}

// 挂载 API 到 /api
const api = new Hono()
setupRouter(api)
Expand Down
44 changes: 36 additions & 8 deletions src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,8 +1146,13 @@ const loadDb = async (envCtx?: any) => {
// 此时必须回退到请求级 globalEnvCtx,否则 readDriver 读不到 DB_DRIVER、
// getD1 读不到 DB binding,会错误回退到 json 后端读到旧的 KV 数据。
const activeEnv = envCtx || globalEnvCtx
const backend = await storeBackendLoader(activeEnv)
// 解析器可被测试注入(__setStoreBackendLoaderForTest),故用 let + 可空:
// 解析动作必须在 try 内 —— 配置类错误(驱动缺失、驱动 × 格式非法)要与
// 读取错误走同一条降级路径,否则它会以「未捕获异常」的形式抛出,
// 让 /init/setup 只给前端一个没有原因的裸 500。
let backend: Awaited<ReturnType<typeof storeBackendLoader>> | null = null
try {
backend = await storeBackendLoader(activeEnv)
const persisted = await backend.load(activeEnv)
if (persisted) {
await unsealDb(persisted, await getEncryptionKey(activeEnv))
Expand All @@ -1167,7 +1172,7 @@ const loadDb = async (envCtx?: any) => {
// 短暂不可见,此时不能把默认库当成事实,更不能让它写回存储。
if (dbTrusted && memoryDb) {
console.warn(
`[DB] Backend ${backend.name} returned empty while a trusted snapshot exists; ` +
`[DB] Backend ${backend?.name ?? "storage"} returned empty while a trusted snapshot exists; ` +
`keeping the in-memory snapshot to avoid overwriting real config.`,
)
ensureDefaultSettings(memoryDb)
Expand All @@ -1182,7 +1187,7 @@ const loadDb = async (envCtx?: any) => {
} catch (err: any) {
// 读取失败绝不能静默回退到默认库并落盘——这正是「数据库被清空」的根因。
console.error(
`[DB] Error reading config from ${backend.name}:`,
`[DB] Error reading config from ${backend?.name ?? "storage"}:`,
err?.message || err,
)
dbLastLoadError = String(err?.message || err)
Expand Down Expand Up @@ -1307,7 +1312,30 @@ function readEnvEncryptionKey(env: any): string | null {
const raw =
env?.JWT_SECRET ||
(typeof process !== "undefined" ? process.env?.JWT_SECRET : "")
return typeof raw === "string" && raw.length >= 16 ? raw : null
// 只要求「非空」:长度是运维建议(推荐 32+),不是硬门槛。
// 强制长度会带来一个很坏的副作用 —— 用户明明配了 JWT_SECRET,却因为
// 不足 16/32 字符被判为「未配置」,于是自动生成逻辑又生成一把新密钥,
// 造成「环境变量密钥」与「持久化密钥」并存、加解密分裂。
return typeof raw === "string" && raw.trim().length > 0 ? raw : null
}

/**
* JWT_SECRET 的推荐长度(仅用于文案与告警,**不做强制校验**)。
*
* 为什么是 32:`openssl rand -hex 32` 输出 64 个 hex 字符,但 32 字符已是
* 足够强的 HS256 密钥;这里取 32 作为「推荐值」的下界。
*/
export const RECOMMENDED_JWT_SECRET_LENGTH = 32

/**
* 判断某个密钥是否短于推荐长度(用于**提示**,不用于拒绝)。
*/
export function isJwtSecretShort(secret: string | null | undefined): boolean {
return (
typeof secret === "string" &&
secret.length > 0 &&
secret.length < RECOMMENDED_JWT_SECRET_LENGTH
)
}

/** 进程内缓存:避免每次 load/save 都读存储 */
Expand Down Expand Up @@ -1350,7 +1378,7 @@ async function getEncryptionKey(envCtx?: any): Promise<string | null> {
// 回退到持久化密钥(仅读取)
try {
const persisted = await readPersistedSecret(env, ENCRYPTION_SECRET_KV_KEY)
if (persisted && persisted.length >= 16) {
if (persisted && persisted.trim().length > 0) {
cachedEncryptionKey = persisted
cachedFromEnv = false
return persisted
Expand Down Expand Up @@ -1392,7 +1420,7 @@ export async function isEncryptionReady(envCtx?: any): Promise<boolean> {
// 直查持久化(不走缓存)
try {
const persisted = await readPersistedSecret(env, ENCRYPTION_SECRET_KV_KEY)
return Boolean(persisted && persisted.length >= 16)
return Boolean(persisted && persisted.trim().length > 0)
} catch {
return false
}
Expand Down Expand Up @@ -1459,7 +1487,7 @@ export async function ensureEncryptionSecret(

// 2. 已存在则复用(存在性门控,永不覆盖)
const existing = await readPersistedSecret(env, ENCRYPTION_SECRET_KV_KEY)
if (existing && existing.length >= 16) {
if (existing && existing.trim().length > 0) {
cachedEncryptionKey = existing
cachedFromEnv = false
return existing
Expand Down Expand Up @@ -1497,7 +1525,7 @@ export async function ensureEncryptionSecret(
}
// 读到的值不是我们写的那把(可能被并发 setup 覆盖):说明存在竞态,
// 采用「先写入者优先」——复用已存在的密钥,避免用两把钥匙加解密。
if (readBack && readBack.length >= 16 && readBack !== generated) {
if (readBack && readBack.trim().length > 0 && readBack !== generated) {
console.warn(
"[DB] A different encryption key already exists; adopting it to " +
"keep encrypt/decrypt symmetric.",
Expand Down
Loading
Loading