Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
224 changes: 113 additions & 111 deletions cloud-functions/[[default]].js

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// 请求(包括 /add、/@manage/* 等前端路由),而 Node 函数内没有 ASSETS 绑定,
// Hono 兜底只能返回 404 —— 这就是「访问 /add 404 后整站打不开」的原因。
// 此中间件在边缘层先行拦截:浏览器导航请求(Accept: text/html)且不属于
// 后端路径时,透明改写为 /index.html,由静态 CDN 直接返回页面壳;
// /api、/d、/p、/sd、/health 等后端路径照常放行到云函数。
// 后端路径时,透明改写到动态 SPA 路由,由云函数读取设置并返回页面壳;
// /api、/d、/p、/sd、/dav、/s3、/kv-* 等后端路径照常放行。
//
// 注意:EdgeOne 的 middleware 属于轻量中间件,context 仅提供
// request / next / redirect / rewrite / geo / clientIp,**没有 env**,
Expand All @@ -17,14 +17,18 @@ export function middleware(context) {
const accept = request.headers.get("accept") || ""

const isBackend =
pathname === "/health" || /^\/(api|d|p|sd|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(pathname)
pathname === "/health" ||
pathname === "/__openlist_spa__" ||
/^\/(api|d|p|sd|dav|s3|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(
pathname,
)

if (
!isBackend &&
(request.method === "GET" || request.method === "HEAD") &&
accept.includes("text/html")
) {
return rewrite("/index.html")
return rewrite("/__openlist_spa__")
}

return next()
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@
"test:189": "tsx --test src/backend/drivers/189/*.test.ts",
"test:drivers": "tsx --test \"src/backend/drivers/**/*.test.ts\"",
"test:server": "tsx --test \"src/backend/server/*.test.ts\"",
"test:edgeone": "node --test \"tests/*.test.mjs\"",
"test:store": "tsx --test src/backend/internal/model/store/store.test.ts",
"test:model": "tsx --test \"src/backend/internal/model/*.test.ts\"",
"test:regress": "tsx scripts/_regress.mjs",
"test:deploy": "node scripts/test-deploy.js",
"env:check": "tsx scripts/env-check.mjs",
"test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:regress",
"test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:edgeone && npm run test:store && npm run test:model && npm run test:regress",
"sls:deploy": "serverless deploy",
"sls:package": "serverless package",
"sls:info": "serverless info",
Expand Down
95 changes: 83 additions & 12 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,77 @@ import { rawRouter } from "./server/raw"
import { assetsRouter } from "./server/assets"
import { webdavRouter } from "./server/webdav"
import { s3Router } from "./server/s3"
import { setEnvCtx } from "./internal/model/db"
import { getDb, isDbTrusted, setEnvCtx } from "./internal/model/db"
import { getStoreConfigErrorDetail } from "./internal/model/store/backend"
import { storageErrorSummary, uiStorageError } from "./server/storage-error"

const app = new Hono()

export const EDGEONE_SPA_ROUTE = "/__openlist_spa__"

const CUSTOMIZE_HEAD_ANCHOR = "<!-- customize head -->"
const CUSTOMIZE_BODY_ANCHOR = "<!-- customize body -->"
const CUSTOMIZE_ANCHOR_PATTERN =
/<!--\s*(?:customize head|customize body)\s*-->/gi

export function applyCustomizeToHtml(html: string, settings: unknown): string {
const values = new Map<string, string>()
if (Array.isArray(settings)) {
for (const item of settings) {
if (!item || typeof item !== "object") continue
const setting = item as { key?: unknown; value?: unknown }
if (typeof setting.key === "string") {
values.set(setting.key, String(setting.value ?? ""))
}
}
}
const customize = (key: string) =>
(values.get(key) ?? "").replace(CUSTOMIZE_ANCHOR_PATTERN, "")
return html
.replace(CUSTOMIZE_HEAD_ANCHOR, () => customize("customize_head"))
.replace(CUSTOMIZE_BODY_ANCHOR, () => customize("customize_body"))
}

async function renderSpaHtml(
c: { env: any; req: { method: string } },
html: string,
status = 200,
sourceHeaders?: Headers,
): Promise<Response> {
let body = html
try {
const db = await getDb(c.env)
if (isDbTrusted(db)) {
body = applyCustomizeToHtml(html, db.settings)
}
} catch {}
const headers = new Headers(sourceHeaders)
for (const name of [
"etag",
"content-encoding",
"content-length",
"last-modified",
]) {
headers.delete(name)
}
headers.set("Content-Type", "text/html; charset=utf-8")
headers.set("Cache-Control", "no-cache, must-revalidate")
return new Response(c.req.method === "HEAD" ? null : body, {
status,
headers,
})
}

/**
* 静态资源 / SPA 壳路径:这些请求不应被存储配置错误拦截,
* 否则前端连提示页面都加载不出来。
*/
function isStaticOrShell(pathname: string, accept: string, method: string): boolean {
function isStaticOrShell(
pathname: string,
accept: string,
method: string,
): boolean {
if (pathname === EDGEONE_SPA_ROUTE) return true
// 带扩展名的静态文件
if (/\.[a-zA-Z0-9]+$/.test(pathname)) return true
// 浏览器导航请求(HTML)由 SPA 壳承载
Expand Down Expand Up @@ -182,33 +242,44 @@ export function setSpaFallbackHtml(html: string) {

app.all("*", async (c) => {
const env = c.env as any
const accept = c.req.header("accept") || ""
if (env && env.ASSETS && typeof env.ASSETS.fetch === "function") {
const url = new URL(c.req.url)
const res = await env.ASSETS.fetch(c.req.raw)
if (res.status >= 200 && res.status < 300) {
// 修复「部署新版本后生产环境仍是旧界面」:index.html 若不设缓存头,
// 会被 Cloudflare 边缘/浏览器长期缓存,导致旧 HTML 引用旧 hash 的 JS/CSS。
// 只对 HTML 入口 no-cache(JS/CSS 带 hash 可安全长期缓存)。
if (url.pathname === "/" || url.pathname === "/index.html") {
const headers = new Headers(res.headers)
headers.set("Cache-Control", "no-cache, must-revalidate")
return new Response(res.body, { status: res.status, headers })
const contentType = res.headers.get("content-type") || ""
const isHtmlResponse =
accept.includes("text/html") && contentType.includes("text/html")
if (
url.pathname === "/" ||
url.pathname === "/index.html" ||
isHtmlResponse
) {
return renderSpaHtml(c, await res.text(), res.status, res.headers)
}
return res
}
// SPA fallback: return index.html for non-asset routes (e.g. /login, /manage)
// 注意:ASSETS.fetch 对 /index.html 也可能返回 307,直接 fetch "/" 获取实际 HTML
const rootReq = new Request(`${url.origin}/`, c.req.raw)
return env.ASSETS.fetch(rootReq)
const rootRes = await env.ASSETS.fetch(rootReq)
if (rootRes.status >= 200 && rootRes.status < 300) {
return renderSpaHtml(
c,
await rootRes.text(),
rootRes.status,
rootRes.headers,
)
}
return rootRes
}
// EdgeOne 等 ASSETS 缺席的环境:直接返回构建期内联的 SPA 壳,
// 避免前端路由(/add、/@manage/* 等)落到 404 文本导致整站不可达
if (spaFallbackHtml && (c.req.method === "GET" || c.req.method === "HEAD")) {
return c.body(spaFallbackHtml, 200, {
"Content-Type": "text/html; charset=utf-8",
// HTML 入口必须 no-cache,否则新版本部署后旧 HTML 仍引用旧 hash 的 JS/CSS
"Cache-Control": "no-cache, must-revalidate",
})
return renderSpaHtml(c, spaFallbackHtml)
}
return c.text("404 Not Found", 404)
})
Expand Down
15 changes: 13 additions & 2 deletions src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@ export const defaultDb = {

let memoryDb: any = null
let globalEnvCtx: any = null
let dbSnapshotTrust = new WeakMap<object, boolean>()

/**
* 数据可信度状态(防止「读失败 → 回退空库 → 落盘覆盖」)。
Expand All @@ -841,8 +842,11 @@ let dbTrusted = false
let dbWriteBlocked = false
let dbLastLoadError: string | null = null

/** 当前内存库是否可信(可安全写回持久化存储)。 */
export function isDbTrusted(): boolean {
/** 当前内存库或指定快照是否可信(可安全写回持久化存储)。 */
export function isDbTrusted(snapshot?: any): boolean {
if (snapshot && typeof snapshot === "object") {
return dbSnapshotTrust.get(snapshot) ?? false
}
return dbTrusted
}

Expand Down Expand Up @@ -1103,6 +1107,7 @@ export const __resetDbCacheForTest = () => {
}
globalEnvCtx = null
memoryDb = null
dbSnapshotTrust = new WeakMap<object, boolean>()
storeBackendLoader = (env: any) => getStoreBackend(env)
// 写前守卫状态复位(否则跨用例串味)
dbTrusted = false
Expand Down Expand Up @@ -1208,6 +1213,7 @@ const loadDb = async (envCtx?: any) => {
// 读取成功:内存库与持久化存储一致,允许后续写回。
dbTrusted = true
dbLastLoadError = null
dbSnapshotTrust.set(memoryDb, true)
return memoryDb
}
// 后端读取成功但没有数据:可能是全新部署(首次初始化)。
Expand All @@ -1223,6 +1229,7 @@ const loadDb = async (envCtx?: any) => {
ensureDefaultShares(memoryDb)
ensureDefaultPlugins(memoryDb)
ensureDefaultMetas(memoryDb)
dbSnapshotTrust.set(memoryDb, true)
return memoryDb
}
dbTrusted = false
Expand All @@ -1245,6 +1252,7 @@ const loadDb = async (envCtx?: any) => {
ensureDefaultShares(memoryDb)
ensureDefaultPlugins(memoryDb)
ensureDefaultMetas(memoryDb)
dbSnapshotTrust.set(memoryDb, true)
return memoryDb
}

Expand All @@ -1255,6 +1263,7 @@ const loadDb = async (envCtx?: any) => {
ensureDefaultShares(memoryDb)
ensureDefaultPlugins(memoryDb)
ensureDefaultMetas(memoryDb)
dbSnapshotTrust.set(memoryDb, false)
return memoryDb
}

Expand Down Expand Up @@ -2044,6 +2053,7 @@ export const saveDb = async (
}

memoryDb = data
dbSnapshotTrust.set(memoryDb, false)
dbWriteBlocked = false
// Refresh the request cache so any getDb() later in this request observes
// the write rather than a pre-write snapshot.
Expand Down Expand Up @@ -2106,6 +2116,7 @@ export const saveDb = async (
// 这样同一 isolate 后续的写入不会被守卫误拦。
dbTrusted = true
dbLastLoadError = null
dbSnapshotTrust.set(memoryDb, true)
return true
}

Expand Down
26 changes: 25 additions & 1 deletion src/backend/internal/model/db_cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ test("getDb: saveDb 后无参读取可观察到最新写入(写后读一致)
await getDb()
assert.equal(stats.load, 1)

const next = { ...SAMPLE, settings: [{ key: "site_title", value: "Changed" }] }
const next = {
...SAMPLE,
settings: [{ key: "site_title", value: "Changed" }],
}
await saveDb(next)
// 注意:saveDb 内部可能同时写入加密密钥等辅助数据,因此不断言 save 恰好为 1,
// 只要求确实发生过持久化写入。
Expand Down Expand Up @@ -231,6 +234,27 @@ test("getDb: 五个无参 getter 复用同一份缓存快照", async () => {
assert.ok(Array.isArray(plugins))
})

test("isDbTrusted(snapshot) 绑定到该快照而非模块全局状态", async () => {
__resetDbCacheForTest()
const { backend } = createCountingBackend(SAMPLE)
__setStoreBackendLoaderForTest(async () => backend)
const trusted = await getDb({ DB_DRIVER: "trusted" })

__setStoreBackendLoaderForTest(async () => ({
name: "failing",
isConfigured: async () => true,
load: async () => {
throw new Error("read failed")
},
save: async () => true,
}))
const untrusted = await getDb({ DB_DRIVER: "failing" })

assert.equal(mod.isDbTrusted(), false)
assert.equal((mod as any).isDbTrusted(trusted), true)
assert.equal((mod as any).isDbTrusted(untrusted), false)
})

test("getDb: TTL 过期后允许重新加载(缓存不是永久固化)", async () => {
__resetDbCacheForTest()
const { backend, stats } = createCountingBackend(SAMPLE)
Expand Down
Loading