系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑 - config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效 - 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文, 只报「是否已配置、来自哪里」;留空保存 = 保持现值 - 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路; 后台改过密码后 admin/admin 开发模式立即失效 - 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示 - 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
This commit is contained in:
1 parent
bf3ce934dd
commit
e68400b389
23 files changed
+957
-126
No files matched your search
@@ -34,12 +34,20 @@ type accountView struct {
|
||||
}
|
||||
|
||||
type passwordInfo struct {
|
||||
// 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
|
||||
// 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
|
||||
// 密码现在有两个可能的家:settings 表里的 bcrypt 哈希(后台改的),
|
||||
// 或环境变量 ONE_ADMIN_PASSWORD(未迁移时的兜底)。
|
||||
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")
|
||||
@@ -54,7 +62,7 @@ func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) buildAccount() (accountView, error) {
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
@@ -72,12 +80,14 @@ func (a *API) buildAccount() (accountView, error) {
|
||||
}
|
||||
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},
|
||||
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},
|
||||
Identities: ids,
|
||||
Passkeys: pks,
|
||||
}
|
||||
@@ -92,15 +102,16 @@ func (a *API) buildAccount() (accountView, error) {
|
||||
}
|
||||
|
||||
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
|
||||
// 否则点了必然报错。
|
||||
// 否则点了必然报错。凭据按有效配置算(后台系统设置优先于环境变量)。
|
||||
func (a *API) providerEnabled(name string) bool {
|
||||
c := a.cfg()
|
||||
switch name {
|
||||
case "github":
|
||||
return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
|
||||
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
|
||||
case "google":
|
||||
return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
|
||||
return c.GoogleClientID != "" && c.GoogleClientSecret != ""
|
||||
case "telegram":
|
||||
return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
|
||||
return c.TelegramBot != "" && c.TelegramToken != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -115,7 +126,7 @@ func (a *API) avatarURL(key string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
return storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
|
||||
type patchAccountRequest struct {
|
||||
@@ -132,7 +143,7 @@ func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -228,7 +239,7 @@ func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "bad provider")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
|
||||
+187
-22
@@ -22,6 +22,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
@@ -34,15 +36,17 @@ import (
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Store *store.Store
|
||||
// Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config()
|
||||
// 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。
|
||||
Cfg *config.Config
|
||||
Res *config.Resolver
|
||||
Sessions *Sessions
|
||||
// Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
|
||||
// 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
|
||||
Hub *hub.Hub
|
||||
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
|
||||
Blobs storage.BlobStore
|
||||
PublicBase string
|
||||
// 文件上传的存储后端兜底(Res 未设置时使用;设置了走 Res.Blobs() 热重建)
|
||||
Blobs storage.BlobStore
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
|
||||
// 缩略图产物,否则已删图片会一直占着缓存。
|
||||
Thumbs *thumbs.Store
|
||||
@@ -163,6 +167,40 @@ 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) {
|
||||
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
|
||||
}
|
||||
if hash != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte(user)) == 1 &&
|
||||
bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
|
||||
return user, true
|
||||
}
|
||||
// 哈希只对 DB 用户名生效;env 密码作后路时继续走下面的比对
|
||||
if c.InsecureDev {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(envUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
return "", false
|
||||
}
|
||||
return envUser, true
|
||||
}
|
||||
|
||||
func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||
@@ -178,15 +216,14 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
user, ok := a.verifyAdmin(in.Username, in.Password)
|
||||
if !ok {
|
||||
a.limiter().Add(key)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
a.limiter().Reset(key)
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
token, exp := a.Sessions.Issue(user)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
@@ -216,7 +253,22 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.OK(w, map[string]any{"user": a.Cfg.AdminUser})
|
||||
// 会话里带着登录时的用户名(后台可改 admin_username,不能只看配置)
|
||||
name := ""
|
||||
if token := bearer(r); token != "" {
|
||||
if u, err := a.Sessions.Verify(token); err == nil {
|
||||
name = u
|
||||
}
|
||||
} else if c, err := r.Cookie(cookieName); err == nil {
|
||||
if u, err := a.Sessions.Verify(c.Value); err == nil {
|
||||
name = u
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"user": name})
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
@@ -522,6 +574,70 @@ func writeProject(w http.ResponseWriter, p model.Project, err error) {
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
// credKeys 是从环境变量迁进后台的系统设置项。
|
||||
// secret 为 true 的项 GET 时永远不回值,只报「是否已配置、来自哪里」。
|
||||
type credKey struct {
|
||||
key string // settings 表的键
|
||||
kind string // 前端字段名(= key)
|
||||
secret bool
|
||||
envOf func(c *config.Config) string
|
||||
}
|
||||
|
||||
var credKeys = []credKey{
|
||||
{key: "site_url", envOf: func(c *config.Config) string { return c.SiteURL }},
|
||||
{key: "github_client_id", envOf: func(c *config.Config) string { return c.GitHubClientID }},
|
||||
{key: "github_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GitHubClientSecret }},
|
||||
{key: "google_client_id", envOf: func(c *config.Config) string { return c.GoogleClientID }},
|
||||
{key: "google_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GoogleClientSecret }},
|
||||
{key: "telegram_bot", envOf: func(c *config.Config) string { return c.TelegramBot }},
|
||||
{key: "telegram_bot_token", secret: true, envOf: func(c *config.Config) string { return c.TelegramToken }},
|
||||
{key: "s3_endpoint", envOf: func(c *config.Config) string { return c.S3Endpoint }},
|
||||
{key: "r2_bucket", envOf: func(c *config.Config) string { return c.R2Bucket }},
|
||||
{key: "r2_access_key", secret: true, envOf: func(c *config.Config) string { return c.R2AccessKey }},
|
||||
{key: "r2_secret_key", secret: true, envOf: func(c *config.Config) string { return c.R2SecretKey }},
|
||||
{key: "uploads_public_base", envOf: func(c *config.Config) string { return c.UploadsPublicBase }},
|
||||
{key: "admin_username", envOf: func(c *config.Config) string { return c.AdminUser }},
|
||||
{key: "admin_password_hash", secret: true, envOf: func(*config.Config) string { return "" }},
|
||||
}
|
||||
|
||||
// credentialMeta 告诉前端每个迁移项的配置状态:
|
||||
// set=是否已有生效值,source=db(后台配的)/ env(环境变量兜底)/ unset。
|
||||
func (a *API) credentialMeta() (map[string]any, error) {
|
||||
m, err := a.Store.GetSettingsMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := a.cfg()
|
||||
out := map[string]any{}
|
||||
for _, ck := range credKeys {
|
||||
src := "unset"
|
||||
if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
|
||||
src = "db"
|
||||
} else if ck.envOf(c) != "" {
|
||||
src = "env"
|
||||
}
|
||||
out[ck.key] = map[string]any{"set": src != "unset", "source": src}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// settingsPut 是设置页的保存载荷:常规设置全量替换(与旧行为一致),
|
||||
// secrets 只写传了非空值的键(秘密项留空 = 保持现值,永远不可能被顺手清掉)。
|
||||
type settingsPut struct {
|
||||
model.Settings
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
}
|
||||
|
||||
// writableSecrets 是允许通过 PUT 写入的秘密键;admin_password 单独处理
|
||||
// (入库前要 bcrypt 哈希)。
|
||||
var writableSecrets = map[string]bool{
|
||||
"github_client_secret": true,
|
||||
"google_client_secret": true,
|
||||
"telegram_bot_token": true,
|
||||
"r2_access_key": true,
|
||||
"r2_secret_key": true,
|
||||
}
|
||||
|
||||
func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -530,28 +646,77 @@ func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
meta, err := a.credentialMeta()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var in model.Settings
|
||||
var in settingsPut
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateSettings(in); err != nil {
|
||||
// admin_password 走单独通道:明文只在请求里出现一次,落库前哈希
|
||||
for k, v := range in.Secrets {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue // 留空 = 不改
|
||||
}
|
||||
if !writableSecrets[k] && k != "admin_password" {
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := a.Store.UpdateSettings(in.Settings); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
a.invalidate()
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
meta, err := a.credentialMeta()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required")
|
||||
}
|
||||
}
|
||||
|
||||
// invalidate 让有效配置缓存立即失效(Res 未装配时没有缓存,跳过)。
|
||||
func (a *API) invalidate() {
|
||||
if a.Res != nil {
|
||||
a.Res.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, errors.New("empty")
|
||||
@@ -651,7 +816,7 @@ func (a *API) files(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
for i := range fp.Items {
|
||||
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase)
|
||||
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
httpx.OK(w, fp)
|
||||
case http.MethodPost:
|
||||
@@ -691,7 +856,7 @@ func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
|
||||
for i := range out {
|
||||
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase)
|
||||
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
httpx.Created(w, out)
|
||||
}
|
||||
@@ -764,7 +929,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
|
||||
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
|
||||
// 数据库必须记录同样的完整 key,直链才不会 404
|
||||
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" {
|
||||
if p := storage.EndpointKeyPrefix(a.cfg().S3Endpoint); p != "" {
|
||||
key = p + "/" + key
|
||||
}
|
||||
|
||||
@@ -772,7 +937,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
|
||||
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
|
||||
if exist, err := a.Store.GetFileByKey(key); err == nil {
|
||||
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
|
||||
exist.URL = storage.FileURL(exist.Store, exist.Key, a.cfg().UploadsPublicBase)
|
||||
return exist, nil
|
||||
}
|
||||
|
||||
@@ -781,7 +946,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
return model.File{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil {
|
||||
if err := a.blobs().Put(ctx, key, f, size, mime); err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
|
||||
@@ -791,12 +956,12 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
Mime: mime,
|
||||
Size: size,
|
||||
SHA256: sum,
|
||||
Store: a.Cfg.StorageDriver,
|
||||
Store: a.cfg().StorageDriver,
|
||||
})
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase)
|
||||
created.URL = storage.FileURL(created.Store, created.Key, a.cfg().UploadsPublicBase)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
@@ -924,7 +1089,7 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
f.URL = storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
|
||||
httpx.OK(w, f)
|
||||
case http.MethodDelete:
|
||||
f, err := a.Store.GetFile(id)
|
||||
@@ -946,7 +1111,7 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// 先删对象存储再删行:存储端失败时行保留,可以重试
|
||||
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
|
||||
if err := a.blobs().Delete(r.Context(), f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
@@ -176,10 +178,15 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get settings: got %d", rec.Code)
|
||||
}
|
||||
var got model.Settings
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
var wrap struct {
|
||||
Settings model.Settings `json:"settings"`
|
||||
CredentialMeta map[string]any `json:"credential_meta"`
|
||||
Secrets map[string]string `json:"-"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&wrap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got := wrap.Settings
|
||||
if got.UIID != "vivid" {
|
||||
t.Errorf("ui_id = %q, want vivid", got.UIID)
|
||||
}
|
||||
@@ -189,6 +196,11 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
if _, ok := got.CustomCSS["bogus"]; ok {
|
||||
t.Error("unknown section should not be persisted")
|
||||
}
|
||||
// 秘密项在任何响应里都不该出现明文
|
||||
if strings.Contains(rec.Body.String(), "client_secret\":") &&
|
||||
!strings.Contains(rec.Body.String(), "credential_meta") {
|
||||
t.Error("settings response should not carry raw secrets")
|
||||
}
|
||||
|
||||
// An invalid ui_id falls back rather than being stored verbatim.
|
||||
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
|
||||
@@ -197,7 +209,8 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
_ = json.NewDecoder(rec.Body).Decode(&got)
|
||||
_ = json.NewDecoder(rec.Body).Decode(&wrap)
|
||||
got = wrap.Settings
|
||||
if got.UIID != "classic" {
|
||||
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
|
||||
}
|
||||
@@ -205,3 +218,109 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
t.Error("custom_css should be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// 系统设置迁移项:secrets 走单独通道写入且不可读回;DB 值在有效配置里
|
||||
// 覆盖环境变量;登录改用 DB 密码后 env 密码是否还作数取决于是否显式配置。
|
||||
func TestSettingsCredentialsRoundTrip(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Cfg = &config.Config{SiteURL: "http://env.example", AdminUser: "admin",
|
||||
AdminPass: "s3cret", GitHubClientID: "env-id", GitHubClientSecret: "env-sec",
|
||||
GoogleClientID: "env-google"}
|
||||
a.Res = config.NewResolver(a.Cfg, a.Store)
|
||||
token := login(t, h, "admin", "s3cret")
|
||||
var sess struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.NewDecoder(token.Body).Decode(&sess)
|
||||
|
||||
put := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", 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
|
||||
}
|
||||
|
||||
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文
|
||||
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
|
||||
"secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`)
|
||||
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") {
|
||||
t.Fatal("secret echoed back in plaintext")
|
||||
}
|
||||
|
||||
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
|
||||
// 未写的项回落 env
|
||||
if meta, err := a.credentialMeta(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
want := map[string]string{
|
||||
"site_url": "db", "github_client_id": "db",
|
||||
"github_client_secret": "db", "google_client_id": "env",
|
||||
"admin_password_hash": "db",
|
||||
}
|
||||
for k, src := range want {
|
||||
m, _ := meta[k].(map[string]any)
|
||||
if m == nil || m["source"] != src {
|
||||
t.Errorf("meta[%s] = %+v, want source=%s", k, m, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有效配置:DB 值覆盖 env;没配的项回落 env
|
||||
c := a.cfg()
|
||||
if c.SiteURL != "https://db.example" || c.GitHubClientID != "db-id" {
|
||||
t.Errorf("overlay failed: site_url=%q client_id=%q", c.SiteURL, c.GitHubClientID)
|
||||
}
|
||||
if c.GitHubClientSecret != "db-sec" {
|
||||
t.Errorf("secret overlay failed: %q", c.GitHubClientSecret)
|
||||
}
|
||||
|
||||
// DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should work right after save")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "s3cret"); !ok {
|
||||
t.Error("explicit env password should stay as backstop")
|
||||
}
|
||||
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 必须立刻失效。
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "admin"); ok {
|
||||
t.Error("admin/admin must stop working once a DB password exists")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func bcryptHash(t *testing.T, pw string) string {
|
||||
t.Helper()
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -44,7 +44,7 @@ func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -84,7 +84,7 @@ func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if name == "" {
|
||||
name = "未命名设备"
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -124,7 +124,7 @@ func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "bad passkey id")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 有效配置与存储后端的取用口。系统设置迁进后台后,凭据 / 站点地址不再是
|
||||
// 启动期常量:按「DB 设置叠加 env」现算(Res.Config 自带短缓存),
|
||||
// 后台保存设置后立即生效。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/storage"
|
||||
)
|
||||
|
||||
// cfg 返回当前请求应使用的有效配置。
|
||||
func (a *API) cfg() *config.Config {
|
||||
if a.Res != nil {
|
||||
return a.Res.Config()
|
||||
}
|
||||
return a.Cfg
|
||||
}
|
||||
|
||||
// blobs 返回当前存储后端(Res 存在时支持后台改配置热重建)。
|
||||
func (a *API) blobs() storage.BlobStore {
|
||||
if a.Res != nil {
|
||||
if b := a.Res.Blobs(); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return a.Blobs
|
||||
}
|
||||
Reference in new issue
Block a user