Files
ONE/backend/internal/api/api.go
T
Sakurasan 6108aca34c 缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
  落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
  一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
  补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
  并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
  {settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
  新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
  (站主豁免,且只计成功写入)
2026-09-28 13:34:19 +08:00

384 lines
10 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 api serves the read-only public surface: site settings, posts,
// archive, tags and RSS. Nothing here requires authentication.
package api
import (
"encoding/xml"
"errors"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
type API struct {
Store *store.Store
Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
// 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 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
AdminSessions interface {
Verify(token string) (string, error)
}
// 其余登录方式(main.go 装配,未配置的自动不开放)
GG auth.Google
TG auth.Telegram
// 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。
// 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、
// Telegram 伪造签名这类恶意请求需要一个兜底。
authFails *ratelimit.Window
commentNew *ratelimit.Window
}
const (
maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调)
authFailWindow = 10 * time.Minute
maxComments = 5 // 每个读者每窗口最多发几条
commentWindow = time.Minute
)
func (a *API) Routes() http.Handler {
if a.authFails == nil {
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
a.commentNew = ratelimit.New(maxComments, commentWindow)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil {
httpx.Error(w, http.StatusServiceUnavailable, "database unreachable")
return
}
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
})
// 读者登录与评论
mux.HandleFunc("/api/auth/providers", a.authProviders)
mux.HandleFunc("/api/auth/me", a.authMe)
mux.HandleFunc("/api/auth/logout", a.authLogout)
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/", a.commentSub)
mux.HandleFunc("/api/site", a.site)
mux.HandleFunc("/api/posts", a.listPosts)
mux.HandleFunc("/api/posts/", a.getPost)
mux.HandleFunc("/api/archive", a.archive)
mux.HandleFunc("/api/tags", a.tags)
mux.HandleFunc("/api/projects", a.projects)
return mux
}
// Mount registers the feed routes on the root mux, where they are not
// shadowed by the SPA catch-all.
func (a *API) Mount(root *http.ServeMux) {
root.Handle("/api/", a.Routes())
root.HandleFunc("/rss.xml", a.RSS)
root.HandleFunc("/feed", a.RSS)
root.HandleFunc("/feed.xml", a.RSS)
}
func (a *API) site(w http.ResponseWriter, r *http.Request) {
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
}
func listOptions(r *http.Request, defSize int) store.ListOptions {
return store.ListOptions{
Kind: httpx.QueryString(r, "kind"),
Tag: httpx.QueryString(r, "tag"),
Query: httpx.QueryString(r, "q"),
Page: httpx.QueryInt(r, "page", 1),
Size: httpx.QueryInt(r, "size", defSize),
Status: model.StatusPublished,
}
}
func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
page, err := a.Store.List(listOptions(r, 10))
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, page)
}
func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/api/posts/")
slug = strings.TrimSuffix(slug, "/")
if slug == "" {
a.listPosts(w, r)
return
}
p, err := a.Store.GetBySlug(slug)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
if p.Status != model.StatusPublished {
httpx.NotFound(w)
return
}
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
nb, err := a.Store.Neighbors(slug)
if err != nil {
nb = nil
}
httpx.OK(w, struct {
model.Post
Neighbors []model.Post `json:"neighbors"`
}{p, nb})
}
func (a *API) archive(w http.ResponseWriter, r *http.Request) {
years, err := a.Store.Archive()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"years": years})
}
func (a *API) tags(w http.ResponseWriter, r *http.Request) {
tags, err := a.Store.ListTags()
if err != nil {
httpx.ServerError(w, err)
return
}
if tags == nil {
tags = []model.Tag{}
}
httpx.OK(w, map[string]any{"tags": tags})
}
func (a *API) projects(w http.ResponseWriter, r *http.Request) {
projects, err := a.Store.ListProjects(model.StatusPublished)
if err != nil {
httpx.ServerError(w, err)
return
}
if projects == nil {
projects = []model.Project{}
}
httpx.OK(w, map[string]any{"projects": projects})
}
// ---------- RSS ----------
type rssItem struct {
XMLName xml.Name `xml:"item"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
Category []string `xml:"category,omitempty"`
}
type rssChannel struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
LastBuild string `xml:"lastBuildDate"`
Items []rssItem `xml:"item"`
}
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel rssChannel `xml:"channel"`
}
// RSS renders the site feed.
func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
settings, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
page, err := a.Store.List(store.ListOptions{Status: model.StatusPublished, Page: 1, Size: 30})
if err != nil {
httpx.ServerError(w, err)
return
}
base := strings.TrimSuffix(a.Cfg.SiteURL, "/")
feed := rssFeed{
Version: "2.0",
Channel: rssChannel{
Title: orDefault(settings.SiteTitle, "ONE"),
Link: base + "/",
Description: orDefault(settings.SiteDesc, ""),
Language: "zh-CN",
LastBuild: time.Now().UTC().Format(time.RFC1123Z),
Items: []rssItem{},
},
}
for _, p := range page.Items {
title := p.Title
if p.Kind == model.KindShort || title == "" {
title = shortTitle(p)
}
desc := p.Summary
if desc == "" {
desc = trimRunes(stripTags(p.ContentHTML), 160)
}
item := rssItem{
Title: title,
Link: base + "/post/" + p.Slug,
Description: desc,
PubDate: rssDate(p.PublishedAt),
GUID: base + "/post/" + p.Slug,
Category: p.Tags,
}
if p.Kind == model.KindShort {
item.Title = "[短] " + title
}
feed.Channel.Items = append(feed.Channel.Items, item)
}
out, err := xml.MarshalIndent(feed, "", " ")
if err != nil {
httpx.ServerError(w, err)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(out)
_, _ = w.Write([]byte("\n"))
}
func shortTitle(p model.Post) string {
s := strings.TrimSpace(stripTags(p.ContentHTML))
if s == "" {
return "无题"
}
return trimRunes(s, 40)
}
func stripTags(s string) string {
var b strings.Builder
in := false
for _, r := range s {
switch {
case r == '<':
in = true
case r == '>':
in = false
case !in:
b.WriteRune(r)
}
}
return html.UnescapeString(strings.TrimSpace(b.String()))
}
func trimRunes(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
func rssDate(rfc3339 string) string {
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
return time.Now().UTC().Format(time.RFC1123Z)
}
return t.UTC().Format(time.RFC1123Z)
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
// ---------- uploads(上传文件的公开访问) ----------
// UploadsHandler 供 main.go 挂在根 mux 的 /uploads/ 前缀上。按 key 查索引行,
// 经存储层流式返回本体;R2 + 公开域名时 302 到直链(后端不出流量)。
// key 必须在 files 表里有行——防止拿任意对象名探测存储端。
func (a *API) UploadsHandler() http.Handler {
return http.HandlerFunc(a.uploads)
}
func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// R2 且配了公开域名:302 到直链,后端不出流量
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)
if err != nil {
httpx.NotFound(w)
return
}
defer rc.Close()
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
w.Header().Set("Content-Type", f.Mime)
w.Header().Set("ETag", `"`+f.SHA256+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if rs, ok := rc.(io.ReadSeeker); ok {
http.ServeContent(w, r, f.Name, time.Time{}, rs)
return
}
// 非本地存储拿不到 Seeker 时退回流式拷贝
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
io.Copy(w, rc)
}