diff --git a/README.md b/README.md index ebd350e..73c5cfc 100644 --- a/README.md +++ b/README.md @@ -35,19 +35,36 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start 登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer `,方便用 curl / 脚本写文章。 -## 环境变量 +## 设置:后台优先,环境变量兜底 + +站点地址、评论区第三方登录(GitHub / Google / Telegram)凭据、R2 对象存储、 +管理员用户名与密码,都在后台 **设置 → 登录与存储**(站点地址在 **基础信息**)里配置, +保存后立即生效,无需重启。这些值存在 `settings` 表里,生效规则是 +「后台填了用后台的,没填回落到环境变量」——老部署不改 env 也能照常跑。 + +秘密项(client secret、Bot token、R2 密钥、管理员密码)在后台只显示 +「是否已配置、来自哪里」,永不回显明文;留空保存 = 保持现值。 +管理员密码在后台以 bcrypt 哈希存储;显式设置过的 `ONE_ADMIN_PASSWORD` +保留作解锁后路(env 和数据库在同一台机器上,能读 env 的人本来就能改库)。 + +环境变量只剩启动期必需项: | 变量 | 默认 | 说明 | | --- | --- | --- | | `ONE_ADDR` | `:8080` | 监听地址 | | `ONE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres` | | `ONE_DB_DSN` | `./data/one.db` | 数据库连接串(Postgres 示例:`postgres://user:pass@localhost/one?sslmode=disable`) | -| `ONE_ADMIN_USER` | `admin` | 后台用户名 | -| `ONE_ADMIN_PASSWORD` | `admin` | 后台密码 | | `ONE_SECRET` | 随机生成 | 会话签名密钥;不设的话重启后登录态失效 | -| `ONE_SITE_URL` | `http://localhost:8080` | RSS 里的站点地址,部署时务必改成真实域名 | | `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 | | `ONE_DATA_DIR` | `./data` | SQLite 数据目录 | +| `ONE_ADMIN_USER` | `admin` | 初始后台用户名(后台可改) | +| `ONE_ADMIN_PASSWORD` | `admin` | 初始后台密码(后台可改;不设即 admin/admin 的开发模式) | +| `ONE_SITE_URL` | `http://localhost:8080` | 站点地址兜底值(后台可改) | +| `ONE_GITHUB_CLIENT_ID` / `_SECRET` | — | GitHub 登录兜底值(后台可改) | +| `ONE_GOOGLE_CLIENT_ID` / `_SECRET` | — | Google 登录兜底值(后台可改) | +| `ONE_TELEGRAM_BOT` / `_TOKEN` | — | Telegram 登录兜底值(后台可改) | +| `S3Api` / `Bucket` / `AccessKey` / `SecretAccessKey` / `PublicURL` | — | R2 存储兜底值(后台可改) | +| `ONE_WEBAUTHN_RP_ID` / `_ORIGINS` | — | Passkey 登录;显式配 origins 才启用 | ## 界面:classic / vivid @@ -177,12 +194,13 @@ PUT /api/admin/settings ```bash make build # 产出 ./one-server,前端已打进 web/dist ONE_ADDR=:8080 \ -ONE_SITE_URL=https://your.domain \ ONE_DB_DRIVER=postgres \ ONE_DB_DSN="postgres://user:pass@127.0.0.1/one?sslmode=disable" \ ONE_SECRET=一串随机长字符串 \ ONE_ADMIN_PASSWORD=强密码 \ ./one-server +# 首次启动后登录后台,把站点地址、第三方登录、对象存储在「设置」页里配好, +# 之后改这些就不再需要碰环境变量或重启。 ``` 要点: @@ -200,7 +218,7 @@ After=network.target [Service] WorkingDirectory=/opt/one -Environment=ONE_ADDR=:8080 ONE_SITE_URL=https://your.domain ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx +Environment=ONE_ADDR=:8080 ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx ExecStart=/opt/one/one-server Restart=always diff --git a/backend/go.mod b/backend/go.mod index 26acf5d..00d85fe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-webauthn/webauthn v0.15.0 github.com/lib/pq v1.10.9 github.com/yuin/goldmark v1.7.13 + golang.org/x/crypto v0.43.0 golang.org/x/text v0.30.0 modernc.org/sqlite v1.39.0 ) @@ -40,7 +41,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.37.0 // indirect modernc.org/libc v1.66.3 // indirect diff --git a/backend/internal/admin/account.go b/backend/internal/admin/account.go index fd8bd09..ac49c3a 100644 --- a/backend/internal/admin/account.go +++ b/backend/internal/admin/account.go @@ -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 diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 696921a..4918b83 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -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 } diff --git a/backend/internal/admin/api_test.go b/backend/internal/admin/api_test.go index 793e7e8..dd50216 100644 --- a/backend/internal/admin/api_test.go +++ b/backend/internal/admin/api_test.go @@ -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) +} diff --git a/backend/internal/admin/passkey.go b/backend/internal/admin/passkey.go index 84b6b58..4d529f9 100644 --- a/backend/internal/admin/passkey.go +++ b/backend/internal/admin/passkey.go @@ -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 diff --git a/backend/internal/admin/runtime.go b/backend/internal/admin/runtime.go new file mode 100644 index 0000000..6fc8d78 --- /dev/null +++ b/backend/internal/admin/runtime.go @@ -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 +} diff --git a/backend/internal/api/account.go b/backend/internal/api/account.go index 557964f..4b39438 100644 --- a/backend/internal/api/account.go +++ b/backend/internal/api/account.go @@ -58,22 +58,22 @@ func (a *API) beginBind(w http.ResponseWriter, r *http.Request) { setBindCookie(w) switch provider { case "github": - if !a.GH.Enabled() { + if !a.gh().Enabled() { clearBindCookie(w) httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { - return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state) + return a.gh().LoginURL(a.siteURL()+"/api/auth/callback/github", state) }) case "google": - if !a.GG.Enabled() { + if !a.gg().Enabled() { clearBindCookie(w) httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { - return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state) + return a.gg().LoginURL(a.siteURL()+"/api/auth/callback/google", state) }) default: clearBindCookie(w) @@ -121,7 +121,7 @@ func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, ex } if bindRequested(r) { - owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser) + owner, err := a.Store.EnsureOwner(a.cfg().AdminUser) if err != nil { httpx.ServerError(w, err) return "" @@ -134,7 +134,7 @@ func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, ex httpx.ServerError(w, err) return "" } - http.Redirect(w, r, strings.TrimRight(a.Cfg.SiteURL, "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound) + http.Redirect(w, r, strings.TrimRight(a.siteURL(), "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound) return "" } @@ -173,7 +173,7 @@ func (a *API) issueReaderSession(w http.ResponseWriter, r *http.Request, readerI // (开发时前端 :3000、后端 :8080 分离,只有它才不会跳错站), // 没有记录(直接敲 URL 进来的)就回站点根。 func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string { - back := a.Cfg.SiteURL + back := a.siteURL() if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" { if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" { back = u.Scheme + "://" + u.Host @@ -187,7 +187,7 @@ func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string { // 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下 // 浏览器会把它当不安全 cookie 丢掉。 func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) { - token, exp := a.AdminSessions.Issue(a.Cfg.AdminUser) + token, exp := a.AdminSessions.Issue(a.cfg().AdminUser) http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Value: token, Path: "/", HttpOnly: true, Secure: isTLS(r), SameSite: http.SameSiteLaxMode, diff --git a/backend/internal/api/account_test.go b/backend/internal/api/account_test.go index 13c7df6..d4d5298 100644 --- a/backend/internal/api/account_test.go +++ b/backend/internal/api/account_test.go @@ -31,9 +31,9 @@ func (f fakeAdmin) TTL() int { return 3600 } func newAccountAPI(t *testing.T) (*API, http.Handler) { t.Helper() a, h := newTestAPI(t) - a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin"} + a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin", + GitHubClientID: "id", GitHubClientSecret: "sec"} a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}} - a.GH = auth.GitHub{ClientID: "id", ClientSecret: "sec"} return a, h } @@ -53,7 +53,7 @@ func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) { if err != nil { t.Fatal(err) } - back := a.Cfg.SiteURL + "/admin/account" + back := a.siteURL() + "/admin/account" req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil) req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"}) req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back}) diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index d51e68d..7b9bfb0 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -26,14 +26,17 @@ import ( type API struct { Store *store.Store - Cfg *config.Config - Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) + // Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config() + // 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。 + Cfg *config.Config + // Res 解析后台「系统设置」(DB 叠加 env),main.go 装配;可为 nil。 + Res *config.Resolver + Blobs storage.BlobStore // 文件上传的存储后端(Res 设置时的兜底,二者取其一) // Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。 // 为 nil 时 /uploads/thumb/ 路由直接回原图。 Thumbs *thumbs.Store // 评论区读者会话与 GitHub OAuth(main.go 装配) ReaderSessions *auth.ReaderSessions - GH auth.GitHub // AdminSessions 是后台管理员会话(admin.Sessions 满足它)。 // 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话, // 所以除了 Verify 还要 Issue/TTL。 @@ -42,9 +45,6 @@ type API struct { Issue(user string) (string, time.Time) TTL() int } - // 其余登录方式(main.go 装配,未配置的自动不开放) - GG auth.Google - TG auth.Telegram // Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放) Passkeys *auth.Passkeys @@ -125,9 +125,11 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } + // /api/site 是公开端点:管理员用户名只服务后台设置页,不外泄 + st.AdminUsername = "" st.AuthorAvatarURL = a.avatarURL(st.AuthorAvatarKey) // uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图) - httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase}) + httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.cfg().UploadsPublicBase}) } // avatarURL 把 settings 里的头像 key 解析成可访问 URL。 @@ -141,7 +143,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) } func listOptions(r *http.Request, defSize int) store.ListOptions { @@ -276,7 +278,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } - base := strings.TrimSuffix(a.Cfg.SiteURL, "/") + base := strings.TrimSuffix(a.siteURL(), "/") feed := rssFeed{ Version: "2.0", @@ -397,12 +399,12 @@ func (a *API) uploads(w http.ResponseWriter, r *http.Request) { } // R2 且配了公开域名:302 到直链,后端不出流量 - if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" { - http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound) + if f.Store == "r2" && a.cfg().UploadsPublicBase != "" { + http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase), http.StatusFound) return } - rc, size, err := a.Blobs.Open(r.Context(), f.Key) + rc, size, err := a.blobs().Open(r.Context(), f.Key) if err != nil { httpx.NotFound(w) return diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 29fc8aa..c3a5ac8 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -27,10 +27,10 @@ func newTestAPI(t *testing.T) (*API, http.Handler) { t.Fatalf("store: %v", err) } a := &API{ - Store: st, - Cfg: &config.Config{SiteURL: "http://localhost:8080"}, + Store: st, + Cfg: &config.Config{SiteURL: "http://localhost:8080", + TelegramBot: "testbot", TelegramToken: "123:abc"}, ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour), - TG: auth.Telegram{Bot: "testbot", Token: "123:abc"}, } settings, err := st.GetSettings() if err != nil { diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go index 768663f..28526b1 100644 --- a/backend/internal/api/comments.go +++ b/backend/internal/api/comments.go @@ -72,7 +72,7 @@ func (a *API) ownerReader() (model.Reader, error) { name = st.AuthorName } return a.Store.UpsertReader(model.Reader{ - Provider: "admin", Handle: a.Cfg.AdminUser, Name: name, + Provider: "admin", Handle: a.cfg().AdminUser, Name: name, }) } @@ -96,18 +96,18 @@ func (a *API) authLogout(w http.ResponseWriter, r *http.Request) { // githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth, // 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。 func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) { - if !a.GH.Enabled() { + if !a.gh().Enabled() { httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { - return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state) + return a.gh().LoginURL(a.siteURL()+"/api/auth/callback/github", state) }) } // githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。 func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { - if !a.GH.Enabled() { + if !a.gh().Enabled() { httpx.NotFound(w) return } @@ -122,13 +122,13 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { httpx.BadRequest(w, "state 不匹配,请重新登录") return } - gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github") + gh, err := a.gh().Exchange(r.Context(), r.FormValue("code"), a.siteURL()+"/api/auth/callback/github") if err != nil { a.authFails.Add(ip) httpx.ServerError(w, err) return } - u, err := a.GH.FetchUser(r.Context(), gh) + u, err := a.gh().FetchUser(r.Context(), gh) if err != nil { a.authFails.Add(ip) httpx.ServerError(w, err) diff --git a/backend/internal/api/providers.go b/backend/internal/api/providers.go index a242073..caebcda 100644 --- a/backend/internal/api/providers.go +++ b/backend/internal/api/providers.go @@ -22,19 +22,19 @@ import ( // 前端内联官方脚本,需要 bot 用户名。 func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { providers := []map[string]any{} - if a.GH.Enabled() { + if a.gh().Enabled() { providers = append(providers, map[string]any{ "id": "github", "label": "GitHub", "kind": "redirect", }) } - if a.GG.Enabled() { + if a.gg().Enabled() { providers = append(providers, map[string]any{ "id": "google", "label": "Google", "kind": "redirect", }) } - if a.TG.Enabled() { + if a.tg().Enabled() { providers = append(providers, map[string]any{ - "id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot, + "id": "telegram", "label": "Telegram", "kind": "widget", "login": a.tg().Bot, }) } httpx.OK(w, map[string]any{"providers": providers}) @@ -42,18 +42,18 @@ func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { // googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub) func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) { - if !a.GG.Enabled() { + if !a.gg().Enabled() { httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { - return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state) + return a.gg().LoginURL(a.siteURL()+"/api/auth/callback/google", state) }) } // googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。 func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { - if !a.GG.Enabled() { + if !a.gg().Enabled() { httpx.NotFound(w) return } @@ -68,13 +68,13 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { httpx.BadRequest(w, "state 不匹配,请重新登录") return } - accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google") + accessToken, err := a.gg().Exchange(r.Context(), r.FormValue("code"), a.siteURL()+"/api/auth/callback/google") if err != nil { a.authFails.Add(ip) httpx.ServerError(w, err) return } - u, err := a.GG.FetchUser(r.Context(), accessToken) + u, err := a.gg().FetchUser(r.Context(), accessToken) if err != nil { a.authFails.Add(ip) httpx.ServerError(w, err) @@ -100,7 +100,7 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { // telegramAuth 校验 Login Widget 回传的签名资料并登录。 // 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。 func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { - if !a.TG.Enabled() { + if !a.tg().Enabled() { httpx.NotFound(w) return } @@ -123,7 +123,7 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { httpx.BadRequest(w, "invalid body") return } - if err := a.TG.VerifyMap(fields); err != nil { + if err := a.tg().VerifyMap(fields); err != nil { a.authFails.Add(ip) httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试") return @@ -148,7 +148,7 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { httpx.Unauthorized(w) return } - owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser) + owner, err := a.Store.EnsureOwner(a.cfg().AdminUser) if err != nil { clearBindCookie(w) httpx.ServerError(w, err) diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go new file mode 100644 index 0000000..2987de1 --- /dev/null +++ b/backend/internal/api/runtime.go @@ -0,0 +1,49 @@ +// 有效配置与登录方式的取用口。OAuth / Telegram 凭据迁移进后台系统设置后 +// 不再是启动期常量:每次请求按「DB 设置叠加 env」现算(Res.Config 自带 +// 短缓存),后台改完凭据不用重启就生效。 +package api + +import ( + "oneblog/internal/auth" + "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() +} + +// 三个读者登录方式。凭据为空时 Enabled() 为 false,相关端点自动 404。 +func (a *API) gh() auth.GitHub { + c := a.cfg() + return auth.GitHub{ClientID: c.GitHubClientID, ClientSecret: c.GitHubClientSecret} +} + +func (a *API) gg() auth.Google { + c := a.cfg() + return auth.Google{ClientID: c.GoogleClientID, ClientSecret: c.GoogleClientSecret} +} + +func (a *API) tg() auth.Telegram { + c := a.cfg() + return auth.Telegram{Bot: c.TelegramBot, Token: c.TelegramToken} +} + +// siteURL 去掉尾部斜杠的站点地址(OAuth 回调、RSS 链接的拼装基座)。 +func (a *API) siteURL() string { + return a.cfg().SiteURL +} diff --git a/backend/internal/api/thumb.go b/backend/internal/api/thumb.go index 6274c5e..1d52841 100644 --- a/backend/internal/api/thumb.go +++ b/backend/internal/api/thumb.go @@ -81,7 +81,7 @@ func (a *API) thumb(w http.ResponseWriter, r *http.Request) { return } - rc, _, err := a.Blobs.Open(r.Context(), f.Key) + rc, _, err := a.blobs().Open(r.Context(), f.Key) if err != nil { a.Thumbs.MarkFailed(f.SHA256) a.thumbFallback(w, r, f) @@ -111,7 +111,7 @@ func (a *API) thumb(w http.ResponseWriter, r *http.Request) { // thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。 func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) { - http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound) + http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase), http.StatusFound) } // serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。 diff --git a/backend/internal/config/resolver.go b/backend/internal/config/resolver.go new file mode 100644 index 0000000..e43cf9d --- /dev/null +++ b/backend/internal/config/resolver.go @@ -0,0 +1,143 @@ +// 系统设置的运行时解析层。 +// +// 迁移自环境变量的设置(站点地址、OAuth 凭据、R2 存储、管理员账号)存进 +// settings KV 表后,生效规则统一为「DB 有值用 DB,空值回落环境变量」: +// 老部署不改 env 也能照常跑,后台改过的值随时可以赢回来。 +// +// Resolver 在 env Config 之上叠加 DB 值,给两个 API 提供按需解析的有效配置; +// 配置带短缓存(设置保存时主动失效),存储后端按配置签名惰性重建—— +// 后台改完 R2 凭据不用重启,下一次上传就走新桶。 +package config + +import ( + "strings" + "sync" + "time" + + "oneblog/internal/model" + "oneblog/internal/storage" + "oneblog/internal/store" +) + +// Overlay 返回 c 的副本:model.Settings 里非空的系统设置项覆盖 env 值。 +// 每个字段独立回落——比如 client_id 在后台改了、secret 还是 env 的, +// 两个拼起来照样能用,不必逼站主一次填齐。 +func (c *Config) Overlay(st model.Settings) *Config { + e := *c + pick := func(dst *string, v string) { + if t := strings.TrimSpace(v); t != "" { + *dst = t + } + } + pick(&e.SiteURL, st.SiteURL) + pick(&e.GitHubClientID, st.GitHubClientID) + pick(&e.GitHubClientSecret, st.GitHubClientSecret) + pick(&e.GoogleClientID, st.GoogleClientID) + pick(&e.GoogleClientSecret, st.GoogleClientSecret) + pick(&e.TelegramBot, st.TelegramBot) + pick(&e.TelegramToken, st.TelegramToken) + pick(&e.AdminUser, st.AdminUsername) + pick(&e.UploadsPublicBase, st.UploadsPublicBase) + pick(&e.S3Endpoint, st.S3Endpoint) + pick(&e.R2Bucket, st.R2Bucket) + pick(&e.R2AccessKey, st.R2AccessKey) + pick(&e.R2SecretKey, st.R2SecretKey) + // 存储端按「凭据是否齐全」重新判定,与 config.Load 的规则一致 + if e.S3Endpoint != "" && e.R2Bucket != "" && e.R2AccessKey != "" && e.R2SecretKey != "" { + e.StorageDriver = "r2" + } else { + e.StorageDriver = "local" + } + return &e +} + +// SettingsSource 是 Resolver 对 store 的最小依赖(测试可注入假实现)。 +type SettingsSource interface { + GetSettings() (model.Settings, error) +} + +// Resolver 提供带缓存的有效配置与按需重建的存储后端。零值不可用, +// 用 NewResolver 构造;API 拿不到 Resolver 时(旧测试)直接用 env 值。 +type Resolver struct { + env *Config + st SettingsSource + + mu sync.Mutex + cfg *Config + cfgAt time.Time + blobs storage.BlobStore + blobsKV string +} + +// cacheTTL 只兜「绕过后台直接改库」这种少数派路径;正常保存走 +// Invalidate() 立即生效,所以 TTL 可以放宽到不影响体验的量级。 +const cacheTTL = 10 * time.Second + +func NewResolver(env *Config, st SettingsSource) *Resolver { + return &Resolver{env: env, st: st} +} + +// Config 返回叠加了 DB 设置的有效配置。读库失败时回落 env—— +// 设置页不能因为 DB 抖动把整站带挂。 +func (r *Resolver) Config() *Config { + if r == nil { + return r.env + } + r.mu.Lock() + defer r.mu.Unlock() + if r.cfg != nil && time.Since(r.cfgAt) < cacheTTL { + return r.cfg + } + st, err := r.st.GetSettings() + if err != nil { + if r.cfg == nil { + return r.env + } + return r.cfg // 沿用上次缓存,别让一次查询失败打断请求 + } + r.cfg = r.env.Overlay(st) + r.cfgAt = time.Now() + return r.cfg +} + +// Invalidate 丢弃缓存:后台保存设置后调用,改完立即生效。 +func (r *Resolver) Invalidate() { + if r == nil { + return + } + r.mu.Lock() + r.cfg, r.cfgAt = nil, time.Time{} + r.mu.Unlock() +} + +// Blobs 返回当前有效配置对应的存储后端,配置变了自动重建。 +// 构造失败(R2 凭据不对等)返回 nil,调用方按「无存储」处理。 +func (r *Resolver) Blobs() storage.BlobStore { + if r == nil { + return nil + } + c := r.Config() + kv := c.StorageDriver + "|" + c.S3Endpoint + "|" + c.R2Bucket + "|" + + c.R2AccessKey + "|" + c.R2SecretKey + r.mu.Lock() + defer r.mu.Unlock() + if r.blobs != nil && kv == r.blobsKV { + return r.blobs + } + var b storage.BlobStore + switch c.StorageDriver { + case "r2": + s, err := storage.NewR2(c.S3Endpoint, c.R2Bucket, c.R2AccessKey, c.R2SecretKey) + if err != nil { + return r.blobs // 重建失败沿用旧后端,至少不把上传/读取打断 + } + b = s + default: + b = storage.NewLocal(c.DataDir + "/uploads") + } + r.blobs, r.blobsKV = b, kv + return b +} + +// 静态断言:*store.Store 满足 SettingsSource(GetSettings 在 store 包里)。 +var _ SettingsSource = (*store.Store)(nil) diff --git a/backend/internal/config/resolver_test.go b/backend/internal/config/resolver_test.go new file mode 100644 index 0000000..d77983e --- /dev/null +++ b/backend/internal/config/resolver_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "testing" + + "oneblog/internal/model" +) + +func TestOverlayDBWinsEnvFallsBack(t *testing.T) { + env := &Config{ + SiteURL: "https://env.example", + GitHubClientID: "env-id", + GitHubClientSecret: "env-sec", + UploadsPublicBase: "https://cdn.example/", + } + st := model.Settings{ + SiteURL: "https://db.example", + GitHubClientSecret: "db-sec", + } + got := env.Overlay(st) + if got.SiteURL != "https://db.example" { + t.Errorf("SiteURL = %q, want db value", got.SiteURL) + } + if got.GitHubClientID != "env-id" { + t.Errorf("GitHubClientID = %q, want env fallback", got.GitHubClientID) + } + if got.GitHubClientSecret != "db-sec" { + t.Errorf("GitHubClientSecret = %q, want db value", got.GitHubClientSecret) + } + // env 原件不能被改掉 + if env.SiteURL != "https://env.example" { + t.Error("Overlay must not mutate the env config") + } +} + +// 存储端按凭据齐全与否重新判定,与 config.Load 的规则一致。 +func TestOverlayStorageCompleteness(t *testing.T) { + env := &Config{DataDir: "/tmp/one"} + full := model.Settings{ + S3Endpoint: "https://acct.r2.cloudflarestorage.com", + R2Bucket: "b", + R2AccessKey: "ak", + R2SecretKey: "sk", + } + if c := env.Overlay(full); c.StorageDriver != "r2" { + t.Errorf("complete R2 set: driver = %q, want r2", c.StorageDriver) + } + partial := full + partial.R2SecretKey = "" + if c := env.Overlay(partial); c.StorageDriver != "local" { + t.Errorf("incomplete R2 set: driver = %q, want local", c.StorageDriver) + } +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 4442356..689b46d 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -239,6 +239,29 @@ type Settings struct { // AuthorAvatarURL 由 API 层按 AuthorAvatarKey + 存储端解析出来, // 不落库;key 指向的文件已删除时为空串。 AuthorAvatarURL string `json:"author_avatar_url"` + + // —— 以下是从环境变量迁进后台的「系统设置」。值存在 settings KV 表里, + // 生效规则统一是「DB 有值用 DB,空值回落环境变量」(见 config.Overlay)。 + // 非秘密项直接进公开 JSON(client_id 本来就会出现在登录跳转 URL 里); + // 秘密项一律 json:"-",只在后台 API 里以「是否已配置 + 来源」的形式露出, + // 修改走 settings PUT 的 secrets 字段(留空 = 保持现值)。 + SiteURL string `json:"site_url"` + GitHubClientID string `json:"github_client_id,omitempty"` + GoogleClientID string `json:"google_client_id,omitempty"` + TelegramBot string `json:"telegram_bot,omitempty"` + // AdminUsername 不算秘密(登录页本来就要输入它),但也不进公开 JSON。 + AdminUsername string `json:"admin_username,omitempty"` + + UploadsPublicBase string `json:"-"` + S3Endpoint string `json:"-"` + R2Bucket string `json:"-"` + R2AccessKey string `json:"-"` + R2SecretKey string `json:"-"` + GitHubClientSecret string `json:"-"` + GoogleClientSecret string `json:"-"` + TelegramToken string `json:"-"` + // AdminPasswordHash 只存 bcrypt 哈希,从不离开服务端。 + AdminPasswordHash string `json:"-"` } // 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。 diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 4c1c918..f272211 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -226,6 +226,26 @@ func (s *Store) seedSettings() error { return nil } +// GetSettingsMap 返回 settings 表的原始 KV。后台设置页用它判断每个 +// 配置项的来源(DB 有值 / 回落环境变量)——这个信息在解析后的 +// model.Settings 里已经丢了(空串既可能是「没配」也可能是「回落 env」)。 +func (s *Store) GetSettingsMap() (map[string]string, error) { + rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`)) + if err != nil { + return nil, err + } + defer rows.Close() + m := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + m[k] = v + } + return m, rows.Err() +} + // ---------- settings ---------- func (s *Store) GetSettings() (model.Settings, error) { @@ -272,6 +292,21 @@ func settingsFromMap(m map[string]string) model.Settings { st.CustomJS = m["custom_js"] st.SocialLinks = decodeSocialLinks(m["social_links"]) st.AuthorAvatarKey = m["owner_avatar_key"] + // 从环境变量迁进后台的系统设置(DB 值优先,空值由 config.Overlay 回落 env) + st.SiteURL = strings.TrimSpace(m["site_url"]) + st.GitHubClientID = strings.TrimSpace(m["github_client_id"]) + st.GoogleClientID = strings.TrimSpace(m["google_client_id"]) + st.TelegramBot = strings.TrimSpace(m["telegram_bot"]) + st.AdminUsername = strings.TrimSpace(m["admin_username"]) + st.UploadsPublicBase = strings.TrimRight(strings.TrimSpace(m["uploads_public_base"]), "/") + st.S3Endpoint = strings.TrimSpace(m["s3_endpoint"]) + st.R2Bucket = strings.TrimSpace(m["r2_bucket"]) + st.R2AccessKey = m["r2_access_key"] + st.R2SecretKey = m["r2_secret_key"] + st.GitHubClientSecret = m["github_client_secret"] + st.GoogleClientSecret = m["google_client_secret"] + st.TelegramToken = m["telegram_bot_token"] + st.AdminPasswordHash = m["admin_password_hash"] // 开关类:'1' / 'true' 都算开,其余(含空)算关 st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true") st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true") @@ -462,6 +497,17 @@ func (s *Store) UpdateSettings(st model.Settings) error { // 开关统一存 '1' / '0'。 "comments_enabled": b2s(st.CommentsEnabled), "comments_review": b2s(st.CommentsReview), + // 从环境变量迁进后台的系统设置(非秘密项)。秘密项不在这里写—— + // 它们经过 model.Settings 的 json:"-" 反序列化永远是空串, + // 全量替换会把配置清掉;改秘密走 admin settings PUT 的 secrets 字段。 + "site_url": strings.TrimSpace(st.SiteURL), + "github_client_id": strings.TrimSpace(st.GitHubClientID), + "google_client_id": strings.TrimSpace(st.GoogleClientID), + "telegram_bot": strings.TrimSpace(st.TelegramBot), + "admin_username": strings.TrimSpace(st.AdminUsername), + "uploads_public_base": strings.TrimRight(strings.TrimSpace(st.UploadsPublicBase), "/"), + "s3_endpoint": strings.TrimSpace(st.S3Endpoint), + "r2_bucket": strings.TrimSpace(st.R2Bucket), } for k, v := range sets { if s.db.Dialect == db.Postgres { diff --git a/backend/main.go b/backend/main.go index 32aeb91..e671c41 100644 --- a/backend/main.go +++ b/backend/main.go @@ -22,7 +22,6 @@ import ( "oneblog/internal/config" "oneblog/internal/db" "oneblog/internal/hub" - "oneblog/internal/storage" "oneblog/internal/store" "oneblog/internal/thumbs" ) @@ -50,18 +49,11 @@ func main() { log.Fatalf("store: %v", err) } - // 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。 - // 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。 - var blobs storage.BlobStore - switch cfg.StorageDriver { - case "r2": - blobs, err = storage.NewR2(cfg.S3Endpoint, cfg.R2Bucket, cfg.R2AccessKey, cfg.R2SecretKey) - if err != nil { - log.Fatalf("storage: %v", err) - } - default: - blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads")) - } + // 有效配置 = DB 里的系统设置叠加环境变量(DB 优先)。站点地址、 + // OAuth 凭据、R2 存储、管理员账号都已迁进后台「设置」页, + // 这里只保留 env 兜底值,改设置不再需要重启。 + res := config.NewResolver(cfg, st) + cfgEff := res.Config() // 评论区的读者会话(cookie one_reader),30 天有效 readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) @@ -86,19 +78,16 @@ func main() { public := &api.API{ Store: st, Cfg: cfg, - Blobs: blobs, + Res: res, Thumbs: thumbCache, ReaderSessions: readerSessions, - GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, - GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret}, - TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken}, AdminSessions: adminSessions, Passkeys: passkeys, Hub: commentHub, } adminAPI := admin.NewAPI(st, cfg, adminSessions) + adminAPI.Res = res adminAPI.Hub = commentHub - adminAPI.Blobs = blobs adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图 adminAPI.Passkeys = passkeys @@ -120,10 +109,21 @@ func main() { go func() { log.Printf("ONE server listening on %s (db=%s)", cfg.Addr, cfg.Driver) - if cfg.InsecureDev { - log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfg.AdminUser, cfg.AdminPass) + // 登录提示按有效配置报:后台改过密码/用户名后,这里说的是 DB 里的那套 + sysSt, err := st.GetSettings() + passNote := "(set ONE_ADMIN_PASSWORD to change)" + if err == nil && sysSt.AdminPasswordHash != "" { + passNote = "(password set in admin settings)" + } + if cfgEff.InsecureDev && (err != nil || sysSt.AdminPasswordHash == "") { + log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfgEff.AdminUser, cfg.AdminPass) } else { - log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser) + log.Printf("admin login: %s / %s", cfgEff.AdminUser, passNote) + } + if cfgEff.StorageDriver == "r2" { + log.Printf("storage: R2 (%s)", cfgEff.R2Bucket) + } else { + log.Print("storage: local disk (在后台「设置 → 对象存储」配置 R2 后无需重启即生效)") } if !dirExists(cfg.WebDist) { log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist) diff --git a/frontend/src/admin/AccountView.vue b/frontend/src/admin/AccountView.vue index 2c2c8f4..5fd201f 100644 --- a/frontend/src/admin/AccountView.vue +++ b/frontend/src/admin/AccountView.vue @@ -185,8 +185,8 @@ onMounted(async () => {

- 由环境变量 ONE_ADMIN_USER 决定,不在这里改 —— - 它是账号的身份锚点,改了要连密码一起换。 + 登录用户名在「设置 → 登录与存储」里改;这里是站主在评论区的 + 身份标识(handle),改它要连登录名一起换。

diff --git a/frontend/src/admin/LoginView.vue b/frontend/src/admin/LoginView.vue index ff4d7ab..51c96d0 100644 --- a/frontend/src/admin/LoginView.vue +++ b/frontend/src/admin/LoginView.vue @@ -61,7 +61,8 @@ async function submit() {

- 默认账号在服务端环境变量里:ONE_ADMIN_USER / ONE_ADMIN_PASSWORD。 + 初始账号在服务端环境变量里:ONE_ADMIN_USER / ONE_ADMIN_PASSWORD, + 登录后可在「设置 → 登录与存储」里改成自己的用户名和密码。

← 回到前台 diff --git a/frontend/src/admin/SettingsView.vue b/frontend/src/admin/SettingsView.vue index c23110f..db0eb6d 100644 --- a/frontend/src/admin/SettingsView.vue +++ b/frontend/src/admin/SettingsView.vue @@ -12,6 +12,45 @@ const error = ref('') const saving = ref(false) const savedAt = ref('') +// ---------- 系统设置(从环境变量迁进后台的部分) ---------- +// 秘密项永不明文回显:输入框留空 = 保持现值。credential_meta 告诉每一项 +// 现在是否生效、来自后台(db)还是环境变量(env)兜底。 +const credMeta = ref({}) +const secrets = ref({}) + +const secretFields = [ + { key: 'github_client_secret', label: 'GitHub Client Secret' }, + { key: 'google_client_secret', label: 'Google Client Secret' }, + { key: 'telegram_bot_token', label: 'Telegram Bot Token' }, + { key: 'r2_access_key', label: 'R2 Access Key' }, + { key: 'r2_secret_key', label: 'R2 Secret Key' } +] + +function srcOf(key) { + const m = credMeta.value[key] + return m ? m.source : 'unset' +} + +// 非秘密项的提示:值存哪、改了是否立即生效 +function srcHint(key) { + const s = srcOf(key) + if (s === 'db') return '已在此配置' + if (s === 'env') return '当前来自环境变量,在此填写后将覆盖' + return '未配置' +} + +// 秘密项输入框的 placeholder:不回显值,只报状态 +function secretPlaceholder(key) { + const s = srcOf(key) + if (s === 'db') return '已配置(后台)· 留空保持不变' + if (s === 'env') return '已通过环境变量配置 · 填写后覆盖' + return '未配置' +} + +// 管理员账号:新密码 + 确认,只在前端做一次一致性与长度预检 +const newPassword = ref('') +const confirmPassword = ref('') + // ---------- 社交链接(settings.social_links <-> 多行文本) ---------- // 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。 const socialText = ref('') @@ -137,7 +176,12 @@ const currentSection = computed(() => SECTIONS.find((s) => s.id === cssSection.v async function load() { loading.value = true try { - settings.value = await adminApi.settings() + const res = await adminApi.settings() + settings.value = res.settings + credMeta.value = res.credential_meta || {} + newPassword.value = '' + confirmPassword.value = '' + secrets.value = {} if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) { settings.value.light_skin_id = 'paper' } @@ -167,13 +211,32 @@ onMounted(load) async function save() { if (!settings.value) return + // 管理员改密码:两次输入一致才提交 + if (newPassword.value || confirmPassword.value) { + if (newPassword.value !== confirmPassword.value) { + toastErr('两次输入的新密码不一致') + return + } + if (newPassword.value.length < 6) { + toastErr('新密码至少 6 位') + return + } + } saving.value = true error.value = '' try { settings.value.social_links = textToSocial(socialText.value) - const s = await adminApi.saveSettings(settings.value) - settings.value = s - socialText.value = socialToText(s.social_links) + // 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理) + const payload = { ...settings.value, secrets: {} } + if (newPassword.value) payload.secrets.admin_password = newPassword.value + for (const f of secretFields) { + const v = (secrets.value[f.key] || '').trim() + if (v) payload.secrets[f.key] = v + } + const res = await adminApi.saveSettings(payload) + settings.value = res.settings + credMeta.value = res.credential_meta || {} + socialText.value = socialToText(settings.value.social_links) // 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦 if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') { settings.value.custom_css = {} @@ -183,6 +246,9 @@ async function save() { settings.value.custom_css[sec.id] = '' } } + secrets.value = {} + newPassword.value = '' + confirmPassword.value = '' savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) toastOk('设置已保存') } catch (e) { @@ -206,6 +272,7 @@ async function save() { +
@@ -220,6 +287,15 @@ async function save() {
+
+ + +

+ RSS 订阅链接、GitHub / Google 登录的回调地址都以它为基座。 + {{ srcHint('site_url') }},保存后立即生效,无需重启。 +

+
@@ -417,6 +493,104 @@ async function save() {
+ +
+

登录方式

+

+ 评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。 + GitHub / Google 的回调地址以「站点地址 + /api/auth/callback/平台名」为准,改完站点地址无需再去平台改回调。 +

+ +
+ + + +

Client ID:{{ srcHint('github_client_id') }}

+
+ +
+ + + +

Client ID:{{ srcHint('google_client_id') }}

+
+ +
+ + + +

Bot 用户名:{{ srcHint('telegram_bot') }};Token 在 @BotFather 里创建 Bot 后获得。

+
+ +
+

对象存储

+

+ 文件上传的存储端。四项凭据齐全走 R2,缺任一项回落本地磁盘 + (data/uploads),切换不影响已上传文件的链接。保存后立即生效,无需重启。 +

+
+ + +

{{ srcHint('s3_endpoint') }}

+
+
+ + +

{{ srcHint('r2_bucket') }}

+
+
+ + +
+
+ + +
+
+ + +

+ {{ srcHint('uploads_public_base') }}。配置后 R2 文件直链走 CDN; + 不配则由本站 /uploads/ 路由代为输出。 +

+
+
+ +
+

管理员账号

+

+ 登录后台用的用户名与密码。在这里设置密码后,环境变量 + ONE_ADMIN_PASSWORD 只作解锁后路保留(仍可用);不填则维持现状。 +

+
+ + +

{{ srcHint('admin_username') }}

+
+
+ + +
+
+ + +
+
+
+