diff --git a/README.md b/README.md index b73ca213..bca3fac3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ OpenList-Worker 是官方 [OpenListTeam/OpenList](https://github.com/OpenListTea - **文件管理**:统一目录树浏览,图片 / 视频 / 音频 / 文档 / 代码 / 压缩包在线预览;跨存储上传、批量下载、流式传输与直链跳转;带有效期、密码与权限控制的分享链接。 - **检索与任务**:已索引存储的全文搜索,后台任务队列支持批量与异步处理。 - **外部接口**:WebDAV、S3 兼容协议,以及可被 AI 助手集成的 MCP 端点。 +- **访客访问**:新装默认要求登录(`guest` 账号默认禁用)。需要匿名只读时,在「用户管理」里启用 `guest` 即可;存量部署升级后保持原状。是否允许匿名只取决于 guest 账号本身是否启用(与 Go 版一致),没有额外的站点开关,也不是 Cloudflare 控制台的环境变量。 - **权限与认证**:RBAC(用户分组、目录级读写权限、配额),账号密码 + TOTP / WebAuthn / SSO / LDAP。 - **安全与运维**:JWT 会话、CSRF、点击劫持(X-Frame-Options)与 CSP 防护,`/health`、`/healthz` 健康探针。 - **并发去重**:相同目录列表 / 元信息请求只向上游网盘发起一次,其余请求复用结果,减少限流与 CPU 消耗。 diff --git a/readmes/README_en.md b/readmes/README_en.md index 6ecb4ffc..f013e124 100644 --- a/readmes/README_en.md +++ b/readmes/README_en.md @@ -73,6 +73,7 @@ In addition to the real storages above, virtual/functional drivers such as `Loca - **File management**: unified directory tree browsing with online preview for images, videos, audio, documents, code and archives; cross-storage upload, batch download, streaming and direct-link redirect; share links with expiry, password and permission control. - **Search & tasks**: full-text search across indexed storages; background task queue for batch and async processing. - **External interfaces**: WebDAV, S3-compatible protocol, and an MCP endpoint for AI assistants. +- **Guest access**: fresh installs require sign-in by default (the `guest` account is disabled). To allow anonymous read-only browsing, enable the `guest` user; existing deployments keep their current behavior. Anonymous access is controlled solely by the guest account state (same as the Go backend) — there is no extra site setting, and it is not a Cloudflare environment variable. - **Access & auth**: RBAC (user groups, directory-level read/write permissions, quotas), account passwords plus TOTP / WebAuthn / SSO / LDAP. - **Security & ops**: JWT sessions, CSRF, clickjacking (X-Frame-Options) and CSP protection, `/health` and `/healthz` probes. - **Concurrency deduplication**: identical directory listing / metadata requests hit the upstream drive only once; the rest reuse the result, reducing rate limits and CPU usage. diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 38894380..e07a9c36 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -1029,7 +1029,10 @@ export const defaultDb = { role: 1, permission: 0, base_path: "/", - disabled: false, + // 新装默认不启用访客:旧行为是 guest 默认启用、匿名请求直接按 guest 浏览, + // 与「打开网页就要登录」的预期相反(issue #133)。存量部署的用户表存在 + // 持久化存储里,不受这里影响;需要匿名只读时在用户管理中显式启用。 + disabled: true, sso_id: "", allow_ldap: false, pwd_update_at: new Date().toISOString(), diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index d2753809..775bded7 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -332,7 +332,8 @@ export async function getOrInitUsers(envCtx: any) { role: 1, permission: 0, base_path: "/", - disabled: false, + // 与 defaultDb.users 保持一致:新装默认不启用访客(issue #133)。 + disabled: true, sso_id: "", allow_ldap: false, pwd_update_at: new Date().toISOString(), @@ -362,6 +363,8 @@ export async function getOrInitUsers(envCtx: any) { // 未初始化:仅创建 guest,admin 由 Web 安装向导(POST /api/public/init/setup)创建 db.users = [guest] } + // 新装默认要求登录(issue #133):由上面的「guest 默认禁用」实现, + // 与 Go 版一致 —— Go 没有 allow_guest 设置项,只有 guest 账号的启用状态。 // 只有配置了 ADMIN_PASS(或已存在真实管理员)时,这份库才不是空壳, // 才应该落盘。未配置时应等待 init/setup 完成初始化,而不是抢先写入一个 // 未初始化的占位库(会被 saveDb 的写前守卫拦截)。 diff --git a/src/backend/server/emby_protocol.test.ts b/src/backend/server/emby_protocol.test.ts index ff205046..402bdfb0 100644 --- a/src/backend/server/emby_protocol.test.ts +++ b/src/backend/server/emby_protocol.test.ts @@ -18,6 +18,7 @@ function buildApp() { const original = new Map() let originalGuestBasePath: string | null = null +let originalGuestDisabled: boolean | null = null let originalMetas: any[] | null = null async function setSetting(key: string, value: string) { @@ -43,6 +44,7 @@ after(async () => { } const guest = (db.users || []).find((u: any) => u.username === "guest") if (guest && originalGuestBasePath !== null) guest.base_path = originalGuestBasePath + if (guest && originalGuestDisabled !== null) guest.disabled = originalGuestDisabled if (originalMetas !== null) db.metas = originalMetas await saveDb(db, undefined, { force: true } as any) }) @@ -101,6 +103,10 @@ async function seedMediaEnv(items: MediaItem[], metas: any[] = [], enabled = tru originalGuestBasePath = String(guest.base_path || "/") } guest.base_path = "/public" + // 新装默认禁用访客(issue #133),本文件验证的是「访客开启时」的封面语义, + // 因此显式启用;是否允许匿名只取决于 guest 账号状态(与 Go 一致)。 + if (originalGuestDisabled === null) originalGuestDisabled = !!guest.disabled + guest.disabled = false } if (originalMetas === null) originalMetas = JSON.parse(JSON.stringify(userDb.metas || [])) userDb.metas = metas @@ -333,3 +339,4 @@ test("Emby 封面:剧集封面同样经过 meta 校验", async () => { const denied = await app.request(path, {}, {}) assert.equal(denied.status, 404) }) + diff --git a/src/backend/server/guest_auth.test.ts b/src/backend/server/guest_auth.test.ts index 12256f09..ddb982ca 100644 --- a/src/backend/server/guest_auth.test.ts +++ b/src/backend/server/guest_auth.test.ts @@ -111,7 +111,7 @@ test("Security: /api/me returns 401 when unauthenticated and guest is deleted or assert.equal(json.code, 401) }) -test("Security: /api/public/settings returns allow_guest 'false' when guest is deleted or disabled", async () => { +test("Security: /api/public/settings does not expose allow_guest (parity with Go)", async () => { const env: any = {} await saveDb( { @@ -136,15 +136,13 @@ test("Security: /api/public/settings returns allow_guest 'false' when guest is d const app = new Hono() app.route("/api/public", publicRouter) - const res = await app.request("/api/public/settings", { - method: "GET", - }) + const res = await app.request("/api/public/settings", { method: "GET" }) assert.equal(res.status, 200) const json: any = await res.json() assert.equal( json.data.allow_guest, - "false", - "allow_guest must be false when guest user is deleted", + undefined, + "Go 版公开设置里没有 allow_guest,这里也不应输出", ) }) @@ -255,3 +253,4 @@ test("/raw/* returns 401 without sign when sign_all is enabled (parity with Go n const res = await app.request("/raw/some/file.txt", { method: "GET" }) assert.equal(res.status, 401, "sign_all enabled must require a valid sign") }) + diff --git a/src/backend/server/guest_defaults.test.ts b/src/backend/server/guest_defaults.test.ts new file mode 100644 index 00000000..6e3dd104 --- /dev/null +++ b/src/backend/server/guest_defaults.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { + __resetDbCacheForTest, + defaultDb, + getDb, + saveDb, +} from "../internal/model/db" +import { memoryDriver } from "../internal/model/store/driver/memory" +import { getOrInitUsers } from "./auth" +import { getUserFromContext } from "./middlewares" +import { fsRouter } from "./fs" +import { publicRouter } from "./public" + +const ADMIN_HASH = "a".repeat(64) + +async function freshEnv() { + for (const key of await memoryDriver.list("openlist_")) { + await memoryDriver.delete(key) + } + __resetDbCacheForTest() + return {} as any +} + +function anonContext(env: any) { + return { + req: { header: () => undefined, query: () => undefined }, + env, + } as any +} + +function buildApp() { + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/public", publicRouter) + return app +} + +async function anonList(env: any) { + const res = await buildApp().request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + return res.status +} + +test("默认库:guest 默认禁用,且不引入 Go 没有的 allow_guest 设置项", () => { + const guest = defaultDb.users.find((u: any) => u.username === "guest") + assert.equal(guest?.disabled, true, "新装默认不应启用 guest") + + const row = defaultDb.settings.find((s: any) => s.key === "allow_guest") + assert.equal(row, undefined, "Go 版没有 allow_guest,默认设置里也不应有") +}) + +test("新装(ADMIN_PASS 首启):guest 禁用,匿名 401", async () => { + const env = await freshEnv() + env.ADMIN_PASS = "admin123" + + const { db } = await getOrInitUsers(env) + const guest = (db.users || []).find((u: any) => u.username === "guest") + assert.equal(guest?.disabled, true) + + // 必须真的落盘:清掉模块缓存后重新读取,不能只是内存里的默认值 + __resetDbCacheForTest() + const reloaded = await getDb(env) + assert.equal( + (reloaded.users || []).find((u: any) => u.username === "guest")?.disabled, + true, + "新装的 guest 禁用状态必须持久化", + ) + + assert.equal(await anonList(env), 401) + assert.equal(await getUserFromContext(anonContext(env)), null) + + const settings = await buildApp().request( + "/api/public/settings", + { method: "GET" }, + env, + ) + const body: any = await settings.json() + assert.equal(body.data.allow_guest, undefined, "公开设置不应输出 allow_guest") +}) + +test("存量库升级(guest 启用):不会被静默改成必须登录", async () => { + const env = await freshEnv() + await saveDb( + { + settings: [], + users: [ + { + id: 1, + username: "admin", + password: ADMIN_HASH, + role: 2, + permission: 0, + base_path: "/", + disabled: false, + }, + { + id: 2, + username: "guest", + password: "", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ], + storages: [], + shares: [], + metas: [], + } as any, + env, + ) + __resetDbCacheForTest() + + const db = await getDb(env) + assert.equal( + (db.users || []).find((u: any) => u.username === "guest")?.disabled, + false, + "存量库的 guest 状态不应被升级改动", + ) + assert.equal((await getUserFromContext(anonContext(env)))?.username, "guest") +}) + +test("新装(安装向导 init/setup):完成后 guest 仍禁用,匿名 401", async () => { + const env = await freshEnv() + + const res = await buildApp().request( + "/api/public/init/setup", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "admin", + password: "admin123", + site_title: "Test", + }), + }, + env, + ) + assert.equal(res.status, 200) + + __resetDbCacheForTest() + const db = await getDb(env) + assert.equal( + (db.users || []).find((u: any) => u.username === "guest")?.disabled, + true, + ) + assert.equal(await anonList(env), 401) +}) diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index 0c7d7d45..b61d3b4b 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -494,8 +494,9 @@ export async function getUserFromContext(c: Context): Promise<{ } } - // 无 Authorization header:尝试使用 guest 用户作为后备 - // 注意:guest 不可用时返回 null 是合理的,调用方应该正确处理 + // 无 Authorization header:尝试使用 guest 用户作为后备。 + // 与 Go 版 server/middlewares/auth.go 的 Auth(false) 一致:是否允许匿名访问 + // 只取决于 guest 账号本身是否存在/被禁用,没有额外的站点开关。 if (!authHeader) { try { const db = await getDb(c.env) diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 10132e5c..a91995b0 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -490,7 +490,6 @@ publicRouter.get("/settings", async (c) => { check_update: "false", // --- Auth --- - allow_guest: "true", webauthn_login_enabled: "false", sso_login_enabled: "false", sso_compatibility_mode: "false", @@ -548,14 +547,9 @@ publicRouter.get("/settings", async (c) => { } }) - // 动态检查是否存在且启用了 guest 账号 - const guest = (db.users || []).find((u: any) => u.username === "guest") - const isGuestActive = Boolean(guest && !guest.disabled) - if (!isGuestActive || settingsObj.allow_guest === "false") { - settingsObj.allow_guest = "false" - } else { - settingsObj.allow_guest = "true" - } + // 注意:不再输出 allow_guest。Go 版没有这个键,匿名访问是否放行完全由 + // guest 账号是否存在/被禁用决定(server/middlewares/auth.go 的 Auth(false))。 + // 前端也不读取该字段,删掉它是为了和 Go 的公开设置保持一致。 return c.json({ code: 200, @@ -821,6 +815,9 @@ publicRouter.post("/init/setup", async (c) => { } } + // 注意:新装默认要求登录由「guest 账号默认禁用」实现(defaultDb.users), + // 与 Go 保持同一套语义,不再写 allow_guest 设置项。 + // 初始化是唯一允许「在一次成功读取(确认存储为空)之后写入空壳」的场景, // 因此显式 force;此时 db 已包含新建的管理员用户,本身也不再是空壳。 await saveDb(db, c.env, { force: true }) diff --git a/src/backend/server/public_settings.test.ts b/src/backend/server/public_settings.test.ts index 3b28aa63..1d3f2fa6 100644 --- a/src/backend/server/public_settings.test.ts +++ b/src/backend/server/public_settings.test.ts @@ -59,8 +59,8 @@ test("Security(C-1): public settings still returns display options (no regressio assert.equal(json.data.webauthn_login_enabled, "false") assert.equal(json.data.sign_all, "true") assert.equal(json.data.site_title, "My Site") - // NOTE: allow_guest is deliberately not asserted here — it is derived from - // whether a guest account exists (see guest_auth.test.ts), not a plain echo. + // NOTE: allow_guest is not part of the public response — the Go backend has no + // such key; anonymous access is controlled by the guest account state only. }) test("Security(F-14): unknown keys fail closed — they are not echoed", async () => {