系统设置迁入后台:站点地址 / 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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
Reference in new issue
Block a user