后端: - ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试 - 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器 - Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限 - 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB 前端: - EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章) - v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser - 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复 - 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复 - 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
257 lines
5.9 KiB
Go
257 lines
5.9 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"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"oneblog/internal/config"
|
|
"oneblog/internal/httpx"
|
|
"oneblog/internal/model"
|
|
"oneblog/internal/store"
|
|
)
|
|
|
|
type API struct {
|
|
Store *store.Store
|
|
Cfg *config.Config
|
|
}
|
|
|
|
func (a *API) Routes() http.Handler {
|
|
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/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)
|
|
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
|
|
}
|
|
httpx.OK(w, st)
|
|
}
|
|
|
|
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
|
|
}
|
|
httpx.OK(w, p)
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// ---------- 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
|
|
}
|