MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user