Files
ONE/backend/main.go
T
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00

227 lines
7.7 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/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)
}
// 有效配置 = 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)
// 后台管理员会话(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,
Res: res,
Thumbs: thumbCache,
ReaderSessions: readerSessions,
AdminSessions: adminSessions,
Passkeys: passkeys,
Hub: commentHub,
}
adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Res = res
adminAPI.Hub = commentHub
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)
// 登录提示按有效配置报:后台改过密码/用户名后,这里说的是 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 / %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)
}
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>`
}