系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启

- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
This commit is contained in:
Sakurasan committed 2026-10-01 12:14:10 +08:00
1 parent bf3ce934dd
commit e68400b389
23 files changed
+957 -126

No files matched your search

+187 -22
View File
@@ -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
}