Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 消耗。
Expand Down
1 change: 1 addition & 0 deletions readmes/README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/backend/internal/model/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 4 additions & 1 deletion src/backend/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 的写前守卫拦截)。
Expand Down
7 changes: 7 additions & 0 deletions src/backend/server/emby_protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function buildApp() {

const original = new Map<string, string>()
let originalGuestBasePath: string | null = null
let originalGuestDisabled: boolean | null = null
let originalMetas: any[] | null = null

async function setSetting(key: string, value: string) {
Expand All @@ -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)
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -333,3 +339,4 @@ test("Emby 封面:剧集封面同样经过 meta 校验", async () => {
const denied = await app.request(path, {}, {})
assert.equal(denied.status, 404)
})

11 changes: 5 additions & 6 deletions src/backend/server/guest_auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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,这里也不应输出",
)
})

Expand Down Expand Up @@ -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")
})

157 changes: 157 additions & 0 deletions src/backend/server/guest_defaults.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
5 changes: 3 additions & 2 deletions src/backend/server/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 6 additions & 9 deletions src/backend/server/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
4 changes: 2 additions & 2 deletions src/backend/server/public_settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading