Files
ONE/backend/main.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

227 lines
7.6 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.
// Command one-server is the ONE blog backend: public API, admin API, RSS and
// (when built) the static frontend.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"oneblog/internal/admin"
"oneblog/internal/api"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/hub"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
func main() {
addr := flag.String("addr", "", "listen address (overrides ONE_ADDR)")
flag.Parse()
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if *addr != "" {
cfg.Addr = *addr
}
pool, err := db.Open(cfg.Driver, cfg.DSN)
if err != nil {
log.Fatalf("database: %v", err)
}
defer pool.Close()
st, err := store.New(pool)
if err != nil {
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"))
}
// 评论区的读者会话(cookie one_reader),30 天有效
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
// 评论变更广播(SSE):公开端订阅、双端发布,共用同一实例
commentHub := hub.New()
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
// Passkey(WebAuthn):只有显式配了 ONE_WEBAUTHN_ORIGINS 才启用。
// 没配就不构造 —— 相关端点保持 404,前端也不会显示入口。
// 显示名固定 "ONE":站点标题存在 settings 表里,为浏览器弹窗去读库不值当。
var passkeys *auth.Passkeys
if cfg.WebauthnRPID != "" && len(cfg.WebauthnOrigins) > 0 {
passkeys, err = auth.NewPasskeys("ONE", cfg.WebauthnRPID, cfg.WebauthnOrigins)
if err != nil {
log.Fatalf("passkeys: %v", err)
}
log.Printf("passkey 登录已启用 (rp_id=%s origins=%s)", cfg.WebauthnRPID, strings.Join(cfg.WebauthnOrigins, ","))
}
public := &api.API{
Store: st,
Cfg: cfg,
Blobs: blobs,
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.Hub = commentHub
adminAPI.Blobs = blobs
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
adminAPI.Passkeys = passkeys
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
public.Mount(root)
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
root.Handle("/uploads/thumb/", public.ThumbHandler())
root.Handle("/uploads/", public.UploadsHandler())
root.Handle("/", spaHandler(cfg.WebDist))
srv := &http.Server{
Addr: cfg.Addr,
Handler: requestLog(root),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
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)
} else {
log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser)
}
if !dirExists(cfg.WebDist) {
log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist)
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Print("bye")
}
// spaHandler serves the built Vue app and falls back to index.html so client
// routes like /post/foo work on refresh.
func spaHandler(dist string) http.Handler {
fs := http.Dir(dist)
fileServer := http.FileServer(fs)
index := filepath.Join(dist, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !dirExists(dist) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, placeholderPage())
return
}
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Open(p); err == nil && hasExt(p) {
// 缓存策略分两类:
// assets/ 下的文件名带内容 hash(Vite 产物),内容变了文件名就变,
// 所以可以长期强缓存;其余(主要是 index.html)必须每次回源校验,
// 否则新构建不会生效 : 页面「改了但看着还是旧的」通常就是它。
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
fileServer.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
http.ServeFile(w, r, index)
})
}
func hasExt(p string) bool {
return strings.Contains(filepath.Base(p), ".")
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func requestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if strings.HasPrefix(r.URL.Path, "/api") || r.URL.Path == "/rss.xml" {
log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), rec.status, time.Since(start).Truncate(time.Millisecond))
}
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }
// Flush 透传给底层 writer——SSE 这类流式响应靠它判定可刷新,
// 包装器不实现 Flusher 的话流式端点会直接不可用。
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func placeholderPage() string {
return `<!doctype html><meta charset="utf-8"><title>ONE</title>
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b;
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
div{max-width:520px;line-height:1.95;padding:0 24px}
code{background:#efe9dd;padding:2px 6px;border-radius:3px}</style>
<div><h1 style="font-family:Georgia,serif">ONE · 一个博客</h1>
<p>后端已启动,但前端还没构建。</p>
<p>在项目根目录执行 <code>make web</code> 构建前端,或 <code>make dev</code> 同时起前后端。</p>
<p>接口可用:<code>/api/posts</code>、<code>/api/archive</code>、<code>/api/tags</code>、<code>/rss.xml</code>;后台接口在 <code>/api/admin/</code>。</p></div>`
}