多用户与角色: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
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user