Files
ONE/backend/internal/admin/account.go
T
Sakurasan 4bb2ff4145 多用户与角色: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)
2026-10-01 22:35:37 +08:00

259 lines
7.4 KiB
Go

// 账户页的后端:站主资料、身份绑定列表、passkey 管理。
//
// 和「站点设置」的分工:站点设置管站点(标题、皮肤、评论开关),账户页管
// 「你是谁 + 你能用什么方式登录」。昵称/简介仍是 author_name/author_bio
// 两个 settings 键(单一来源,前端各处照旧读),头像另用 owner_avatar_key
// 单键写入,避开 UpdateSettings 的全量替换。
package admin
import (
"errors"
"net/http"
"strings"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
const maxAvatarKeyLen = 160
// accountView 是账户页一次拉取的全部数据。
type accountView struct {
Name string `json:"name"`
Bio string `json:"bio"`
AvatarKey string `json:"avatar_key"`
AvatarURL string `json:"avatar_url"`
Handle string `json:"handle"`
Password passwordInfo `json:"password"`
Identities []model.UserIdentity `json:"identities"`
Passkeys []model.Passkey `json:"passkeys"`
// Providers 告诉前端哪些平台可以绑(未配凭据的平台不出现)。
Providers []string `json:"providers"`
}
type passwordInfo struct {
// 多用户后密码统一在 users 行上(bcrypt),在账户页修改。
// 首次登录时 env / settings 的引导凭据会自迁移成行内哈希。
ManagedBy string `json:"managed_by"`
Username string `json:"username"`
}
func (a *API) account(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
return
}
v, err := a.buildAccount()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, v)
}
func (a *API) buildAccount() (accountView, error) {
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
if err != nil {
return accountView{}, err
}
st, err := a.Store.GetSettings()
if err != nil {
return accountView{}, err
}
ids, err := a.Store.ListIdentities(owner.ID)
if err != nil {
return accountView{}, err
}
pks, err := a.Store.ListPasskeys(owner.ID)
if err != nil {
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,
Password: passwordInfo{ManagedBy: "account", Username: a.cfg().AdminUser},
Identities: ids,
Passkeys: pks,
}
// 可绑定的平台:只有跳转式 OAuth 能在后台发起。Telegram 是评论区里的
// 登录 widget,后台没有它的入口,所以不进这个列表(已绑的记录仍会显示)。
for _, p := range []string{"github", "google"} {
if a.providerEnabled(p) {
v.Providers = append(v.Providers, p)
}
}
return v, nil
}
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
// 否则点了必然报错。凭据按有效配置算(后台系统设置优先于环境变量)。
func (a *API) providerEnabled(name string) bool {
c := a.cfg()
switch name {
case "github":
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
case "google":
return c.GoogleClientID != "" && c.GoogleClientSecret != ""
case "telegram":
return c.TelegramBot != "" && c.TelegramToken != ""
}
return false
}
// avatarURL 把 files key 解析成可访问 URL。key 指向的文件已删除时返回空串
// (前端会自动回落到站标),不留一个打不开的链接。
func (a *API) avatarURL(key string) string {
if key == "" {
return ""
}
f, err := a.Store.GetFileByKey(key)
if err != nil {
return ""
}
return storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
}
type patchAccountRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
AvatarKey *string `json:"avatar_key"`
}
// patchAccount 改资料。只动传了的字段;头像 key 必须是 files 表里真实存在的
// 图片,免得存一个指向任意字符串的死链。
func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
var in patchAccountRequest
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
changed := false
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if len([]rune(name)) > 40 {
httpx.BadRequest(w, "昵称最多 40 字")
return
}
if name == "" {
httpx.BadRequest(w, "昵称不能为空")
return
}
// 站主行与站点设置两处都要写:前者是身份来源,后者是既有前端读的地方。
if _, err := a.Store.UpdateProfile(owner.ID, name); err != nil {
httpx.ServerError(w, err)
return
}
st.AuthorName = name
changed = true
}
if in.Bio != nil {
bio := strings.TrimSpace(*in.Bio)
if len([]rune(bio)) > 200 {
httpx.BadRequest(w, "简介最多 200 字")
return
}
st.AuthorBio = bio
changed = true
}
if in.AvatarKey != nil {
key := strings.TrimSpace(*in.AvatarKey)
if len(key) > maxAvatarKeyLen {
httpx.BadRequest(w, "头像 key 过长")
return
}
if key != "" {
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.BadRequest(w, "头像文件不存在,请重新上传")
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if !strings.HasPrefix(f.Mime, "image/") {
httpx.BadRequest(w, "头像必须是图片")
return
}
}
st.AuthorAvatarKey = key
changed = true
}
if !changed {
httpx.BadRequest(w, "没有要更新的字段")
return
}
// AuthorAvatarURL 是算出来的,不入库;写库前清掉免得误读。
st.AuthorAvatarURL = ""
if err := a.Store.UpdateSettings(st); err != nil {
httpx.ServerError(w, err)
return
}
// 头像键单独写:UpdateSettings 是全量替换,不含这个键。
if in.AvatarKey != nil {
if err := a.Store.SetSetting("owner_avatar_key", st.AuthorAvatarKey); err != nil {
httpx.ServerError(w, err)
return
}
}
v, err := a.buildAccount()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, v)
}
// unbindIdentity 解绑一个第三方登录方式。
// 站主始终有环境变量密码兜底,所以这里不需要「不能解绑唯一登录方式」的护栏。
func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/identities/"), "/")
if provider == "" || strings.Contains(provider, "/") {
httpx.BadRequest(w, "bad provider")
return
}
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
if err := a.Store.UnbindIdentity(owner.ID, provider); 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})
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}