多用户与角色: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
@@ -42,10 +42,14 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start
|
||||
保存后立即生效,无需重启。这些值存在 `settings` 表里,生效规则是
|
||||
「后台填了用后台的,没填回落到环境变量」——老部署不改 env 也能照常跑。
|
||||
|
||||
秘密项(client secret、Bot token、R2 密钥、管理员密码)在后台只显示
|
||||
秘密项(client secret、Bot token、R2 密钥)在后台只显示
|
||||
「是否已配置、来自哪里」,永不回显明文;留空保存 = 保持现值。
|
||||
管理员密码在后台以 bcrypt 哈希存储;显式设置过的 `ONE_ADMIN_PASSWORD`
|
||||
保留作解锁后路(env 和数据库在同一台机器上,能读 env 的人本来就能改库)。
|
||||
|
||||
**多用户与角色**:站主(owner,全库唯一)可在后台 **用户** 页创建内容管理员
|
||||
(admin),管理员能写文章、管理评论与文件,但系统设置、用户管理与第三方凭据
|
||||
只有站主动。密码 bcrypt 存在 users 表上,各账号在「账户」页自行修改;
|
||||
显式设置过的 `ONE_ADMIN_PASSWORD` 保留作站主的解锁后路,
|
||||
admin/admin 开发模式在任何账号设置过密码后立即失效。
|
||||
|
||||
环境变量只剩启动期必需项:
|
||||
|
||||
|
||||
@@ -34,20 +34,12 @@ type accountView struct {
|
||||
}
|
||||
|
||||
type passwordInfo struct {
|
||||
// 密码现在有两个可能的家:settings 表里的 bcrypt 哈希(后台改的),
|
||||
// 或环境变量 ONE_ADMIN_PASSWORD(未迁移时的兜底)。
|
||||
// 多用户后密码统一在 users 行上(bcrypt),在账户页修改。
|
||||
// 首次登录时 env / settings 的引导凭据会自迁移成行内哈希。
|
||||
ManagedBy string `json:"managed_by"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// passwordSource 报告当前密码存哪。前端只作展示,不参与逻辑。
|
||||
func passwordSource(hash string) string {
|
||||
if hash != "" {
|
||||
return "settings"
|
||||
}
|
||||
return "env:ONE_ADMIN_PASSWORD"
|
||||
}
|
||||
|
||||
func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
@@ -79,15 +71,16 @@ func (a *API) buildAccount() (accountView, error) {
|
||||
return accountView{}, err
|
||||
}
|
||||
v := accountView{
|
||||
// Providers 必须非 nil:一个第三方都没配时 Go 会序列化成 null,
|
||||
// 前端 acct.providers.length 直接炸(真实事故:新库未配 OAuth 时账户页白屏)
|
||||
Providers: []string{},
|
||||
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
// 密码可迁到哪:后台「管理员账号」里改过就是 DB(bcrypt 哈希),
|
||||
// 否则回落 env。
|
||||
Password: passwordInfo{ManagedBy: passwordSource(st.AdminPasswordHash), Username: a.cfg().AdminUser},
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
Password: passwordInfo{ManagedBy: "account", Username: a.cfg().AdminUser},
|
||||
Identities: ids,
|
||||
Passkeys: pks,
|
||||
}
|
||||
|
||||
@@ -11,28 +11,32 @@ import (
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
// doAs 带着有效后台会话发一个请求。
|
||||
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
|
||||
// doAs 带着有效后台会话(owner)发一个请求。会话 token 只带用户 ID,
|
||||
// 所以先确保 owner 行存在,再按真实 ID 签。
|
||||
func doAs(t *testing.T, a *API, method, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
owner, err := a.Store.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
|
||||
sess := NewSessions("test-secret", time.Hour)
|
||||
tok, _ := sess.Issue("admin")
|
||||
tok, _ := sess.Issue(owner.ID)
|
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
a.Routes().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAccountGET(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
|
||||
rec := doAs(t, a, http.MethodGet, "/api/admin/account", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -55,11 +59,11 @@ func TestAccountGET(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountPATCHProfile(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
|
||||
rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -81,12 +85,12 @@ func TestAccountPATCHProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
|
||||
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
|
||||
rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
|
||||
}
|
||||
@@ -98,7 +102,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
|
||||
rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非图片: got %d, want 400", rec.Code)
|
||||
}
|
||||
@@ -110,7 +114,7 @@ func TestAccountPATCHAvatarKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
|
||||
rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -143,11 +147,11 @@ func TestAccountRejectsAnonymous(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountUnbindUnknown(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if _, err := a.Store.EnsureOwner("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
|
||||
rec := doAs(t, a, http.MethodDelete, "/api/admin/account/identities/github", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
|
||||
}
|
||||
@@ -155,11 +159,11 @@ func TestAccountUnbindUnknown(t *testing.T) {
|
||||
|
||||
// passkey 未配置时必须明确不可用,而不是假装成功
|
||||
func TestPasskeysUnavailableWhenNil(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a, _ := newTestAPI(t)
|
||||
if a.Passkeys != nil {
|
||||
t.Skip("测试构造里不该有 Passkeys")
|
||||
}
|
||||
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
|
||||
rec := doAs(t, a, http.MethodPost, "/api/admin/account/passkeys/begin", "")
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d, want 503", rec.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// 会话身份的解析与角色守卫。token 只带用户 ID(防篡改靠 HMAC),角色与
|
||||
// 停用状态每请求从 users 表现读——后台改角色 / 停用账号即时生效,
|
||||
// 不用等 7 天会话过期。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
type actor struct {
|
||||
ID int64
|
||||
Handle string
|
||||
Role string
|
||||
}
|
||||
|
||||
type actorKey struct{}
|
||||
|
||||
func (a *API) guard(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
id, err := a.Sessions.Verify(token)
|
||||
if err != nil {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
u, err := a.Store.GetUserByID(id)
|
||||
// 只认后台账号行:token 是我们签发的,理论上不会指到 reader,
|
||||
// 但防御式校验一层;停用的账号立即失效。
|
||||
if err != nil || u.Provider != "admin" || u.Banned {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
act := actor{ID: u.ID, Handle: u.Handle, Role: u.Role}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, act)))
|
||||
}
|
||||
}
|
||||
|
||||
// guardOwner 在 guard 之上加角色门槛:系统设置、用户管理只属于站主。
|
||||
func (a *API) guardOwner(next http.HandlerFunc) http.HandlerFunc {
|
||||
return a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
if actorFrom(r).Role != model.RoleOwner {
|
||||
httpx.Error(w, http.StatusForbidden, "需要站主权限")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func actorFrom(r *http.Request) actor {
|
||||
act, _ := r.Context().Value(actorKey{}).(actor)
|
||||
return act
|
||||
}
|
||||
+266
-78
@@ -92,6 +92,10 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
|
||||
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
|
||||
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
|
||||
// 用户管理:站主专属(多用户与角色)
|
||||
mux.HandleFunc("/api/admin/users", a.guardOwner(a.users))
|
||||
mux.HandleFunc("/api/admin/users/", a.guardOwner(a.userByID))
|
||||
mux.HandleFunc("/api/admin/account/password", a.guard(a.changePassword))
|
||||
// 账户页:资料 + 身份绑定 + passkey
|
||||
mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
@@ -129,23 +133,7 @@ func (a *API) Routes() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// guard requires a valid session; the token may arrive as a cookie (browser)
|
||||
// or as a Bearer token (CLI / API client).
|
||||
func (a *API) guard(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" || !a.valid(token) {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
// guard / actor 解析在 actor.go:token 只带用户 ID,角色每请求从库现读。
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
@@ -155,11 +143,6 @@ func bearer(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *API) valid(token string) bool {
|
||||
_, err := a.Sessions.Verify(token)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -167,38 +150,65 @@ type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// verifyAdmin 校验登录凭据,返回应发会话的用户名。
|
||||
// 密码有两个可能的家:settings 表里的 bcrypt 哈希(后台「管理员账号」里改的,
|
||||
// 优先),和 ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在
|
||||
// 同一台机器上,能读 env 的人本来就能直接改库,不算额外开口子)。
|
||||
// 没显式设 env 密码时是 InsecureDev(admin/admin),一旦后台改过密码就失效。
|
||||
func (a *API) verifyAdmin(username, password string) (string, bool) {
|
||||
// verifyAdmin 校验登录凭据,返回应发会话的后台账号行。
|
||||
//
|
||||
// 第一优先:users 表里的后台账号(owner / admin,行内 bcrypt 哈希)——
|
||||
// 多用户体系的主路径,站主在后台改过密码后哈希就在自己那行上。
|
||||
// 兜底:引导链。settings 里的哈希(旧版「管理员账号」写入的)或
|
||||
// ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在同一台机器上,
|
||||
// 能读 env 的人本来就能直接改库);首次登录成功时把引导凭据自迁移成
|
||||
// owner 行的哈希,此后引导键不再参与。没显式设 env 密码时是 InsecureDev
|
||||
// (admin/admin),只在 owner 行还没有哈希时有效——后台一旦设过密码即失效。
|
||||
func (a *API) verifyAdmin(username, password string) (model.Reader, bool) {
|
||||
// 1) 库里后台账号
|
||||
if u, err := a.Store.GetStaffByHandle(username); err == nil && !u.Banned && u.PasswordHash != "" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 引导链:只认 owner 用户名(settings 覆盖值或 env 默认值)
|
||||
c := a.cfg()
|
||||
envUser := c.AdminUser
|
||||
user := envUser
|
||||
var hash string
|
||||
if st, err := a.Store.GetSettings(); err == nil {
|
||||
if st.AdminUsername != "" {
|
||||
user = st.AdminUsername
|
||||
}
|
||||
hash = st.AdminPasswordHash
|
||||
ownerName := c.AdminUser // Overlay 已把 settings 的 admin_username 叠进来
|
||||
owner, oerr := a.Store.EnsureOwner(ownerName)
|
||||
if oerr != nil {
|
||||
return model.Reader{}, false
|
||||
}
|
||||
if hash != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte(user)) == 1 &&
|
||||
bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
|
||||
return user, true
|
||||
if username != owner.Handle {
|
||||
return model.Reader{}, false
|
||||
}
|
||||
|
||||
// env 显式密码:永久后路(无论行内是否有哈希)
|
||||
if !c.InsecureDev && subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1 {
|
||||
a.ensureOwnerHash(owner, password)
|
||||
return owner, true
|
||||
}
|
||||
// 行内还没有哈希 → 首次登录引导:收 settings 哈希或 dev 密码,写行。
|
||||
// settings 哈希存在时 dev 密码(admin/admin)不再生效——那是真的库内密码。
|
||||
if owner.PasswordHash == "" {
|
||||
st, serr := a.Store.GetSettings()
|
||||
hasLegacyHash := serr == nil && st.AdminPasswordHash != ""
|
||||
if hasLegacyHash &&
|
||||
bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(password)) == nil {
|
||||
a.ensureOwnerHash(owner, password)
|
||||
return owner, true
|
||||
}
|
||||
// 哈希只对 DB 用户名生效;env 密码作后路时继续走下面的比对
|
||||
if c.InsecureDev {
|
||||
return "", false
|
||||
if !hasLegacyHash && c.InsecureDev && password == c.AdminPass {
|
||||
a.ensureOwnerHash(owner, password)
|
||||
return owner, true
|
||||
}
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(envUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
return "", false
|
||||
return model.Reader{}, false
|
||||
}
|
||||
|
||||
// ensureOwnerHash 把引导凭据落成 owner 行的 bcrypt 哈希(自迁移)。
|
||||
// 走 EnsureStaffHash:只在行内为空时写,不覆盖后台改过的密码。
|
||||
func (a *API) ensureOwnerHash(owner model.Reader, password string) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return envUser, true
|
||||
_ = a.Store.EnsureStaffHash(owner.ID, string(h))
|
||||
}
|
||||
|
||||
func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -223,7 +233,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.limiter().Reset(key)
|
||||
token, exp := a.Sessions.Issue(user)
|
||||
token, exp := a.Sessions.Issue(user.ID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
@@ -234,7 +244,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
Expires: exp,
|
||||
MaxAge: a.Sessions.TTL(),
|
||||
})
|
||||
httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339)})
|
||||
httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339), "role": user.Role, "user": user.Handle})
|
||||
}
|
||||
|
||||
const rfc3339 = "2006-01-02T15:04:05Z07:00"
|
||||
@@ -253,22 +263,197 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
// 会话里带着登录时的用户名(后台可改 admin_username,不能只看配置)
|
||||
name := ""
|
||||
if token := bearer(r); token != "" {
|
||||
if u, err := a.Sessions.Verify(token); err == nil {
|
||||
name = u
|
||||
// guard 已解析过 actor;这里直接回带角色,前端据此显隐「用户管理」等入口
|
||||
act := actorFrom(r)
|
||||
httpx.OK(w, map[string]any{"user": act.Handle, "role": act.Role})
|
||||
}
|
||||
|
||||
// ---------- users(多用户管理,站主专属) ----------
|
||||
|
||||
// users:GET 列表 / POST 创建内容管理员。
|
||||
func (a *API) users(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list, err := a.Store.ListStaff()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
} else if c, err := r.Cookie(cookieName); err == nil {
|
||||
if u, err := a.Sessions.Verify(c.Value); err == nil {
|
||||
name = u
|
||||
httpx.OK(w, map[string]any{"users": list})
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Username)
|
||||
if !validStaffName(name) {
|
||||
httpx.BadRequest(w, "用户名限 2-32 位,字母 / 数字 / _ - . @")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 6 || len(in.Password) > 72 {
|
||||
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
u, err := a.Store.CreateStaff(name, string(hash))
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
httpx.Error(w, http.StatusConflict, "用户名已被占用")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.Created(w, u)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
|
||||
}
|
||||
}
|
||||
|
||||
// userByID:PATCH(重置密码 / 停用恢复)/ DELETE。owner 行不可动,自己
|
||||
// 不能停用自己(会把自己锁在会话外面)。
|
||||
func (a *API) userByID(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/"))
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(w, "bad user id")
|
||||
return
|
||||
}
|
||||
target, terr := a.Store.GetUserByID(id)
|
||||
if terr != nil || target.Provider != "admin" {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if target.Role == model.RoleOwner {
|
||||
httpx.BadRequest(w, "站主账号不可在此修改")
|
||||
return
|
||||
}
|
||||
act := actorFrom(r)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
var in struct {
|
||||
Password *string `json:"password"`
|
||||
Banned *bool `json:"banned"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.Password != nil && *in.Password != "" {
|
||||
if len(*in.Password) < 6 || len(*in.Password) > 72 {
|
||||
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetStaffPassword(id, string(hash)); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Banned != nil {
|
||||
if *in.Banned && id == act.ID {
|
||||
httpx.BadRequest(w, "不能停用自己")
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetStaffBanned(id, *in.Banned); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
u, err := a.Store.GetUserByID(id)
|
||||
if err != nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, u)
|
||||
case http.MethodDelete:
|
||||
if id == act.ID {
|
||||
httpx.BadRequest(w, "不能删除自己")
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeleteStaff(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PATCH/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
// validStaffName 用户名白名单:2-32 位,字母数字与 _ - . @。
|
||||
func validStaffName(s string) bool {
|
||||
if len(s) < 2 || len(s) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
|
||||
c == '_' || c == '-' || c == '.' || c == '@'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// changePassword 自己改自己的密码(owner / admin 通用)。
|
||||
// 旧密码只在行内已有哈希时校验——OAuth / Passkey 直接登录、从没设过密码的
|
||||
// 账号第一次设密码不需要旧密码。
|
||||
func (a *API) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch && r.Method != http.MethodPut {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PATCH required")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Old string `json:"old_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if len(in.New) < 6 || len(in.New) > 72 {
|
||||
httpx.BadRequest(w, "新密码长度需在 6-72 位之间")
|
||||
return
|
||||
}
|
||||
act := actorFrom(r)
|
||||
u, err := a.Store.GetUserByID(act.ID)
|
||||
if err != nil {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"user": name})
|
||||
if u.PasswordHash != "" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Old)) != nil {
|
||||
httpx.Error(w, http.StatusForbidden, "旧密码不对")
|
||||
return
|
||||
}
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetStaffPassword(u.ID, string(hash)); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
@@ -611,6 +796,19 @@ func (a *API) credentialMeta() (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for _, ck := range credKeys {
|
||||
src := "unset"
|
||||
if ck.key == "admin_password_hash" {
|
||||
// 多用户后密码在 owner 行上;settings 键只是旧版遗留引导,
|
||||
// 行上有哈希就以此为准
|
||||
if owner, err := a.Store.EnsureOwner(c.AdminUser); err == nil && owner.PasswordHash != "" {
|
||||
src = "db"
|
||||
} else if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
|
||||
src = "db"
|
||||
} else {
|
||||
src = "env" // env 显式密码或 dev 默认,都算「有生效值」
|
||||
}
|
||||
out[ck.key] = map[string]any{"set": src != "unset", "source": src}
|
||||
continue
|
||||
}
|
||||
if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
|
||||
src = "db"
|
||||
} else if ck.envOf(c) != "" {
|
||||
@@ -653,37 +851,27 @@ func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
|
||||
case http.MethodPut, http.MethodPost:
|
||||
// 系统设置(含凭据与管理员用户名)是站主的权限;内容管理员只读
|
||||
if act := actorFrom(r); act.Role != model.RoleOwner {
|
||||
httpx.Error(w, http.StatusForbidden, "需要站主权限")
|
||||
return
|
||||
}
|
||||
var in settingsPut
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
// admin_password 走单独通道:明文只在请求里出现一次,落库前哈希
|
||||
// 密码变更不走这里:多用户后密码在 users 行上,
|
||||
// 自己改走 /api/admin/account/password,重置别人走 /api/admin/users/{id}
|
||||
for k, v := range in.Secrets {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue // 留空 = 不改
|
||||
}
|
||||
if !writableSecrets[k] && k != "admin_password" {
|
||||
if !writableSecrets[k] {
|
||||
httpx.BadRequest(w, "unknown secret key: "+k)
|
||||
return
|
||||
}
|
||||
if k == "admin_password" {
|
||||
if len(v) < 6 || len(v) > 72 {
|
||||
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
|
||||
return
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(v), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetSetting("admin_password_hash", string(h)); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := a.Store.SetSetting(k, v); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
|
||||
@@ -242,18 +242,24 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) {
|
||||
return rec
|
||||
}
|
||||
|
||||
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文
|
||||
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文。
|
||||
// 密码不再走 settings(多用户后在 users 行上),改走 account/password 端点。
|
||||
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
|
||||
"secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`)
|
||||
"secrets":{"github_client_secret":"db-sec"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "db-sec") || strings.Contains(rec.Body.String(), "newpass1") {
|
||||
if strings.Contains(rec.Body.String(), "db-sec") {
|
||||
t.Fatal("secret echoed back in plaintext")
|
||||
}
|
||||
// admin_password 已退役:出现即 400
|
||||
if rec := put(`{"secrets":{"admin_password":"newpass1"}}`); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("legacy admin_password: got %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
|
||||
// 未写的项回落 env
|
||||
// 未写的项回落 env;密码此时还在 env 引导链上(首次登录已自迁移成行哈希,
|
||||
// 来源也是 db)
|
||||
if meta, err := a.credentialMeta(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
@@ -279,9 +285,23 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) {
|
||||
t.Errorf("secret overlay failed: %q", c.GitHubClientSecret)
|
||||
}
|
||||
|
||||
// DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行
|
||||
// 自己改密码:旧密码错被拒,对了立即生效;显式 env 密码仍作后路
|
||||
patch := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/account/password", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
if rec := patch(`{"old_password":"wrong","new_password":"newpass1"}`); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("wrong old password: got %d, want 403", rec.Code)
|
||||
}
|
||||
if rec := patch(`{"old_password":"s3cret","new_password":"newpass1"}`); rec.Code != http.StatusOK {
|
||||
t.Errorf("change password: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should work right after save")
|
||||
t.Error("row password should work right after change")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "s3cret"); !ok {
|
||||
t.Error("explicit env password should stay as backstop")
|
||||
@@ -289,30 +309,33 @@ func TestSettingsCredentialsRoundTrip(t *testing.T) {
|
||||
if _, ok := a.verifyAdmin("admin", "wrong"); ok {
|
||||
t.Error("wrong password must fail")
|
||||
}
|
||||
|
||||
// admin_password 6 位下限
|
||||
if rec := put(`{"secrets":{"admin_password":"123"}}`); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("short password: got %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码
|
||||
// admin/admin 必须立刻失效。
|
||||
// InsecureDev(env 未显式设密码)时 admin/admin 有效;首次登录会把引导凭据
|
||||
// 自迁移成 owner 行哈希——之后站主改了密码(行哈希更新),admin/admin 即失效。
|
||||
func TestInsecureDevDisabledByDBPassword(t *testing.T) {
|
||||
a, _ := newTestAPI(t)
|
||||
a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true}
|
||||
if _, ok := a.verifyAdmin("admin", "admin"); !ok {
|
||||
t.Fatal("insecure default should work before any password is set")
|
||||
}
|
||||
h := bcryptHash(t, "newpass1")
|
||||
if err := a.Store.SetSetting("admin_password_hash", h); err != nil {
|
||||
owner, err := a.Store.EnsureOwner("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner.PasswordHash == "" {
|
||||
t.Fatal("first login should migrate bootstrap credentials onto the owner row")
|
||||
}
|
||||
if err := a.Store.SetStaffPassword(owner.ID, bcryptHash(t, "newpass1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "admin"); ok {
|
||||
t.Error("admin/admin must stop working once a DB password exists")
|
||||
t.Error("admin/admin must stop working once a row password exists")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should be accepted")
|
||||
t.Error("row password should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,3 +347,109 @@ func bcryptHash(t *testing.T, pw string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 多用户与角色:owner 创建 admin 账号;admin 能进内容接口,
|
||||
// 动不了系统设置与用户管理;被停用后存量会话立即失效。
|
||||
func TestMultiUserLifecycle(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
tokOf := func(user, pass string) string {
|
||||
rec := login(t, h, user, pass)
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %s: got %d body=%s", user, rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
|
||||
t.Fatalf("login %s: no token: %v", user, err)
|
||||
}
|
||||
if user == "admin" && out.Role != model.RoleOwner {
|
||||
t.Errorf("owner role = %q", out.Role)
|
||||
}
|
||||
return out.Token
|
||||
}
|
||||
reqAs := func(tok, method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
owner := tokOf("admin", "s3cret")
|
||||
|
||||
// owner 创建内容管理员
|
||||
rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create staff: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 用户名占用
|
||||
if rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`); rec.Code != http.StatusConflict {
|
||||
t.Errorf("duplicate username: got %d, want 409", rec.Code)
|
||||
}
|
||||
|
||||
// staff 登录,角色是 admin
|
||||
staff := tokOf("editor", "editor1")
|
||||
|
||||
// 内容接口可进
|
||||
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("staff read posts: got %d", rec.Code)
|
||||
}
|
||||
// 系统设置写不了、用户管理进不去
|
||||
if rec := reqAs(staff, http.MethodPut, "/api/admin/settings", `{"site_title":"x"}`); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("staff put settings: got %d, want 403", rec.Code)
|
||||
}
|
||||
if rec := reqAs(staff, http.MethodGet, "/api/admin/users", ""); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("staff list users: got %d, want 403", rec.Code)
|
||||
}
|
||||
if rec := reqAs(staff, http.MethodPost, "/api/admin/users", `{"username":"x2","password":"xxxxxx"}`); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("staff create user: got %d, want 403", rec.Code)
|
||||
}
|
||||
|
||||
// owner 重置 staff 密码后,新密码立即生效
|
||||
staffID := 0
|
||||
list := reqAs(owner, http.MethodGet, "/api/admin/users", "")
|
||||
var lu struct {
|
||||
Users []model.Reader `json:"users"`
|
||||
}
|
||||
_ = json.NewDecoder(list.Body).Decode(&lu)
|
||||
for _, u := range lu.Users {
|
||||
if u.Handle == "editor" {
|
||||
staffID = int(u.ID)
|
||||
if u.Role != model.RoleAdmin {
|
||||
t.Errorf("staff role = %q, want admin", u.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
if staffID == 0 {
|
||||
t.Fatal("editor not in staff list")
|
||||
}
|
||||
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)),
|
||||
`{"password":"reset99"}`); rec.Code != http.StatusOK {
|
||||
t.Errorf("reset password: got %d", rec.Code)
|
||||
}
|
||||
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusOK {
|
||||
t.Errorf("login with reset password: got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 停用后存量会话立即 401;owner 行与自身不可停用 / 不可删
|
||||
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"banned":true}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("ban staff: got %d", rec.Code)
|
||||
}
|
||||
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("banned staff session: got %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("banned staff login: got %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/"+itoa(int64(staffID)), ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("delete staff: got %d", rec.Code)
|
||||
}
|
||||
// owner 行自我保护
|
||||
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/1", ""); rec.Code == http.StatusOK {
|
||||
t.Error("owner row must not be deletable")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sessions are stateless: base64("user:expiryUnix") + "." + HMAC-SHA256.
|
||||
// They survive restarts as long as ONE_SECRET stays the same.
|
||||
// Sessions are stateless: base64("user:<id>:expiryUnix") + "." + HMAC-SHA256.
|
||||
// They survive restarts as long as ONE_SECRET stays the same. The token only
|
||||
// carries the user's DB id — role / ban state is read fresh from the database
|
||||
// on every request, so demotions and bans take effect immediately.
|
||||
type Sessions struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
@@ -27,37 +29,41 @@ func NewSessions(secret string, ttl time.Duration) *Sessions {
|
||||
|
||||
var ErrBadSession = errors.New("invalid session")
|
||||
|
||||
func (s *Sessions) Issue(user string) (string, time.Time) {
|
||||
func (s *Sessions) Issue(userID int64) (string, time.Time) {
|
||||
exp := time.Now().Add(s.ttl)
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(user + ":" + strconv.FormatInt(exp.Unix(), 10)))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("user:%d:%d", userID, exp.Unix())))
|
||||
return payload + "." + s.sign(payload), exp
|
||||
}
|
||||
|
||||
func (s *Sessions) Verify(token string) (string, error) {
|
||||
func (s *Sessions) Verify(token string) (int64, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return "", ErrBadSession
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) {
|
||||
return "", ErrBadSession
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", ErrBadSession
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
i := strings.LastIndex(string(raw), ":")
|
||||
if i <= 0 {
|
||||
return "", ErrBadSession
|
||||
// user:<id>:<exp>
|
||||
f := strings.Split(string(raw), ":")
|
||||
if len(f) != 3 || f[0] != "user" {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
user := string(raw)[:i]
|
||||
expUnix, err := strconv.ParseInt(string(raw)[i+1:], 10, 64)
|
||||
id, err := strconv.ParseInt(f[1], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
expUnix, err := strconv.ParseInt(f[2], 10, 64)
|
||||
if err != nil {
|
||||
return "", ErrBadSession
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
if time.Now().After(time.Unix(expUnix, 0)) {
|
||||
return "", ErrBadSession
|
||||
return 0, ErrBadSession
|
||||
}
|
||||
return user, nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Sessions) sign(payload string) string {
|
||||
|
||||
@@ -142,7 +142,7 @@ func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, ex
|
||||
u, err := a.Store.GetUserByIdentity(provider, externUID)
|
||||
switch {
|
||||
case err == nil && u.Role == model.RoleOwner:
|
||||
a.issueAdminSession(w, r)
|
||||
a.issueAdminSession(w, r, u.ID)
|
||||
return ""
|
||||
case err == nil:
|
||||
return a.issueReaderSession(w, r, u.ID)
|
||||
@@ -186,8 +186,8 @@ func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string {
|
||||
// issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」
|
||||
// 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下
|
||||
// 浏览器会把它当不安全 cookie 丢掉。
|
||||
func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) {
|
||||
token, exp := a.AdminSessions.Issue(a.cfg().AdminUser)
|
||||
func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request, userID int64) {
|
||||
token, exp := a.AdminSessions.Issue(userID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName, Value: token, Path: "/", HttpOnly: true,
|
||||
Secure: isTLS(r), SameSite: http.SameSiteLaxMode,
|
||||
@@ -271,7 +271,7 @@ func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if u.Role == model.RoleOwner {
|
||||
a.issueAdminSession(w, r)
|
||||
a.issueAdminSession(w, r, u.ID)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ import (
|
||||
// 两个 API 之间不该为了测试互相依赖。
|
||||
type fakeAdmin struct{ valid map[string]bool }
|
||||
|
||||
func (f fakeAdmin) Verify(token string) (string, error) {
|
||||
func (f fakeAdmin) Verify(token string) (int64, error) {
|
||||
if f.valid[token] {
|
||||
return "admin", nil
|
||||
return 1, nil
|
||||
}
|
||||
return "", errors.New("bad session")
|
||||
return 0, errors.New("bad session")
|
||||
}
|
||||
func (f fakeAdmin) Issue(string) (string, time.Time) {
|
||||
func (f fakeAdmin) Issue(int64) (string, time.Time) {
|
||||
return "issued-admin-token", time.Now().Add(time.Hour)
|
||||
}
|
||||
func (f fakeAdmin) TTL() int { return 3600 }
|
||||
|
||||
@@ -38,11 +38,11 @@ type API struct {
|
||||
// 评论区读者会话与 GitHub OAuth(main.go 装配)
|
||||
ReaderSessions *auth.ReaderSessions
|
||||
// AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
|
||||
// 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话,
|
||||
// 所以除了 Verify 还要 Issue/TTL。
|
||||
// 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话。
|
||||
// token 只带用户 ID(多用户后角色每请求查库,改角色 / 停用即时生效)。
|
||||
AdminSessions interface {
|
||||
Verify(token string) (string, error)
|
||||
Issue(user string) (string, time.Time)
|
||||
Verify(token string) (int64, error)
|
||||
Issue(userID int64) (string, time.Time)
|
||||
TTL() int
|
||||
}
|
||||
// Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放)
|
||||
|
||||
@@ -42,7 +42,8 @@ func (a *API) readerID(r *http.Request) (int64, bool) {
|
||||
}
|
||||
|
||||
// resolveReader 解出当前访客的读者身份:读者会话优先,
|
||||
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
|
||||
// 其次是后台管理员会话——管理员用自己 users 行上的身份发言
|
||||
// (多用户后 owner / admin 各自署名,不再都挂在站主名下)。
|
||||
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
|
||||
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
|
||||
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
|
||||
@@ -54,9 +55,17 @@ func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
|
||||
}
|
||||
if a.AdminSessions != nil {
|
||||
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
|
||||
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
|
||||
rd, oerr := a.ownerReader()
|
||||
if oerr == nil {
|
||||
if id, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
|
||||
rd, gerr := a.Store.GetReader(id)
|
||||
// 行被删 / 被停用的后台账号:会话当作不存在,
|
||||
// 不能落到站主身份上顶名发言
|
||||
if gerr == nil && rd.Provider == "admin" && !rd.Banned {
|
||||
if rd.Name == "" {
|
||||
// 首次发言补一次署名(站点作者名)
|
||||
if filled, ferr := a.ownerReader(); ferr == nil {
|
||||
return filled, true, nil
|
||||
}
|
||||
}
|
||||
return rd, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||||
// 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话
|
||||
if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil {
|
||||
if u.Role == model.RoleOwner {
|
||||
a.issueAdminSession(w, r)
|
||||
a.issueAdminSession(w, r, u.ID)
|
||||
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,9 +264,11 @@ type Settings struct {
|
||||
AdminPasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
// 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。
|
||||
// 账号角色。owner 全库唯一(站主),admin 是站主在后台创建的内容管理员
|
||||
// (可管内容、不可动系统设置与用户),reader 是评论区登录进来的访客。
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleAdmin = "admin"
|
||||
RoleReader = "reader"
|
||||
)
|
||||
|
||||
@@ -281,8 +283,12 @@ type Reader struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
URL string `json:"url"`
|
||||
Banned bool `json:"banned"`
|
||||
// Role 区分站主与访客:绑定到 owner 的第三方身份登录时会话升级为管理员。
|
||||
// Role 区分站主、内容管理员与访客:绑定到 owner 的第三方身份登录时
|
||||
// 会话升级为管理员。
|
||||
Role string `json:"role"`
|
||||
// PasswordHash 只属于后台账号(provider=admin,owner/admin 两级),
|
||||
// bcrypt 哈希从不离开服务端;reader 恒为空串。
|
||||
PasswordHash string `json:"-"`
|
||||
// CommentCount 是累计评论数(后台用户列表展示用)
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
@@ -166,6 +166,9 @@ func (s *Store) migrate() error {
|
||||
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
|
||||
// 账号角色:owner(站主,全库唯一)/ reader(评论区访客)
|
||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
|
||||
// 后台账号密码(provider=admin 的行才有值):多用户改造后密码上库,
|
||||
// env / settings 只作首次引导
|
||||
`ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, q := range columnAdds {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
|
||||
@@ -1809,11 +1812,12 @@ func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
|
||||
}
|
||||
|
||||
// userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列
|
||||
// 就要同步改一遍(漏一处就是扫错位置)。
|
||||
const userCols = `id,provider,handle,name,avatar_url,url,banned,role,created_at`
|
||||
// 就要同步改一遍(漏一处就是扫错位置)。password_hash 只在后台账号路径用,
|
||||
// reader 恒为空串,带上无害。
|
||||
const userCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,created_at`
|
||||
|
||||
// userColsU 是 JOIN 查询里带 u. 前缀的同一份列清单。和 userCols 成对改。
|
||||
const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at`
|
||||
const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.password_hash,u.created_at`
|
||||
|
||||
func (s *Store) GetReader(id int64) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
|
||||
@@ -1916,6 +1920,105 @@ func (s *Store) GetOwner() (model.Reader, error) {
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ---------- 后台账号(多用户):provider=admin 的行,owner / admin 两级 ----------
|
||||
|
||||
const staffCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,created_at`
|
||||
|
||||
func scanStaff(sc interface{ Scan(...any) error }) (model.Reader, error) {
|
||||
var u model.Reader
|
||||
if err := sc.Scan(&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL,
|
||||
&u.Banned, &u.Role, &u.PasswordHash, &u.CreatedAt); err != nil {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListStaff 后台账号列表:站主在前、其余按创建时间。
|
||||
func (s *Store) ListStaff() ([]model.Reader, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT ` + staffCols + ` FROM users
|
||||
WHERE provider = 'admin' ORDER BY CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Reader{}
|
||||
for rows.Next() {
|
||||
u, err := scanStaff(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetStaffByHandle 按用户名取后台账号(登录用)。只在带密码哈希的行上有意义。
|
||||
func (s *Store) GetStaffByHandle(handle string) (model.Reader, error) {
|
||||
u, err := scanStaff(s.db.QueryRow(s.db.Q(`SELECT `+staffCols+` FROM users
|
||||
WHERE provider = 'admin' AND handle = ?`), handle))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetUserByID 按主键取任意用户(含 reader)——会话校验用:token 只带 ID,
|
||||
// 角色每请求从库里现读,改角色 / 禁用即时生效。
|
||||
func (s *Store) GetUserByID(id int64) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// CreateStaff 站主创建内容管理员:provider=admin、role=admin。
|
||||
// 用户名占用返回 ErrConflict。
|
||||
func (s *Store) CreateStaff(handle, passwordHash string) (model.Reader, error) {
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,password_hash,created_at)
|
||||
VALUES ('admin',?,'','','',0,'admin',?,?)`), handle, passwordHash, now()); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate key") {
|
||||
return model.Reader{}, ErrConflict
|
||||
}
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return s.GetStaffByHandle(handle)
|
||||
}
|
||||
|
||||
// SetStaffPassword 改后台账号密码(用户管理重置 / 自己修改)。
|
||||
func (s *Store) SetStaffPassword(id int64, hash string) error {
|
||||
_, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ? WHERE id = ? AND provider = 'admin'`), hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureStaffHash 只在行内还没有哈希时写入:首次登录把 env / settings 的
|
||||
// 引导凭据自迁移成行内哈希。已有哈希(站主后台改过密码)绝不能被
|
||||
// env 后路登录覆盖——这正是它和 SetStaffPassword 的区别。
|
||||
func (s *Store) EnsureStaffHash(id int64, hash string) error {
|
||||
_, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ?
|
||||
WHERE id = ? AND provider = 'admin' AND (password_hash = '' OR password_hash IS NULL)`), hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetStaffBanned 停用 / 恢复后台账号(禁用后既不能登录,存量会话也会在
|
||||
// guard 查库时被拒)。
|
||||
func (s *Store) SetStaffBanned(id int64, banned bool) error {
|
||||
_, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ? AND provider = 'admin' AND role != 'owner'`), b2i(banned), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteStaff 删除后台账号。owner 行不可删(全库唯一锚点)。
|
||||
func (s *Store) DeleteStaff(id int64) error {
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM users WHERE id = ? AND provider = 'admin' AND role != 'owner'`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindIdentity 把 (provider, extern_uid) 绑到某个用户上。
|
||||
// 外部账号已被别人占用时返回 ErrConflict —— 调用方必须原样拒绝,
|
||||
// 不能「后来者覆盖」,否则任何人都能抢先把别人的 GitHub 账号登记成自己的。
|
||||
@@ -2056,7 +2159,7 @@ func b2i(b bool) int64 {
|
||||
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
|
||||
var r model.Reader
|
||||
var banned int64
|
||||
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.CreatedAt)
|
||||
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.PasswordHash, &r.CreatedAt)
|
||||
r.Banned = banned == 1
|
||||
return r, err
|
||||
}
|
||||
|
||||
@@ -18,13 +18,39 @@ const uploading = ref(false)
|
||||
const regBusy = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
// 修改登录密码(owner / admin 都在这里改;密码存在自己的账号行上)
|
||||
const pw = ref({ old: '', next: '', confirm: '' })
|
||||
const pwBusy = ref(false)
|
||||
async function changePassword() {
|
||||
if (pw.value.next !== pw.value.confirm) {
|
||||
toastErr('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
if (pw.value.next.length < 6) {
|
||||
toastErr('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
pwBusy.value = true
|
||||
try {
|
||||
await adminApi.changePassword({ old_password: pw.value.old, new_password: pw.value.next })
|
||||
toastOk('密码已更新,下次登录用新密码')
|
||||
pw.value = { old: '', next: '', confirm: '' }
|
||||
} catch (e) {
|
||||
toastErr(e.message || '修改失败')
|
||||
} finally {
|
||||
pwBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function load() {
|
||||
console.warn('ACCT: load() start')
|
||||
loading.value = true
|
||||
try {
|
||||
acct.value = await adminApi.account()
|
||||
console.warn('ACCT: loaded', !!acct.value)
|
||||
} catch (e) {
|
||||
toastErr(e.message || '读取账户信息失败')
|
||||
} finally {
|
||||
@@ -185,8 +211,8 @@ onMounted(async () => {
|
||||
<label>用户名(handle)</label>
|
||||
<input :value="acct.handle" class="input" disabled />
|
||||
<p class="field-hint">
|
||||
登录用户名在「设置 → 登录与存储」里改;这里是站主在评论区的
|
||||
身份标识(handle),改它要连登录名一起换。
|
||||
后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改,
|
||||
管理员账号由站主在「用户」页创建。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +221,26 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录密码 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录密码</h2></div>
|
||||
<div class="field">
|
||||
<label>旧密码</label>
|
||||
<input v-model="pw.old" class="input" type="password" autocomplete="current-password" placeholder="从未设过密码可不填" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>新密码(6-72 位)</label>
|
||||
<input v-model="pw.next" class="input" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="pw.confirm" class="input" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<button class="btn btn-primary" :disabled="pwBusy" @click="changePassword">
|
||||
{{ pwBusy ? '提交中…' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录方式 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
|
||||
@@ -63,6 +63,7 @@ const crumb = computed(() => {
|
||||
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
|
||||
if (p.endsWith('/files')) return '文件'
|
||||
if (p.endsWith('/comments')) return '评论'
|
||||
if (p.endsWith('/users')) return '用户'
|
||||
if (p.endsWith('/tags')) return '标签'
|
||||
if (p.endsWith('/settings')) return '设置'
|
||||
if (p === '/admin' || p === '/admin/') return '总览'
|
||||
@@ -150,6 +151,11 @@ watch(() => route.path, () => {
|
||||
<span class="ic">◉</span>
|
||||
<span>账户</span>
|
||||
</RouterLink>
|
||||
<!-- 用户管理是站主专属:内容管理员(admin)看不到这个入口 -->
|
||||
<RouterLink v-if="session.isOwner" to="/admin/users" class="item">
|
||||
<span class="ic">◍</span>
|
||||
<span>用户</span>
|
||||
</RouterLink>
|
||||
<div class="group">前台</div>
|
||||
<a href="/" target="_blank" class="item">
|
||||
<span class="ic">↗</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ async function submit() {
|
||||
try {
|
||||
const data = await adminApi.login(username.value, password.value)
|
||||
session.user = username.value
|
||||
session.role = data.role || 'admin'
|
||||
void data
|
||||
toastOk('登录成功')
|
||||
router.push('/admin')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
import { adminApi, session } from '../api'
|
||||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||||
import { SECTIONS } from '../ui/sections'
|
||||
|
||||
@@ -47,9 +47,6 @@ function secretPlaceholder(key) {
|
||||
return '未配置'
|
||||
}
|
||||
|
||||
// 管理员账号:新密码 + 确认,只在前端做一次一致性与长度预检
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
// ---------- 社交链接(settings.social_links <-> 多行文本) ----------
|
||||
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
|
||||
@@ -179,8 +176,6 @@ async function load() {
|
||||
const res = await adminApi.settings()
|
||||
settings.value = res.settings
|
||||
credMeta.value = res.credential_meta || {}
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
secrets.value = {}
|
||||
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
|
||||
settings.value.light_skin_id = 'paper'
|
||||
@@ -211,24 +206,12 @@ onMounted(load)
|
||||
|
||||
async function save() {
|
||||
if (!settings.value) return
|
||||
// 管理员改密码:两次输入一致才提交
|
||||
if (newPassword.value || confirmPassword.value) {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toastErr('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
toastErr('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value.social_links = textToSocial(socialText.value)
|
||||
// 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理)
|
||||
const payload = { ...settings.value, secrets: {} }
|
||||
if (newPassword.value) payload.secrets.admin_password = newPassword.value
|
||||
for (const f of secretFields) {
|
||||
const v = (secrets.value[f.key] || '').trim()
|
||||
if (v) payload.secrets[f.key] = v
|
||||
@@ -247,8 +230,6 @@ async function save() {
|
||||
}
|
||||
}
|
||||
secrets.value = {}
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
toastOk('设置已保存')
|
||||
} catch (e) {
|
||||
@@ -272,7 +253,7 @@ async function save() {
|
||||
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
|
||||
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
|
||||
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
|
||||
<button :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||||
<button v-if="session.isOwner" :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -493,8 +474,8 @@ async function save() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项) -->
|
||||
<div v-if="tab === 'system'" class="panel">
|
||||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项)。站主专属 -->
|
||||
<div v-if="tab === 'system' && session.isOwner" class="panel">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。
|
||||
@@ -569,25 +550,15 @@ async function save() {
|
||||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||||
<div class="panel-title"><h2>管理员账号</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
登录后台用的用户名与密码。在这里设置密码后,环境变量
|
||||
ONE_ADMIN_PASSWORD 只作解锁后路保留(仍可用);不填则维持现状。
|
||||
站主登录后台的用户名(改掉后环境变量 ONE_ADMIN_USER 不再生效)。
|
||||
密码在「账户」页修改;给其他人开账号去「用户」页。
|
||||
</p>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>用户名</label>
|
||||
<input v-model="settings.admin_username" class="input" spellcheck="false"
|
||||
:placeholder="srcOf('admin_username') === 'env' ? '当前来自环境变量 ONE_ADMIN_USER' : ''" />
|
||||
<p class="field-hint">{{ srcHint('admin_username') }}</p>
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>新密码</label>
|
||||
<input v-model="newPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="留空保持不变(6-72 位)" />
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="confirmPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="再输入一遍" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi, session } from '../api'
|
||||
import { formatDateShort } from '../utils'
|
||||
|
||||
// 用户管理(站主专属):创建内容管理员(admin 角色)、重置密码、停用 / 删除。
|
||||
// owner 行(站主本人)只读展示——它是「谁登录算站主」的锚点,密码走账户页改。
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const creating = ref(false)
|
||||
const form = ref({ username: '', password: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.users()
|
||||
users.value = data.users || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
await adminApi.createUser({ username: form.value.username.trim(), password: form.value.password })
|
||||
toastOk(`已创建 ${form.value.username.trim()}`)
|
||||
form.value = { username: '', password: '' }
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(u) {
|
||||
const pw = window.prompt(`给「${u.handle}」设置新密码(6-72 位):`)
|
||||
if (!pw) return
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { password: pw })
|
||||
toastOk('密码已重置')
|
||||
} catch (e) {
|
||||
toastErr(e.message || '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan(u) {
|
||||
if (u.banned) {
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { banned: false })
|
||||
toastOk(`已恢复「${u.handle}」`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`停用「${u.handle}」?停用后其立即无法登录,存量会话同步失效。`)) return
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { banned: true })
|
||||
toastOk('已停用')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u) {
|
||||
if (!window.confirm(`删除「${u.handle}」?其发布的评论保留,但无法再登录。`)) return
|
||||
try {
|
||||
await adminApi.deleteUser(u.id)
|
||||
toastOk('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = (r) => (r === 'owner' ? '站主' : '管理员')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="loading">载入中…</section>
|
||||
<section v-else-if="error" class="empty">{{ error }}</section>
|
||||
<section v-else>
|
||||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">用户</h1>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title"><h2>添加内容管理员</h2></div>
|
||||
<form style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;" @submit.prevent="create">
|
||||
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
|
||||
<label>用户名(2-32 位,字母 / 数字 / _ - . @)</label>
|
||||
<input v-model="form.username" class="input" spellcheck="false" autocomplete="off" placeholder="editor" />
|
||||
</div>
|
||||
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
|
||||
<label>初始密码(6-72 位)</label>
|
||||
<input v-model="form.password" class="input" type="password" autocomplete="new-password" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" :disabled="creating">添加</button>
|
||||
</form>
|
||||
<p class="field-hint" style="margin-top: 10px;">
|
||||
管理员可以写文章、管理评论与文件;系统设置、用户管理与第三方凭据只有你能动。
|
||||
他们自己的密码在「账户」页修改。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>后台账号</h2></div>
|
||||
<div class="recent-list">
|
||||
<div v-for="u in users" :key="u.id" class="recent-item" style="gap: 14px;">
|
||||
<span class="title" style="font-weight: 700;">{{ u.handle }}</span>
|
||||
<span class="meta" style="font-size: 12px;">{{ roleLabel(u.role) }}</span>
|
||||
<span v-if="u.banned" class="meta" style="color: #b4553f;">已停用</span>
|
||||
<span class="meta" style="margin-left: auto; white-space: normal; text-align: right;">
|
||||
{{ formatDateShort(u.created_at) }}
|
||||
</span>
|
||||
<span v-if="u.role !== 'owner'" style="display: flex; gap: 4px; flex: none;">
|
||||
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="resetPassword(u)">重置密码</button>
|
||||
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="toggleBan(u)">
|
||||
{{ u.banned ? '恢复' : '停用' }}
|
||||
</button>
|
||||
<button v-if="u.id !== 0" class="btn danger" style="font-size: 12px; padding: 4px 10px;" type="button" @click="remove(u)">删除</button>
|
||||
</span>
|
||||
<span v-else class="meta" style="flex: none;">站主本人 · 密码在账户页修改</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 12px;">
|
||||
当前登录:{{ session.user }}({{ roleLabel(session.role) }})。
|
||||
停用或删除后,对方的存量登录会话立即失效。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn.danger {color: #b4553f;}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
import { adminApi, session } from '../api'
|
||||
|
||||
// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效
|
||||
// 后台的登录态在服务端(httpOnly cookie);这里探测一次是否还有效,
|
||||
// 顺带把用户名与角色(owner / admin)存下来,供导航显隐用
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
await adminApi.me()
|
||||
const me = await adminApi.me()
|
||||
session.user = me.user || ''
|
||||
session.role = me.role || 'admin'
|
||||
return true
|
||||
} catch (e) {
|
||||
session.clear()
|
||||
|
||||
+19
-1
@@ -83,6 +83,11 @@ export const adminApi = {
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||||
users: () => request('/api/admin/users'),
|
||||
createUser: (body) => request('/api/admin/users', { method: 'POST', body }),
|
||||
patchUser: (id, body) => request('/api/admin/users/' + id, { method: 'PATCH', body }),
|
||||
deleteUser: (id) => request('/api/admin/users/' + id, { method: 'DELETE' }),
|
||||
changePassword: (body) => request('/api/admin/account/password', { method: 'PATCH', body }),
|
||||
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
|
||||
account: () => request('/api/admin/account'),
|
||||
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
|
||||
@@ -186,8 +191,10 @@ export const readerApi = {
|
||||
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
|
||||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存展示用的用户名与角色
|
||||
// (owner / admin,来自 /api/admin/me,控制「用户管理」等入口的显隐)
|
||||
const USER_KEY = 'one.admin.user'
|
||||
const ROLE_KEY = 'one.admin.role'
|
||||
|
||||
export const session = {
|
||||
get user() {
|
||||
@@ -197,7 +204,18 @@ export const session = {
|
||||
if (v) localStorage.setItem(USER_KEY, v)
|
||||
else localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
get role() {
|
||||
return localStorage.getItem(ROLE_KEY) || 'admin'
|
||||
},
|
||||
set role(v) {
|
||||
if (v) localStorage.setItem(ROLE_KEY, v)
|
||||
else localStorage.removeItem(ROLE_KEY)
|
||||
},
|
||||
get isOwner() {
|
||||
return this.role === 'owner'
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ const routes = [
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('./admin/UsersView.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user