- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed), 服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与 immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与 409 字段级错误、fail-only 登录限流(429 + Retry-After)、 设置白名单(永不序列化口令哈希) - internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、 深链回退 index.html)+ html/template 元信息注入(仅可见笔记) - cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与 每小时会话清理 - 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、 上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
179 lines
5.9 KiB
Go
179 lines
5.9 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}。
|
||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||
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, _ := s.st.AdjacentPublicNote(note, "prev")
|
||
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
|
||
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)。
|
||
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
|
||
}
|
||
// 非公开图:每次请求重新判定,禁止缓存
|
||
w.Header().Set("Cache-Control", "private, no-store")
|
||
} else {
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
}
|
||
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
|
||
}
|
||
data, err := s.st.GetImageData(id)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||
return
|
||
}
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||
_, _ = w.Write(data)
|
||
}
|