// 账户页的后端:站主资料、身份绑定列表、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 { // 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证 // 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。 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{ // 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。 Name: firstNonEmptyStr(owner.Name, st.AuthorName), Bio: st.AuthorBio, AvatarKey: st.AuthorAvatarKey, AvatarURL: a.avatarURL(st.AuthorAvatarKey), Handle: owner.Handle, Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", 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 { switch name { case "github": return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != "" case "google": return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != "" case "telegram": return a.Cfg.TelegramBot != "" && a.Cfg.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 "" }