- 新增 /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 条/分钟
(站主豁免,且只计成功写入)
384 lines
10 KiB
Go
384 lines
10 KiB
Go
// 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)
|
||
}
|