系统设置迁入后台:站点地址 / 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

+8 -8
View File
@@ -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,
+3 -3
View File
@@ -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})
+14 -12
View File
@@ -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
+3 -3
View File
@@ -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 {
+6 -6
View File
@@ -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)
+12 -12
View File
@@ -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)
+49
View File
@@ -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
}
+2 -2
View File
@@ -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。