/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
195 lines
6.5 KiB
Go
195 lines
6.5 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"pure-note/internal/store"
|
||
)
|
||
|
||
// handleHealth GET /api/health。
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
if err := s.st.DB().Ping(); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "db_unavailable", "数据库不可用")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
|
||
// 响应随会话态变化且含 CSRF token → no-store。
|
||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
if sess, ok := s.sessionFrom(r); ok {
|
||
s.maybeRotate(w, sess)
|
||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||
}
|
||
|
||
// handleSiteInfo GET /api/site:站点设置白名单视图(标题/副标题/每页条数)。
|
||
// 永不包含 admin_password_hash(§9.3)。
|
||
func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
||
ss, err := s.st.GetSiteSettings()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, ss)
|
||
}
|
||
|
||
// publicNoteItem 公开列表项(元信息,不含全文)。
|
||
type publicNoteItem struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Summary string `json:"summary"`
|
||
Tags []string `json:"tags"`
|
||
Pinned bool `json:"pinned"`
|
||
CreatedAt int64 `json:"created_at"`
|
||
UpdatedAt int64 `json:"updated_at"`
|
||
}
|
||
|
||
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||
func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
||
page, pageSize, ok := s.parsePagination(r)
|
||
if !ok {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "分页参数越界(page ∈ [1,10000],page_size ∈ [1,100])")
|
||
return
|
||
}
|
||
tag := r.URL.Query().Get("tag")
|
||
notes, total, err := s.st.ListPublicNotes(page, pageSize, tag)
|
||
if err != nil {
|
||
s.log.Error("查询公开列表失败", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
items := make([]publicNoteItem, 0, len(notes))
|
||
for _, n := range notes {
|
||
items = append(items, publicNoteItem{
|
||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||
Tags: n.Tags, Pinned: n.Pinned,
|
||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"items": items, "page": page, "page_size": pageSize, "total": total,
|
||
})
|
||
}
|
||
|
||
// handlePublicNote GET /api/notes/{slug}:public 或管理员会话可读;其余统一 404。
|
||
func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
note, err := s.st.GetNoteBySlug(slug)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
if note.DeletedAt != nil {
|
||
// 回收站内容仅经 /api/admin/trash 出口可见(§13)
|
||
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||
return
|
||
}
|
||
if note.Status != "public" {
|
||
if _, isAdmin := s.sessionFrom(r); !isAdmin {
|
||
// 私有笔记对匿名统一 404(不泄露存在性);管理员可私有预览
|
||
writeError(w, http.StatusNotFound, "not_found", "笔记不存在")
|
||
return
|
||
}
|
||
}
|
||
prevSlug, prevTitle, err := s.st.AdjacentPublicNote(note, "prev")
|
||
if err != nil {
|
||
s.log.Error("查询上一篇失败", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
nextSlug, nextTitle, err := s.st.AdjacentPublicNote(note, "next")
|
||
if err != nil {
|
||
s.log.Error("查询下一篇失败", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"id": note.ID, "slug": note.Slug, "title": note.Title,
|
||
"summary": note.Summary, "content": note.Content,
|
||
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||
})
|
||
}
|
||
|
||
func siblingOrEmpty(slug, title string) map[string]any {
|
||
if slug == "" {
|
||
return nil
|
||
}
|
||
return map[string]any{"slug": slug, "title": title}
|
||
}
|
||
|
||
// handleTags GET /api/tags:标签聚合(仅公开且未删除)。
|
||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||
tags, err := s.st.PublicTags()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
if tags == nil {
|
||
tags = []store.TagCount{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"tags": tags})
|
||
}
|
||
|
||
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
|
||
// 缓存头按可见性分流(§7.4)。缓存头在数据读取成功后才设置,
|
||
// 错误路径不携带公开缓存指令(防 404 被 CDN 缓存一年)。
|
||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||
idStr := r.PathValue("id")
|
||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||
if err != nil || id <= 0 {
|
||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||
return
|
||
}
|
||
img, err := s.st.GetImageMeta(id)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||
return
|
||
}
|
||
public, err := s.st.ImageIsPublic(id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||
return
|
||
}
|
||
if !public {
|
||
if _, isAdmin := s.sessionFrom(r); !isAdmin {
|
||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||
return
|
||
}
|
||
}
|
||
// 先取数据:数据行竞态缺失时返回的 404 不携带任何缓存指令
|
||
data, err := s.st.GetImageData(id)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||
return
|
||
}
|
||
if public {
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
} else {
|
||
// 非公开图:每次请求重新判定,禁止缓存
|
||
w.Header().Set("Cache-Control", "private, no-store")
|
||
}
|
||
w.Header().Set("Content-Type", img.MIME)
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("ETag", `"`+img.SHA256+`"`)
|
||
if r.Header.Get("If-None-Match") == `"`+img.SHA256+`"` {
|
||
w.WriteHeader(http.StatusNotModified)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||
_, _ = w.Write(data)
|
||
}
|