多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
This commit is contained in:
1 parent
e5dee4daf1
commit
4bb2ff4145
23 files changed
+917
-222
No files matched your search
@@ -41,7 +41,7 @@ func seed(t *testing.T, a *API, key, body string) model.File {
|
||||
}
|
||||
|
||||
func TestFileRefsEndpoint(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/aaa.png", `看图 `)
|
||||
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
|
||||
@@ -49,7 +49,7 @@ func TestFileRefsEndpoint(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
|
||||
rec := doAs(t, a, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -73,12 +73,12 @@ func TestFileRefsEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
// 不存在的文件:404 而不是空列表
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "")
|
||||
rec = doAs(t, a, http.MethodGet, "/api/admin/files/9999/refs", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
// 乱七八糟的子路径不收
|
||||
rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "")
|
||||
rec = doAs(t, a, http.MethodGet, "/api/admin/files/1/nope", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
@@ -86,12 +86,12 @@ func TestFileRefsEndpoint(t *testing.T) {
|
||||
|
||||
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
|
||||
func TestFileDeleteBlockedByRefs(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/bbb.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -114,12 +114,12 @@ func TestFileDeleteBlockedByRefs(t *testing.T) {
|
||||
|
||||
// 明确带 force=1 才真删:行、blob 一起走。
|
||||
func TestFileDeleteForceProceeds(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
dir := t.TempDir()
|
||||
a.Blobs = storage.NewLocal(dir)
|
||||
f := seed(t, a, "2026/09/ccc.png", ``)
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
|
||||
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -133,11 +133,11 @@ func TestFileDeleteForceProceeds(t *testing.T) {
|
||||
|
||||
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
|
||||
func TestFileDeleteUnreferenced(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
a.Blobs = storage.NewLocal(t.TempDir())
|
||||
f := seed(t, a, "2026/09/ddd.png", "")
|
||||
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
rec := doAs(t, a, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user