Files
ONE/backend/internal/config/config.go
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

204 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"crypto/rand"
"encoding/hex"
"log"
"net/url"
"os"
"path/filepath"
"strings"
)
type Config struct {
Addr string
Driver string // sqlite | postgres
DSN string
AdminUser string
AdminPass string
SessionSec string
WebDist string
DataDir string
SiteURL string
InsecureDev bool
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
GitHubClientID string
GitHubClientSecret string
// 评论区 Google 登录(OAuth 客户端凭据,Google Cloud Console 创建)
GoogleClientID string
GoogleClientSecret string
// 评论区 Telegram 登录(Login Widget):Bot 是用户名(不含 @,下发给
// 前端 widget),Token 用于验签。两者都要,缺一该入口不开放。
TelegramBot string
TelegramToken string
// Passkey(WebAuthn)。RPID 是域名(不含端口),Origins 是允许的完整来源。
// 必须显式配 ONE_WEBAUTHN_ORIGINS 才启用(它是发放永久登录凭据的功能);
// 启用后 RPID 默认取 SiteURL 的主机名,Origin 列表会自动并入 SiteURL 本身。
// 开发时前端在 :3000、后端在 :8080,所以要把两个来源都写上:
// ONE_WEBAUTHN_ORIGINS=http://localhost:3000,http://localhost:8080
WebauthnRPID string // env: ONE_WEBAUTHN_RP_ID
WebauthnOrigins []string // env: ONE_WEBAUTHN_ORIGINS(空 = 不启用)
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
// PublicURL = 公开访问域名(r2.dev / 绑定的自定义域名),文件直链走它
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
// (DataDir/uploads),并在启动日志提示一句。
StorageDriver string // r2 | local
S3Endpoint string // env: S3Api
R2Bucket string
R2AccessKey string
R2SecretKey string
UploadsPublicBase string // env: PublicURL
}
func getenv(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}
func Load() (*Config, error) {
root := getenv("ONE_ROOT", "")
if root == "" {
if wd, err := os.Getwd(); err == nil {
root = filepath.Dir(wd) // server/ -> repo root
} else {
root = "."
}
}
c := &Config{
Addr: getenv("ONE_ADDR", ":8080"),
Driver: strings.ToLower(getenv("ONE_DB_DRIVER", "sqlite")),
AdminUser: getenv("ONE_ADMIN_USER", "admin"),
AdminPass: getenv("ONE_ADMIN_PASSWORD", "admin"),
SessionSec: getenv("ONE_SECRET", ""),
WebDist: getenv("ONE_WEB_DIST", filepath.Join(root, "frontend", "dist")),
DataDir: getenv("ONE_DATA_DIR", filepath.Join(root, "data")),
SiteURL: getenv("ONE_SITE_URL", "http://localhost:8080"),
}
if c.Driver == "" {
c.Driver = "sqlite"
}
if c.Driver != "sqlite" && c.Driver != "postgres" && c.Driver != "postgresql" {
return nil, &badDriver{c.Driver}
}
if c.Driver == "postgresql" {
c.Driver = "postgres"
}
if c.DSN = getenv("ONE_DB_DSN", ""); c.DSN == "" {
if c.Driver == "sqlite" {
c.DSN = filepath.Join(c.DataDir, "one.db")
} else {
c.DSN = "postgres://localhost/one?sslmode=disable"
}
}
if c.SessionSec == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
c.SessionSec = hex.EncodeToString(b)
}
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
// 对象存储:变量名按站主 .env 里整理好的来(无 ONE_ 前缀)。
c.UploadsPublicBase = strings.TrimRight(getenv("PublicURL", ""), "/")
c.S3Endpoint = getenv("S3Api", "")
c.R2AccessKey = getenv("AccessKey", "")
c.R2SecretKey = getenv("SecretAccessKey", "")
c.R2Bucket = getenv("Bucket", "")
if c.S3Endpoint != "" && c.R2Bucket != "" && c.R2AccessKey != "" && c.R2SecretKey != "" {
c.StorageDriver = "r2"
} else {
c.StorageDriver = "local"
// 配了一半(有凭据没端点之类)时给一句启动日志,别让站主猜。
// 只报字段名,不报值。
var missing []string
if c.S3Endpoint == "" {
missing = append(missing, "S3Api")
}
if c.R2Bucket == "" {
missing = append(missing, "Bucket")
}
if c.R2AccessKey == "" {
missing = append(missing, "AccessKey")
}
if c.R2SecretKey == "" {
missing = append(missing, "SecretAccessKey")
}
if len(missing) > 0 {
log.Printf("storage: R2 配置缺 %s,上传回落本地磁盘", strings.Join(missing, "、"))
}
}
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
c.GoogleClientID = getenv("ONE_GOOGLE_CLIENT_ID", "")
c.GoogleClientSecret = getenv("ONE_GOOGLE_CLIENT_SECRET", "")
c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "")
c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "")
// Passkey:默认跟着 SiteURL,显式配了 ONE_WEBAUTHN_ORIGINS 才算启用
// (没配就不注册相关路由,也不给前端暴露入口)。
c.WebauthnRPID, c.WebauthnOrigins = webauthnFromSite(c.SiteURL, getenv("ONE_WEBAUTHN_RP_ID", ""), getenv("ONE_WEBAUTHN_ORIGINS", ""))
return c, nil
}
// webauthnFromSite 推导 RPID 与允许的 Origin 列表。
// 只有显式配了 ONE_WEBAUTHN_ORIGINS 才返回非空列表 —— 没配就视为不启用
// (返回 nil,调用方跳过构造)。这是个发放永久登录凭据的功能,
// 默认关闭比默认开启安全;SiteURL 推导出的 origin 只在启用后作为额外来源。
func webauthnFromSite(siteURL, rpID, origins string) (string, []string) {
list := []string{}
for _, o := range strings.Split(origins, ",") {
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
list = append(list, o)
}
}
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
if rpID == "" {
rpID = u.Hostname() // Hostname() 会去掉端口
}
}
if len(list) == 0 {
return "", nil // 未启用:RPID 一并清空,免得调用方误判成可用
}
// 启用后把站点自身来源也加上(生产环境页面就来自这里)
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
if origin := u.Scheme + "://" + u.Host; !containsStr(list, origin) {
list = append(list, origin)
}
}
return rpID, list
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if strings.EqualFold(v, s) {
return true
}
}
return false
}
type badDriver struct{ d string }
func (e *badDriver) Error() string {
return "unsupported ONE_DB_DRIVER: " + e.d + " (use sqlite or postgres)"
}