// 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 }