Files
pure-note/internal/httpapi/public.go
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

197 lines
6.7 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 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"`
PublishedAt int64 `json:"published_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, PublishedAt: n.PublishedAt,
})
}
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,
"published_at": note.PublishedAt,
"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)
}