Files
ONE/backend/internal/api/api.go
T
Sakurasan 1a2db5ddac 管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 user
- 公开 API 解析顺序:读者 cookie → 管理员 cookie(provider=admin 站主读者,名字取站点作者名)
- 站主发评论跳过禁言与审核;后台禁言接口拒绝站主身份
- CommentItem 读 c.author 改为 c.user(后端实际字段),站主徽章按 provider 判断
2026-09-28 00:43:25 +08:00

349 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/storage"
"oneblog/internal/store"
)
type API struct {
Store *store.Store
Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
AdminSessions interface {
Verify(token string) (string, error)
}
}
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/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/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
}
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
}
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
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()
w.Header().Set("Content-Type", f.Mime)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("ETag", `"`+f.SHA256+`"`)
// key 含内容哈希:内容不变则 URL 不变,可永久缓存
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
io.Copy(w, rc)
}