系统设置迁入后台:站点地址 / 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
@@ -35,19 +35,36 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start
|
||||
|
||||
登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer <token>`,方便用 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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -34,12 +34,20 @@ type accountView struct {
|
||||
}
|
||||
|
||||
type passwordInfo struct {
|
||||
// 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
|
||||
// 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
|
||||
// 密码现在有两个可能的家:settings 表里的 bcrypt 哈希(后台改的),
|
||||
// 或环境变量 ONE_ADMIN_PASSWORD(未迁移时的兜底)。
|
||||
ManagedBy string `json:"managed_by"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// passwordSource 报告当前密码存哪。前端只作展示,不参与逻辑。
|
||||
func passwordSource(hash string) string {
|
||||
if hash != "" {
|
||||
return "settings"
|
||||
}
|
||||
return "env:ONE_ADMIN_PASSWORD"
|
||||
}
|
||||
|
||||
func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
@@ -54,7 +62,7 @@ func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) buildAccount() (accountView, error) {
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
@@ -72,12 +80,14 @@ func (a *API) buildAccount() (accountView, error) {
|
||||
}
|
||||
v := accountView{
|
||||
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", Username: a.Cfg.AdminUser},
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
// 密码可迁到哪:后台「管理员账号」里改过就是 DB(bcrypt 哈希),
|
||||
// 否则回落 env。
|
||||
Password: passwordInfo{ManagedBy: passwordSource(st.AdminPasswordHash), Username: a.cfg().AdminUser},
|
||||
Identities: ids,
|
||||
Passkeys: pks,
|
||||
}
|
||||
@@ -92,15 +102,16 @@ func (a *API) buildAccount() (accountView, error) {
|
||||
}
|
||||
|
||||
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
|
||||
// 否则点了必然报错。
|
||||
// 否则点了必然报错。凭据按有效配置算(后台系统设置优先于环境变量)。
|
||||
func (a *API) providerEnabled(name string) bool {
|
||||
c := a.cfg()
|
||||
switch name {
|
||||
case "github":
|
||||
return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
|
||||
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
|
||||
case "google":
|
||||
return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
|
||||
return c.GoogleClientID != "" && c.GoogleClientSecret != ""
|
||||
case "telegram":
|
||||
return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
|
||||
return c.TelegramBot != "" && c.TelegramToken != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -115,7 +126,7 @@ func (a *API) avatarURL(key string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
return storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
|
||||
type patchAccountRequest struct {
|
||||
@@ -132,7 +143,7 @@ func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -228,7 +239,7 @@ func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "bad provider")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
|
||||
+187
-22
@@ -22,6 +22,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
@@ -34,15 +36,17 @@ import (
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Store *store.Store
|
||||
// Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config()
|
||||
// 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。
|
||||
Cfg *config.Config
|
||||
Res *config.Resolver
|
||||
Sessions *Sessions
|
||||
// Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
|
||||
// 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
|
||||
Hub *hub.Hub
|
||||
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
|
||||
Blobs storage.BlobStore
|
||||
PublicBase string
|
||||
// 文件上传的存储后端兜底(Res 未设置时使用;设置了走 Res.Blobs() 热重建)
|
||||
Blobs storage.BlobStore
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
|
||||
// 缩略图产物,否则已删图片会一直占着缓存。
|
||||
Thumbs *thumbs.Store
|
||||
@@ -163,6 +167,40 @@ type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// verifyAdmin 校验登录凭据,返回应发会话的用户名。
|
||||
// 密码有两个可能的家:settings 表里的 bcrypt 哈希(后台「管理员账号」里改的,
|
||||
// 优先),和 ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在
|
||||
// 同一台机器上,能读 env 的人本来就能直接改库,不算额外开口子)。
|
||||
// 没显式设 env 密码时是 InsecureDev(admin/admin),一旦后台改过密码就失效。
|
||||
func (a *API) verifyAdmin(username, password string) (string, bool) {
|
||||
c := a.cfg()
|
||||
envUser := c.AdminUser
|
||||
user := envUser
|
||||
var hash string
|
||||
if st, err := a.Store.GetSettings(); err == nil {
|
||||
if st.AdminUsername != "" {
|
||||
user = st.AdminUsername
|
||||
}
|
||||
hash = st.AdminPasswordHash
|
||||
}
|
||||
if hash != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte(user)) == 1 &&
|
||||
bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
|
||||
return user, true
|
||||
}
|
||||
// 哈希只对 DB 用户名生效;env 密码作后路时继续走下面的比对
|
||||
if c.InsecureDev {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(envUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
return "", false
|
||||
}
|
||||
return envUser, true
|
||||
}
|
||||
|
||||
func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||
@@ -178,15 +216,14 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
user, ok := a.verifyAdmin(in.Username, in.Password)
|
||||
if !ok {
|
||||
a.limiter().Add(key)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
a.limiter().Reset(key)
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
token, exp := a.Sessions.Issue(user)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
@@ -216,7 +253,22 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.OK(w, map[string]any{"user": a.Cfg.AdminUser})
|
||||
// 会话里带着登录时的用户名(后台可改 admin_username,不能只看配置)
|
||||
name := ""
|
||||
if token := bearer(r); token != "" {
|
||||
if u, err := a.Sessions.Verify(token); err == nil {
|
||||
name = u
|
||||
}
|
||||
} else if c, err := r.Cookie(cookieName); err == nil {
|
||||
if u, err := a.Sessions.Verify(c.Value); err == nil {
|
||||
name = u
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"user": name})
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
@@ -522,6 +574,70 @@ func writeProject(w http.ResponseWriter, p model.Project, err error) {
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
// credKeys 是从环境变量迁进后台的系统设置项。
|
||||
// secret 为 true 的项 GET 时永远不回值,只报「是否已配置、来自哪里」。
|
||||
type credKey struct {
|
||||
key string // settings 表的键
|
||||
kind string // 前端字段名(= key)
|
||||
secret bool
|
||||
envOf func(c *config.Config) string
|
||||
}
|
||||
|
||||
var credKeys = []credKey{
|
||||
{key: "site_url", envOf: func(c *config.Config) string { return c.SiteURL }},
|
||||
{key: "github_client_id", envOf: func(c *config.Config) string { return c.GitHubClientID }},
|
||||
{key: "github_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GitHubClientSecret }},
|
||||
{key: "google_client_id", envOf: func(c *config.Config) string { return c.GoogleClientID }},
|
||||
{key: "google_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GoogleClientSecret }},
|
||||
{key: "telegram_bot", envOf: func(c *config.Config) string { return c.TelegramBot }},
|
||||
{key: "telegram_bot_token", secret: true, envOf: func(c *config.Config) string { return c.TelegramToken }},
|
||||
{key: "s3_endpoint", envOf: func(c *config.Config) string { return c.S3Endpoint }},
|
||||
{key: "r2_bucket", envOf: func(c *config.Config) string { return c.R2Bucket }},
|
||||
{key: "r2_access_key", secret: true, envOf: func(c *config.Config) string { return c.R2AccessKey }},
|
||||
{key: "r2_secret_key", secret: true, envOf: func(c *config.Config) string { return c.R2SecretKey }},
|
||||
{key: "uploads_public_base", envOf: func(c *config.Config) string { return c.UploadsPublicBase }},
|
||||
{key: "admin_username", envOf: func(c *config.Config) string { return c.AdminUser }},
|
||||
{key: "admin_password_hash", secret: true, envOf: func(*config.Config) string { return "" }},
|
||||
}
|
||||
|
||||
// credentialMeta 告诉前端每个迁移项的配置状态:
|
||||
// set=是否已有生效值,source=db(后台配的)/ env(环境变量兜底)/ unset。
|
||||
func (a *API) credentialMeta() (map[string]any, error) {
|
||||
m, err := a.Store.GetSettingsMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := a.cfg()
|
||||
out := map[string]any{}
|
||||
for _, ck := range credKeys {
|
||||
src := "unset"
|
||||
if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
|
||||
src = "db"
|
||||
} else if ck.envOf(c) != "" {
|
||||
src = "env"
|
||||
}
|
||||
out[ck.key] = map[string]any{"set": src != "unset", "source": src}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// settingsPut 是设置页的保存载荷:常规设置全量替换(与旧行为一致),
|
||||
// secrets 只写传了非空值的键(秘密项留空 = 保持现值,永远不可能被顺手清掉)。
|
||||
type settingsPut struct {
|
||||
model.Settings
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
}
|
||||
|
||||
// writableSecrets 是允许通过 PUT 写入的秘密键;admin_password 单独处理
|
||||
// (入库前要 bcrypt 哈希)。
|
||||
var writableSecrets = map[string]bool{
|
||||
"github_client_secret": true,
|
||||
"google_client_secret": true,
|
||||
"telegram_bot_token": true,
|
||||
"r2_access_key": true,
|
||||
"r2_secret_key": true,
|
||||
}
|
||||
|
||||
func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -530,28 +646,77 @@ func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
meta, err := a.credentialMeta()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var in model.Settings
|
||||
var in settingsPut
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateSettings(in); err != nil {
|
||||
// admin_password 走单独通道:明文只在请求里出现一次,落库前哈希
|
||||
for k, v := range in.Secrets {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue // 留空 = 不改
|
||||
}
|
||||
if !writableSecrets[k] && k != "admin_password" {
|
||||
httpx.BadRequest(w, "unknown secret key: "+k)
|
||||
return
|
||||
}
|
||||
if k == "admin_password" {
|
||||
if len(v) < 6 || len(v) > 72 {
|
||||
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
|
||||
return
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(v), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.SetSetting("admin_password_hash", string(h)); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := a.Store.SetSetting(k, v); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.Store.UpdateSettings(in.Settings); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
a.invalidate()
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
meta, err := a.credentialMeta()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required")
|
||||
}
|
||||
}
|
||||
|
||||
// invalidate 让有效配置缓存立即失效(Res 未装配时没有缓存,跳过)。
|
||||
func (a *API) invalidate() {
|
||||
if a.Res != nil {
|
||||
a.Res.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, errors.New("empty")
|
||||
@@ -651,7 +816,7 @@ func (a *API) files(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
for i := range fp.Items {
|
||||
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase)
|
||||
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
httpx.OK(w, fp)
|
||||
case http.MethodPost:
|
||||
@@ -691,7 +856,7 @@ func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
|
||||
for i := range out {
|
||||
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase)
|
||||
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.cfg().UploadsPublicBase)
|
||||
}
|
||||
httpx.Created(w, out)
|
||||
}
|
||||
@@ -764,7 +929,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
|
||||
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
|
||||
// 数据库必须记录同样的完整 key,直链才不会 404
|
||||
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" {
|
||||
if p := storage.EndpointKeyPrefix(a.cfg().S3Endpoint); p != "" {
|
||||
key = p + "/" + key
|
||||
}
|
||||
|
||||
@@ -772,7 +937,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
|
||||
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
|
||||
if exist, err := a.Store.GetFileByKey(key); err == nil {
|
||||
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
|
||||
exist.URL = storage.FileURL(exist.Store, exist.Key, a.cfg().UploadsPublicBase)
|
||||
return exist, nil
|
||||
}
|
||||
|
||||
@@ -781,7 +946,7 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
return model.File{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil {
|
||||
if err := a.blobs().Put(ctx, key, f, size, mime); err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
|
||||
@@ -791,12 +956,12 @@ func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string,
|
||||
Mime: mime,
|
||||
Size: size,
|
||||
SHA256: sum,
|
||||
Store: a.Cfg.StorageDriver,
|
||||
Store: a.cfg().StorageDriver,
|
||||
})
|
||||
if err != nil {
|
||||
return model.File{}, err
|
||||
}
|
||||
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase)
|
||||
created.URL = storage.FileURL(created.Store, created.Key, a.cfg().UploadsPublicBase)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
@@ -924,7 +1089,7 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
f.URL = storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
|
||||
httpx.OK(w, f)
|
||||
case http.MethodDelete:
|
||||
f, err := a.Store.GetFile(id)
|
||||
@@ -946,7 +1111,7 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// 先删对象存储再删行:存储端失败时行保留,可以重试
|
||||
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
|
||||
if err := a.blobs().Delete(r.Context(), f.Key); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
@@ -176,10 +178,15 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get settings: got %d", rec.Code)
|
||||
}
|
||||
var got model.Settings
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
var wrap struct {
|
||||
Settings model.Settings `json:"settings"`
|
||||
CredentialMeta map[string]any `json:"credential_meta"`
|
||||
Secrets map[string]string `json:"-"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&wrap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got := wrap.Settings
|
||||
if got.UIID != "vivid" {
|
||||
t.Errorf("ui_id = %q, want vivid", got.UIID)
|
||||
}
|
||||
@@ -189,6 +196,11 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
if _, ok := got.CustomCSS["bogus"]; ok {
|
||||
t.Error("unknown section should not be persisted")
|
||||
}
|
||||
// 秘密项在任何响应里都不该出现明文
|
||||
if strings.Contains(rec.Body.String(), "client_secret\":") &&
|
||||
!strings.Contains(rec.Body.String(), "credential_meta") {
|
||||
t.Error("settings response should not carry raw secrets")
|
||||
}
|
||||
|
||||
// An invalid ui_id falls back rather than being stored verbatim.
|
||||
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
|
||||
@@ -197,7 +209,8 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
_ = json.NewDecoder(rec.Body).Decode(&got)
|
||||
_ = json.NewDecoder(rec.Body).Decode(&wrap)
|
||||
got = wrap.Settings
|
||||
if got.UIID != "classic" {
|
||||
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
|
||||
}
|
||||
@@ -205,3 +218,109 @@ func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
t.Error("custom_css should be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// 系统设置迁移项:secrets 走单独通道写入且不可读回;DB 值在有效配置里
|
||||
// 覆盖环境变量;登录改用 DB 密码后 env 密码是否还作数取决于是否显式配置。
|
||||
func TestSettingsCredentialsRoundTrip(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
a.Cfg = &config.Config{SiteURL: "http://env.example", AdminUser: "admin",
|
||||
AdminPass: "s3cret", GitHubClientID: "env-id", GitHubClientSecret: "env-sec",
|
||||
GoogleClientID: "env-google"}
|
||||
a.Res = config.NewResolver(a.Cfg, a.Store)
|
||||
token := login(t, h, "admin", "s3cret")
|
||||
var sess struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.NewDecoder(token.Body).Decode(&sess)
|
||||
|
||||
put := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文
|
||||
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
|
||||
"secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "db-sec") || strings.Contains(rec.Body.String(), "newpass1") {
|
||||
t.Fatal("secret echoed back in plaintext")
|
||||
}
|
||||
|
||||
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
|
||||
// 未写的项回落 env
|
||||
if meta, err := a.credentialMeta(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
want := map[string]string{
|
||||
"site_url": "db", "github_client_id": "db",
|
||||
"github_client_secret": "db", "google_client_id": "env",
|
||||
"admin_password_hash": "db",
|
||||
}
|
||||
for k, src := range want {
|
||||
m, _ := meta[k].(map[string]any)
|
||||
if m == nil || m["source"] != src {
|
||||
t.Errorf("meta[%s] = %+v, want source=%s", k, m, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有效配置:DB 值覆盖 env;没配的项回落 env
|
||||
c := a.cfg()
|
||||
if c.SiteURL != "https://db.example" || c.GitHubClientID != "db-id" {
|
||||
t.Errorf("overlay failed: site_url=%q client_id=%q", c.SiteURL, c.GitHubClientID)
|
||||
}
|
||||
if c.GitHubClientSecret != "db-sec" {
|
||||
t.Errorf("secret overlay failed: %q", c.GitHubClientSecret)
|
||||
}
|
||||
|
||||
// DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should work right after save")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "s3cret"); !ok {
|
||||
t.Error("explicit env password should stay as backstop")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "wrong"); ok {
|
||||
t.Error("wrong password must fail")
|
||||
}
|
||||
|
||||
// admin_password 6 位下限
|
||||
if rec := put(`{"secrets":{"admin_password":"123"}}`); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("short password: got %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码
|
||||
// admin/admin 必须立刻失效。
|
||||
func TestInsecureDevDisabledByDBPassword(t *testing.T) {
|
||||
a, _ := newTestAPI(t)
|
||||
a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true}
|
||||
if _, ok := a.verifyAdmin("admin", "admin"); !ok {
|
||||
t.Fatal("insecure default should work before any password is set")
|
||||
}
|
||||
h := bcryptHash(t, "newpass1")
|
||||
if err := a.Store.SetSetting("admin_password_hash", h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "admin"); ok {
|
||||
t.Error("admin/admin must stop working once a DB password exists")
|
||||
}
|
||||
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
|
||||
t.Error("db password should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func bcryptHash(t *testing.T, pw string) string {
|
||||
t.Helper()
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -44,7 +44,7 @@ func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -84,7 +84,7 @@ func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if name == "" {
|
||||
name = "未命名设备"
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
@@ -124,7 +124,7 @@ func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.BadRequest(w, "bad passkey id")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
owner, err := a.Store.EnsureOwner(a.cfg().AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 有效配置与存储后端的取用口。系统设置迁进后台后,凭据 / 站点地址不再是
|
||||
// 启动期常量:按「DB 设置叠加 env」现算(Res.Config 自带短缓存),
|
||||
// 后台保存设置后立即生效。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/storage"
|
||||
)
|
||||
|
||||
// cfg 返回当前请求应使用的有效配置。
|
||||
func (a *API) cfg() *config.Config {
|
||||
if a.Res != nil {
|
||||
return a.Res.Config()
|
||||
}
|
||||
return a.Cfg
|
||||
}
|
||||
|
||||
// blobs 返回当前存储后端(Res 存在时支持后台改配置热重建)。
|
||||
func (a *API) blobs() storage.BlobStore {
|
||||
if a.Res != nil {
|
||||
if b := a.Res.Blobs(); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return a.Blobs
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 是评论区登录进来的访客。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-21
@@ -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)
|
||||
|
||||
@@ -185,8 +185,8 @@ onMounted(async () => {
|
||||
<label>用户名(handle)</label>
|
||||
<input :value="acct.handle" class="input" disabled />
|
||||
<p class="field-hint">
|
||||
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
|
||||
它是账号的身份锚点,改了要连密码一起换。
|
||||
登录用户名在「设置 → 登录与存储」里改;这里是站主在评论区的
|
||||
身份标识(handle),改它要连登录名一起换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ async function submit() {
|
||||
</button>
|
||||
|
||||
<p class="hint">
|
||||
默认账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>。
|
||||
初始账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>,
|
||||
登录后可在「设置 → 登录与存储」里改成自己的用户名和密码。
|
||||
</p>
|
||||
|
||||
<RouterLink to="/" class="back">← 回到前台</RouterLink>
|
||||
|
||||
@@ -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() {
|
||||
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
|
||||
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
|
||||
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
|
||||
<button :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -220,6 +287,15 @@ async function save() {
|
||||
<label>站点副标题</label>
|
||||
<input v-model="settings.site_desc" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>站点地址</label>
|
||||
<input v-model="settings.site_url" class="input" spellcheck="false"
|
||||
:placeholder="srcOf('site_url') === 'env' ? '当前来自环境变量 ONE_SITE_URL' : 'https://your.domain'" />
|
||||
<p class="field-hint">
|
||||
RSS 订阅链接、GitHub / Google 登录的回调地址都以它为基座。
|
||||
{{ srcHint('site_url') }},保存后立即生效,无需重启。
|
||||
</p>
|
||||
</div>
|
||||
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
|
||||
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
|
||||
<div class="field">
|
||||
@@ -417,6 +493,104 @@ async function save() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项) -->
|
||||
<div v-if="tab === 'system'" class="panel">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。
|
||||
GitHub / Google 的回调地址以「站点地址 + /api/auth/callback/平台名」为准,改完站点地址无需再去平台改回调。
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label>GitHub OAuth App</label>
|
||||
<input v-model="settings.github_client_id" class="input" spellcheck="false"
|
||||
placeholder="Client ID" style="margin-bottom: 8px;" />
|
||||
<input v-model="secrets.github_client_secret" class="input" type="password"
|
||||
autocomplete="new-password" :placeholder="secretPlaceholder('github_client_secret')" />
|
||||
<p class="field-hint">Client ID:{{ srcHint('github_client_id') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Google OAuth 客户端</label>
|
||||
<input v-model="settings.google_client_id" class="input" spellcheck="false"
|
||||
placeholder="Client ID" style="margin-bottom: 8px;" />
|
||||
<input v-model="secrets.google_client_secret" class="input" type="password"
|
||||
autocomplete="new-password" :placeholder="secretPlaceholder('google_client_secret')" />
|
||||
<p class="field-hint">Client ID:{{ srcHint('google_client_id') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Telegram 登录 Widget</label>
|
||||
<input v-model="settings.telegram_bot" class="input" spellcheck="false"
|
||||
placeholder="Bot 用户名(不含 @)" style="margin-bottom: 8px;" />
|
||||
<input v-model="secrets.telegram_bot_token" class="input" type="password"
|
||||
autocomplete="new-password" :placeholder="secretPlaceholder('telegram_bot_token')" />
|
||||
<p class="field-hint">Bot 用户名:{{ srcHint('telegram_bot') }};Token 在 @BotFather 里创建 Bot 后获得。</p>
|
||||
</div>
|
||||
|
||||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||||
<div class="panel-title"><h2>对象存储</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
文件上传的存储端。四项凭据齐全走 R2,缺任一项回落本地磁盘
|
||||
(data/uploads),切换不影响已上传文件的链接。保存后立即生效,无需重启。
|
||||
</p>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>S3 API 端点</label>
|
||||
<input v-model="settings.s3_endpoint" class="input" spellcheck="false"
|
||||
placeholder="https://<账户ID>.r2.cloudflarestorage.com" />
|
||||
<p class="field-hint">{{ srcHint('s3_endpoint') }}</p>
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>桶名(Bucket)</label>
|
||||
<input v-model="settings.r2_bucket" class="input" spellcheck="false" />
|
||||
<p class="field-hint">{{ srcHint('r2_bucket') }}</p>
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>访问密钥</label>
|
||||
<input v-model="secrets.r2_access_key" class="input" type="password"
|
||||
autocomplete="new-password" :placeholder="secretPlaceholder('r2_access_key')" />
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>私密密钥</label>
|
||||
<input v-model="secrets.r2_secret_key" class="input" type="password"
|
||||
autocomplete="new-password" :placeholder="secretPlaceholder('r2_secret_key')" />
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>公开访问域名</label>
|
||||
<input v-model="settings.uploads_public_base" class="input" spellcheck="false"
|
||||
placeholder="https://cdn.example.com(r2.dev 或绑定的自定义域名)" />
|
||||
<p class="field-hint">
|
||||
{{ srcHint('uploads_public_base') }}。配置后 R2 文件直链走 CDN;
|
||||
不配则由本站 /uploads/ 路由代为输出。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||||
<div class="panel-title"><h2>管理员账号</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
登录后台用的用户名与密码。在这里设置密码后,环境变量
|
||||
ONE_ADMIN_PASSWORD 只作解锁后路保留(仍可用);不填则维持现状。
|
||||
</p>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>用户名</label>
|
||||
<input v-model="settings.admin_username" class="input" spellcheck="false"
|
||||
:placeholder="srcOf('admin_username') === 'env' ? '当前来自环境变量 ONE_ADMIN_USER' : ''" />
|
||||
<p class="field-hint">{{ srcHint('admin_username') }}</p>
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>新密码</label>
|
||||
<input v-model="newPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="留空保持不变(6-72 位)" />
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="confirmPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="再输入一遍" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中…' : '保存设置' }}
|
||||
|
||||
Reference in new issue
Block a user