diff --git a/README.md b/README.md index d7b1a08..85c2090 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ ## 功能 -- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签 -- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容) +- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选 +- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo - **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密 -- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除 -- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4),深色/浅色/跟随系统三态主题 -- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`) +- **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除 +- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题 +- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`) ## 快速开始 @@ -20,7 +20,7 @@ # 1. 构建单二进制(前端 + 后端) make build -# 2. 初始化(设置管理员口令与站点标题) +# 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档) ./pn init # 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init diff --git a/cmd/pn/main.go b/cmd/pn/main.go index c0f594d..c0b4ba6 100644 --- a/cmd/pn/main.go +++ b/cmd/pn/main.go @@ -226,6 +226,10 @@ func runInit(args []string) error { if err := st.SetSetting(store.KeySiteTitle, title); err != nil { return err } + // 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片 + if _, err := seedWelcome(st); err != nil { + return fmt.Errorf("写入初始文档失败: %w", err) + } fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir) return nil } diff --git a/cmd/pn/seed.go b/cmd/pn/seed.go new file mode 100644 index 0000000..71e42c1 --- /dev/null +++ b/cmd/pn/seed.go @@ -0,0 +1,183 @@ +// init 种子内容:一篇展示常用 Markdown 语法的初始文档 + 一张程序生成的示例图片。 +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + "image/color" + "image/png" + "time" + + "pure-note/internal/store" +) + +// welcomeSlug 固定 slug,便于用户识别与重装后覆盖判断(init 幂等由口令守卫保证)。 +const welcomeSlug = "welcome" + +// seedWelcome 写入初始文档(公开)与其引用的示例图片;返回写入的笔记 id。 +func seedWelcome(st *store.Store) (int64, error) { + imgID, err := seedWelcomeImage(st) + if err != nil { + return 0, fmt.Errorf("写入示例图片: %w", err) + } + content := welcomeMarkdown(imgID) + now := time.Now().Unix() + n := &store.Note{ + Slug: welcomeSlug, + Title: "欢迎使用 Pure Note:Markdown 速览", + Summary: "一篇入门笔记,覆盖日常写作会用到的全部 Markdown 语法:标题、列表、引用、代码、表格与图片。", + Content: content, + Status: "public", + Tags: []string{"Markdown", "入门"}, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + id, err := st.CreateNote(n) + if err != nil { + return 0, err + } + if err := st.RebuildImageRefs(id, content); err != nil { + return 0, err + } + return id, nil +} + +// welcomeMarkdown 初始文档正文:覆盖常用语法,并引用示例图片。 +func welcomeMarkdown(imgID int64) string { + return fmt.Sprintf(`这是一篇初始化时自动创建的示例笔记,覆盖日常写作会用到的绝大部分 Markdown 语法。随时可以编辑或删除它。 + +## 基础排版 + +普通段落之间用一个空行分隔。行内语法有:**加粗**、*斜体*、***粗斜体***、~~删除线~~,以及行内代码 `+"`pn start`"+`。也可以直接粘贴链接: + +## 标题层级 + +### 三级标题 + +#### 四级标题 + +标题会进入文章大纲,正文建议从二级标题用起。 + +## 列表 + +无序列表: + +- 支持嵌套 + - 子项缩进两个空格 +- 快捷键 `+"`Ctrl/⌘ + S`"+` 可手动保存 + +有序列表: + +1. 在编辑器里写 Markdown +2. 粘贴图片直接上传 +3. 点击发布即可访问 + +任务列表: + +- [x] 初始化站点 +- [x] 阅读本文档 +- [ ] 写第一篇笔记 + +## 引用 + +> 简单,可靠,只有一个数据文件。 +> —— Pure Note 设计哲学 + +## 代码块 + +代码块支持语法高亮: + +`+"```go"+` + +package main + +import "fmt" + +func main() { + fmt.Println("Hello, Pure Note!") +} + +`+"```"+` + +`+"```"+`javascript +const note = { title: "你好", tags: ["Markdown"] }; +console.log(`+"`开始写作:${note.title}`"+`); +`+"```"+` + +## 表格 + +| 语法 | 用途 | 示例 | +| --- | --- | --- | +| **文本** | 加粗 | **重要** | +| `+"`代码`"+` | 行内代码 | `+"`npm run dev`"+` | +| [文本](url) | 超链接 | [首页](/) | + +## 图片 + +在编辑器里直接粘贴或拖入图片即可上传(≤5MB),下面这张就是初始化时自动生成的示例图: + +![示例图片](/api/images/%d) + +## 其他 + +--- + +私有笔记仅自己可见,公开笔记会出现在首页与 RSS 中;删除的笔记进入回收站保留 30 天。祝写作愉快。 +`, imgID) +} + +// seedWelcomeImage 生成一张对角渐变 PNG(zinc 色系),入库后返回图片 id。 +func seedWelcomeImage(st *store.Store) (int64, error) { + data, err := gradientPNG() + if err != nil { + return 0, err + } + return st.UpsertImage(sha256Hex(data), "image/png", len(data), data, time.Now().Unix()) +} + +// gradientPNG 绘制 960×360 对角渐变:zinc-950 → zinc-400,叠加一条高光斜带。 +func gradientPNG() ([]byte, error) { + const w, h = 960, 360 + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + from := color.RGBA{R: 24, G: 24, B: 27, A: 255} // #18181b + to := color.RGBA{R: 161, G: 161, B: 170, A: 255} // #a1a1aa + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + t := (float64(x)/float64(w-1) + float64(y)/float64(h-1)) / 2 + // 高光斜带:在 t≈0.55 附近提亮,形成柔和的光泽过渡 + band := 0.16 * float64(1) / (1 + pow2((t-0.55)*9)) + c := color.RGBA{ + R: lerp(from.R, to.R, t+band), + G: lerp(from.G, to.G, t+band), + B: lerp(from.B, to.B, t+band), + A: 255, + } + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func lerp(a, b uint8, t float64) uint8 { + if t < 0 { + t = 0 + } else if t > 1 { + t = 1 + } + return uint8(float64(a) + (float64(b)-float64(a))*t) +} + +func pow2(x float64) float64 { return x * x } + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/docs/design.md b/docs/design.md index 529481e..55ae71d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -58,8 +58,8 @@ | F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 | | F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) | | F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 | -| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) | -| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 | +| F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) | +| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 | | F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 | ### 2.2 非功能需求 @@ -232,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes ( pinned INTEGER NOT NULL DEFAULT 0, deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除) created_at INTEGER NOT NULL, -- Unix 秒 - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at ); CREATE INDEX IF NOT EXISTS idx_notes_public ON notes (status, pinned, updated_at DESC); @@ -267,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings ( ); -- 键白名单(§7.1 SettingsDTO 同源): -- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$$ --- site_title / site_desc / page_size +-- site_title / site_desc / page_size / beian_no / site_logo(站内绝对路径) -- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3) ``` @@ -326,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings ( | PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 | | DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 | | GET | `/api/admin/trash` | 回收站列表 | +| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) | | POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) | | POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB | | GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) | -| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 | +| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `admin_password_hash`、写只收白名单键 | | POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 | ### 7.2 中间件链与服务器参数 @@ -379,7 +381,7 @@ SecurityHeaders(含 HSTS,§9.2) | 子命令 / 开关 | 说明 | | --- | --- | | `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` | -| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 | +| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 | | `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 | | `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 | | `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) | @@ -398,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2) | `/tags/:tag` | 标签页 | 该标签下公开笔记 | | `/admin/login` | 登录页 | 口令登录、防爆破提示 | | `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) | -| `/admin/trash` | 回收站 | 已删列表、恢复 | -| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 | +| `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 | +| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 | | `*` | 404 | 统一兜底 | ### 8.2 核心技术要点 diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go index 2d4fca2..d4f5be9 100644 --- a/internal/httpapi/admin.go +++ b/internal/httpapi/admin.go @@ -27,16 +27,17 @@ import ( // adminNoteItem 管理列表项(不含全文)。 type adminNoteItem struct { - ID int64 `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Summary string `json:"summary"` - Status string `json:"status"` - Tags []string `json:"tags"` - Pinned bool `json:"pinned"` - DeletedAt *int64 `json:"deleted_at,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Summary string `json:"summary"` + Status string `json:"status"` + Tags []string `json:"tags"` + Pinned bool `json:"pinned"` + DeletedAt *int64 `json:"deleted_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + PublishedAt int64 `json:"published_at"` } func toAdminItem(n *store.Note) adminNoteItem { @@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem { ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary, Status: n.Status, Tags: n.Tags, Pinned: n.Pinned, DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, + PublishedAt: n.PublishedAt, } } @@ -61,15 +63,16 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)}) } -// notePayload 笔记写请求体。 +// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。 type notePayload struct { - Title string `json:"title"` - Slug string `json:"slug"` - Summary string `json:"summary"` - Content string `json:"content"` - Status string `json:"status"` - Tags []string `json:"tags"` - Pinned bool `json:"pinned"` + Title string `json:"title"` + Slug string `json:"slug"` + Summary string `json:"summary"` + Content string `json:"content"` + Status string `json:"status"` + Tags []string `json:"tags"` + Pinned bool `json:"pinned"` + PublishedAt *int64 `json:"published_at"` } func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息) @@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(summary) == "" { summary = markdown.Summary(p.Content, 200) } + publishedAt := now + if p.PublishedAt != nil && *p.PublishedAt > 0 { + publishedAt = *p.PublishedAt + } n := &store.Note{ Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary, Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags), - Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now, + Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now, } id, err := s.st.CreateNote(n) if err != nil { @@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) { n.Tags = store.NormalizeTags(p.Tags) n.Pinned = p.Pinned n.Content = p.Content + if p.PublishedAt != nil && *p.PublishedAt > 0 { + n.PublishedAt = *p.PublishedAt + } if strings.TrimSpace(p.Summary) == "" { n.Summary = markdown.Summary(p.Content, 200) } else { @@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)}) } +// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。 +func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) { + n, err := s.st.EmptyTrash() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + s.log.Info("admin_action", "op", "trash.empty", "count", n) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n}) +} + // handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。 func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) { id, ok := pathID(r) @@ -397,6 +418,7 @@ type settingsDTO struct { SiteDesc *string `json:"site_desc"` PageSize *int `json:"page_size"` BeianNo *string `json:"beian_no"` + SiteLogo *string `json:"site_logo"` } // handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。 @@ -415,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) dec.DisallowUnknownFields() var dto settingsDTO if err := dec.Decode(&dto); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)") + writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)") return } if dto.SiteTitle != nil { @@ -440,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) } } if dto.PageSize != nil { - if *dto.PageSize < 1 || *dto.PageSize > 100 { - writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]") + if !store.ValidPageSize(*dto.PageSize) { + writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50") return } if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil { @@ -460,6 +482,18 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request) return } } + if dto.SiteLogo != nil { + // 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示 + v := strings.TrimSpace(*dto.SiteLogo) + if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) { + writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空") + return + } + if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil { + writeError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + } // admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。 s.log.Info("admin_action", "op", "settings.update") ss, err := s.st.GetSiteSettings() diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index b584475..d89beaf 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -284,7 +284,7 @@ func TestSettingsWhitelist(t *testing.T) { } // PUT 合法键 resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", - []byte(`{"site_title":"新标题","site_desc":"描述","page_size":5,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders()) + []byte(`{"site_title":"新标题","site_desc":"描述","page_size":20,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders()) if resp.StatusCode != 200 { t.Errorf("合法 PUT 应 200: %d", resp.StatusCode) } @@ -311,14 +311,16 @@ func TestSettingsWhitelist(t *testing.T) { } // page_size 生效为公开列表默认 _, body = e.get(e.client(), "/api/notes") - if !strings.Contains(string(body), `"page_size":5`) { + if !strings.Contains(string(body), `"page_size":20`) { t.Errorf("page_size 设置应生效: %s", body) } - // page_size 越界 - resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", - []byte(`{"page_size":0}`), e.adminHeaders()) - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("page_size=0 应 400: %d", resp.StatusCode) + // page_size 越界/非候选项 + for _, bad := range []string{"0", "5", "101"} { + resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", + []byte(`{"page_size":`+bad+`}`), e.adminHeaders()) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode) + } } } diff --git a/internal/httpapi/feed.go b/internal/httpapi/feed.go index 6bc4d89..8b4b595 100644 --- a/internal/httpapi/feed.go +++ b/internal/httpapi/feed.go @@ -29,7 +29,7 @@ type rssItem struct { Title string `xml:"title"` Link string `xml:"link"` GUID string `xml:"guid"` - PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范) + PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范) Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义) } @@ -92,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) { // ---- sitemap(仅公开笔记)---- type urlSet struct { - XMLName xml.Name `xml:"urlset"` - XMLNS string `xml:"xmlns,attr"` + XMLName xml.Name `xml:"urlset"` + XMLNS string `xml:"xmlns,attr"` URLs []siteURL `xml:"url"` } diff --git a/internal/httpapi/public.go b/internal/httpapi/public.go index 8d5c451..53b4518 100644 --- a/internal/httpapi/public.go +++ b/internal/httpapi/public.go @@ -42,13 +42,14 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) { // 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"` + 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)。 @@ -70,7 +71,7 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) { 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, + CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt, }) } writeJSON(w, http.StatusOK, map[string]any{ @@ -119,8 +120,9 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) { "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), + "published_at": note.PublishedAt, + "prev": siblingOrEmpty(prevSlug, prevTitle), + "next": siblingOrEmpty(nextSlug, nextTitle), }) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7ab3970..1ce0831 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -29,12 +29,12 @@ const ( // Server HTTP 服务。 type Server struct { - st *store.Store - cfg *config.Config - log *slog.Logger - global *middleware.Limiter // 全局宽松限流(per-IP) - loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟 - loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟 + st *store.Store + cfg *config.Config + log *slog.Logger + global *middleware.Limiter // 全局宽松限流(per-IP) + loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟 + loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟 } // New 构造 Server(生产限流参数,§7.2)。 @@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler { adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate))) adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete) adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash) + adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty) adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore) adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload))) adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages) @@ -291,13 +292,13 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta { ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle} } m := webui.Meta{ - Title: ss.SiteTitle, - Description: ss.SiteDesc, - OGTitle: ss.SiteTitle, + Title: ss.SiteTitle, + Description: ss.SiteDesc, + OGTitle: ss.SiteTitle, OGDescription: ss.SiteDesc, - OGType: "website", - SiteName: ss.SiteTitle, - OGURL: baseURL(r) + "/", + OGType: "website", + SiteName: ss.SiteTitle, + OGURL: baseURL(r) + "/", } slug := metaSlug(r.URL.Path) if slug == "" { diff --git a/internal/httpapi/visibility_test.go b/internal/httpapi/visibility_test.go index ea3c5ae..5e3ad4b 100644 --- a/internal/httpapi/visibility_test.go +++ b/internal/httpapi/visibility_test.go @@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) { // ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)---- imageCases := []struct { - name string - id int64 - anonCode int - anonCache string - adminCode int - adminCache string + name string + id int64 + anonCode int + anonCache string + adminCode int + adminCache string }{ {"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"}, {"私有图", f.imgPriv, 404, "", 200, "private, no-store"}, diff --git a/internal/store/images.go b/internal/store/images.go index f46d815..9934e72 100644 --- a/internal/store/images.go +++ b/internal/store/images.go @@ -101,11 +101,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error { } // ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。 +// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。 func (s *Store) ListOrphanImages() ([]Image, error) { rows, err := s.db.Query(` SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id) - ORDER BY i.created_at DESC`) + AND i.id <> ? + ORDER BY i.created_at DESC`, s.logoImageID()) if err != nil { return nil, err } diff --git a/internal/store/maint.go b/internal/store/maint.go index 6481220..b79f375 100644 --- a/internal/store/maint.go +++ b/internal/store/maint.go @@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) { } rows.Close() - // 孤儿图片:0 引用且超过宽限期 + // 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护 imageCutoff := now.Add(-OrphanGrace).Unix() + logoID := s.logoImageID() orphanRows, err := s.db.Query(` SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id) - ORDER BY i.created_at`, imageCutoff) + AND i.id <> ? + ORDER BY i.created_at`, imageCutoff, logoID) if err != nil { return nil, err } @@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) { } } for _, img := range rep.OrphanImages { + // 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图 res, err := s.db.Exec( - `DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`, - img.ID, img.ID) + `DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`, + img.ID, img.ID, logoID) if err != nil { return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err) } diff --git a/internal/store/notes.go b/internal/store/notes.go index 1bb03cd..ae9b373 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -9,31 +9,34 @@ import ( ) // Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。 +// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。 type Note struct { - ID int64 `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Summary string `json:"summary"` - Content string `json:"content,omitempty"` - Status string `json:"status"` - Tags []string `json:"tags"` - Pinned bool `json:"pinned"` - DeletedAt *int64 `json:"deleted_at,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Summary string `json:"summary"` + Content string `json:"content,omitempty"` + Status string `json:"status"` + Tags []string `json:"tags"` + Pinned bool `json:"pinned"` + DeletedAt *int64 `json:"deleted_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + PublishedAt int64 `json:"published_at"` } // ErrNotFound 统一的「不存在」错误。 var ErrNotFound = errors.New("not found") -const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at" +// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。 +const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at" func scanNote(scan func(dest ...any) error) (*Note, error) { var n Note var tagsJSON string var pinned int var content string - if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil { + if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil { return nil, err } n.Content = content @@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) { return 0, err } res, err := s.db.Exec( - `INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt) + `INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt) if err != nil { return 0, err } @@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error { return err } res, err := s.db.Exec( - `UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID) + `UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID) if err != nil { return err } @@ -164,7 +167,7 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil { return nil, 0, err } - q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at + q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at FROM notes n WHERE ` + where + ` ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?` rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...) @@ -234,7 +237,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) { // ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。 func (s *Store) ListAdminNotes() ([]Note, error) { - q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at + q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at FROM notes WHERE deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC, id DESC` return s.queryNotes(q) @@ -242,12 +245,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) { // ListTrash 回收站列表(软删除中),不含全文。 func (s *Store) ListTrash() ([]Note, error) { - q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at + q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` return s.queryNotes(q) } +// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。 +func (s *Store) EmptyTrash() (int64, error) { + res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + // ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。 func (s *Store) ListExpiredTrash(before int64) ([]int64, error) { rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before) diff --git a/internal/store/settings.go b/internal/store/settings.go index eee6d1a..4b29ad7 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -14,6 +14,7 @@ const ( KeySiteDesc = "site_desc" KeyPageSize = "page_size" KeyBeianNo = "beian_no" + KeySiteLogo = "site_logo" ) // GetSetting 读取单个设置。 @@ -39,6 +40,7 @@ type SiteSettings struct { SiteDesc string `json:"site_desc"` PageSize int `json:"page_size"` BeianNo string `json:"beian_no"` + SiteLogo string `json:"site_logo"` } // Defaults,未初始化时兜底。 @@ -49,6 +51,15 @@ const ( DefaultBeianNo = "" ) +// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。 +func ValidPageSize(n int) bool { + switch n { + case 10, 20, 30, 50: + return true + } + return false +} + // GetSiteSettings 读取站点设置(白名单键,带默认值)。 func (s *Store) GetSiteSettings() (*SiteSettings, error) { ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo} @@ -65,8 +76,9 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) { if v, ok, err := s.GetSetting(KeyPageSize); err != nil { return nil, err } else if ok { - // strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13) - if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 { + // strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13); + // 脏值/越界值回退默认,而非落入任意 [1,100] + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) { ss.PageSize = n } } @@ -75,5 +87,25 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) { } else if ok { ss.BeianNo = strings.TrimSpace(v) } + if v, ok, err := s.GetSetting(KeySiteLogo); err != nil { + return nil, err + } else if ok { + ss.SiteLogo = strings.TrimSpace(v) + } return ss, nil } + +// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。 +func (s *Store) logoImageID() int64 { + v, ok, err := s.GetSetting(KeySiteLogo) + if err != nil || !ok || v == "" { + return 0 + } + if m := imageRefRe.FindStringSubmatch(v); m != nil { + var id int64 + if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 { + return id + } + } + return 0 +} diff --git a/internal/store/store.go b/internal/store/store.go index 63de246..706e9f6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); +`, + // v2: 笔记发布日期(可自选);存量笔记回填为 created_at + ` +ALTER TABLE notes ADD COLUMN published_at INTEGER; +UPDATE notes SET published_at = created_at WHERE published_at IS NULL; `, } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d55f67a..0e28cc1 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -150,10 +150,10 @@ func TestAdjacentPublicNote(t *testing.T) { t.Fatal(err) } } - mk("a", 100, false, "public") // id=1 - mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前) - mk("c", 80, false, "public") // id=3 - mk("p", 50, true, "public") // id=4 置顶 → 列表首 + mk("a", 100, false, "public") // id=1 + mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前) + mk("c", 80, false, "public") // id=3 + mk("p", 50, true, "public") // id=4 置顶 → 列表首 mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中 get := func(slug string) *Note { diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 4b8feda..d82c8da 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -64,12 +64,16 @@ function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) export function BlogLayout() { const { data } = useSiteTitle() const title = data?.site_title ?? 'Pure Note' + const logo = data?.site_logo ?? '' const location = useLocation() return (
- {title} + + {logo && } + {title} +
+
-