Files
ONE/backend/internal/api/api.go
T
Sakurasan 3238175ef0 作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
2026-09-22 01:47:59 +08:00

270 lines
6.2 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)
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
}
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})
}
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
}