feat(server): add Go backend with vault, auth, events and REST API

- vault: path validation (no traversal, hidden segments or symlink
  escape), atomic writes with If-Match/If-None-Match revisions, moves
  that carry the note's .assets folder and rewrite its image links,
  trash with restore, content-addressed asset uploads
- auth: single-user Argon2id password, sliding sessions in SQLite,
  login rate limiting, first-run setup gated by a one-time code written
  to the data directory
- watch: fsnotify plus periodic rescans diffed against a snapshot, so
  both RawMark and external edits are broadcast as tree/changed/moved/
  deleted events over WebSocket
- api: stdlib router, SameSite=Strict cookies plus Origin checks,
  sandboxed raw file serving, embedded SPA with gzip and CSP
This commit is contained in:
eezd committed 2026-10-16 18:23:23 +08:00
1 parent 5746f2e9ae
commit c4d2bd4636
15 files changed
+2196

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
package vault
import (
"bytes"
"regexp"
"strings"
)
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过围栏代码块和行内代码。
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
target := `(\./)?(?:` + oldRaw + `|` + oldEsc + `)`
inline := regexp.MustCompile(`(\]\(\s*<?|\bsrc\s*=\s*["'])` + target)
refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*<?)` + target)
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
lines := bytes.SplitAfter(content, []byte("\n"))
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " ")
if fence != nil {
if bytes.HasPrefix(trimmed, fence) && len(bytes.TrimSpace(bytes.TrimLeft(trimmed, string(fence[:1])))) == 0 {
fence = nil
}
continue
}
if f := fenceOpen(line); f != nil {
fence = f
continue
}
line = refDef.ReplaceAll(line, repl)
lines[i] = replaceOutsideCode(line, inline, repl)
}
return bytes.Join(lines, nil)
}
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串
func fenceOpen(line []byte) []byte {
indent := len(line) - len(bytes.TrimLeft(line, " "))
if indent > 3 {
return nil
}
rest := line[indent:]
if len(rest) < 3 || (rest[0] != '`' && rest[0] != '~') {
return nil
}
n := 0
for n < len(rest) && rest[n] == rest[0] {
n++
}
if n < 3 || (rest[0] == '`' && bytes.IndexByte(rest[n:], '`') >= 0) {
return nil
}
return rest[:n]
}
// replaceOutsideCode 只在行内代码 `…` 之外的片段上做替换
func replaceOutsideCode(line []byte, re *regexp.Regexp, repl []byte) []byte {
var out []byte
for len(line) > 0 {
start := bytes.IndexByte(line, '`')
if start < 0 {
break
}
n := 1
for start+n < len(line) && line[start+n] == '`' {
n++
}
end := bytes.Index(line[start+n:], line[start:start+n])
if end < 0 {
break
}
codeEnd := start + n + end + n
out = append(out, re.ReplaceAll(line[:start], repl)...)
out = append(out, line[start:codeEnd]...)
line = line[codeEnd:]
}
return append(out, re.ReplaceAll(line, repl)...)
}
+579
View File
@@ -0,0 +1,579 @@
// Package vault 管理笔记仓库目录:文件即真源,所有路径以仓库根为基准、使用 "/" 分隔。
package vault
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
var (
ErrInvalidPath = errors.New("非法路径")
ErrNotFound = errors.New("文件不存在")
ErrExists = errors.New("目标已存在")
ErrTooLarge = errors.New("文件过大")
)
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空)
type ConflictError struct{ Rev string }
func (e *ConflictError) Error() string { return "版本冲突" }
const (
NoteExt = ".md"
AssetsExt = ".assets"
metaDir = ".rawmark"
tmpPrefix = ".rawmark-tmp-"
)
type Vault struct {
root string // 已解析符号链接的绝对路径
trash string
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性
mu sync.Mutex
}
type Entry struct {
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒
}
type TrashItem struct {
ID string `json:"id"`
Path string `json:"path"`
Dir bool `json:"dir"`
DeletedAt int64 `json:"deletedAt"` // 毫秒
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录)
Assets string `json:"assets,omitempty"`
}
func Open(root string) (*Vault, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
real, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, err
}
real, err = filepath.Abs(real)
if err != nil {
return nil, err
}
trash := filepath.Join(real, metaDir, "trash")
if err := os.MkdirAll(trash, 0o755); err != nil {
return nil, err
}
return &Vault{root: real, trash: trash}, nil
}
func (v *Vault) Root() string { return v.root }
// Rev 为内容版本号:sha256 前 8 字节的十六进制
func Rev(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:8])
}
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露)
func CleanPath(p string) (string, error) {
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
return "", ErrInvalidPath
}
for _, seg := range strings.Split(p, "/") {
if seg == "" || strings.HasPrefix(seg, ".") {
return "", ErrInvalidPath
}
}
return p, nil
}
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets
func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
func (v *Vault) abs(rel string) (string, error) {
rel, err := CleanPath(rel)
if err != nil {
return "", err
}
p := filepath.Join(v.root, filepath.FromSlash(rel))
if err := v.checkInside(p); err != nil {
return "", err
}
return p, nil
}
// checkInside 确认路径最深的已存在祖先在解析符号链接后仍位于仓库内,防止经符号链接逃逸
func (v *Vault) checkInside(p string) error {
for cur := p; ; {
real, err := filepath.EvalSymlinks(cur)
if err == nil {
if real != v.root && !strings.HasPrefix(real, v.root+string(filepath.Separator)) {
return ErrInvalidPath
}
return nil
}
if !errors.Is(err, fs.ErrNotExist) {
return err
}
parent := filepath.Dir(cur)
if parent == cur {
return ErrInvalidPath
}
cur = parent
}
}
func (v *Vault) rel(abs string) string {
r, _ := filepath.Rel(v.root, abs)
return filepath.ToSlash(r)
}
// ---- 读写 ----
func (v *Vault) Read(rel string) ([]byte, string, error) {
if !IsNote(rel) {
return nil, "", ErrInvalidPath
}
p, err := v.abs(rel)
if err != nil {
return nil, "", err
}
data, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
return data, Rev(data), nil
}
// Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖
type Precondition struct {
IfMatch string
IfNoneMatch bool
}
func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, error) {
if !IsNote(rel) {
return "", ErrInvalidPath
}
p, err := v.abs(rel)
if err != nil {
return "", err
}
v.mu.Lock()
defer v.mu.Unlock()
cur, err := os.ReadFile(p)
exists := err == nil
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", err
}
if pre.IfNoneMatch && exists {
return "", ErrExists
}
if pre.IfMatch != "" {
if !exists {
return "", &ConflictError{}
}
if Rev(cur) != pre.IfMatch {
return "", &ConflictError{Rev: Rev(cur)}
}
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return "", err
}
if err := atomicWrite(p, content); err != nil {
return "", err
}
return Rev(content), nil
}
// atomicWrite 先写同目录临时文件并 fsync,再 rename 覆盖,保证不会留下写了一半的文件
func atomicWrite(p string, data []byte) error {
dir := filepath.Dir(p)
f, err := os.CreateTemp(dir, tmpPrefix+"*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // rename 成功后为空操作
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Chmod(tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, p); err != nil {
return err
}
if d, err := os.Open(dir); err == nil {
d.Sync()
d.Close()
}
return nil
}
func (v *Vault) Mkdir(rel string) error {
p, err := v.abs(rel)
if err != nil {
return err
}
v.mu.Lock()
defer v.mu.Unlock()
if _, err := os.Lstat(p); err == nil {
return ErrExists
}
return os.MkdirAll(p, 0o755)
}
// OpenRaw 打开仓库内的普通文件(图片等资源),调用方负责关闭
func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
p, err := v.abs(rel)
if err != nil {
return nil, nil, err
}
f, err := os.Open(p)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, ErrNotFound
}
if err != nil {
return nil, nil, err
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
f.Close()
if err == nil {
err = ErrNotFound
}
return nil, nil, err
}
return f, info, nil
}
// ---- 列表 ----
// skipName 判断目录树中不展示的条目:隐藏文件/目录(含临时文件)与 .assets 图片目录
func skipName(name string, dir bool) bool {
return strings.HasPrefix(name, ".") || (dir && strings.HasSuffix(name, AssetsExt))
}
// Tree 列出所有文件夹与 .md 笔记
func (v *Vault) Tree() ([]Entry, error) {
entries := []Entry{}
err := filepath.WalkDir(v.root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == v.root {
return nil
}
if skipName(d.Name(), d.IsDir()) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && !IsNote(d.Name()) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
e := Entry{Path: v.rel(p), Dir: d.IsDir(), ModTime: info.ModTime().UnixMilli()}
if !e.Dir {
e.Size = info.Size()
}
entries = append(entries, e)
return nil
})
return entries, err
}
// ---- 移动 / 重命名 ----
// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接
func (v *Vault) Move(from, to string) error {
src, err := v.abs(from)
if err != nil {
return err
}
dst, err := v.abs(to)
if err != nil {
return err
}
if from == to || strings.HasPrefix(to, from+"/") {
return ErrInvalidPath
}
v.mu.Lock()
defer v.mu.Unlock()
info, err := os.Lstat(src)
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
if err != nil {
return err
}
isNote := !info.IsDir()
if isNote && (!IsNote(from) || !IsNote(to)) {
return ErrInvalidPath
}
if _, err := os.Lstat(dst); err == nil {
return ErrExists
}
srcAssets, dstAssets := "", ""
if isNote {
if st, err := os.Stat(assetsDir(src)); err == nil && st.IsDir() {
srcAssets, dstAssets = assetsDir(src), assetsDir(dst)
if _, err := os.Lstat(dstAssets); err == nil {
return ErrExists
}
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.Rename(src, dst); err != nil {
return err
}
if srcAssets != "" {
if err := os.Rename(srcAssets, dstAssets); err != nil {
return fmt.Errorf("移动图片目录失败: %w", err)
}
oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
newBase := strings.TrimSuffix(path.Base(to), NoteExt)
if oldBase != newBase {
data, err := os.ReadFile(dst)
if err != nil {
return err
}
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
return atomicWrite(dst, out)
}
}
}
return nil
}
// ---- 回收站 ----
func newTrashID() string {
var b [4]byte
rand.Read(b[:])
return time.Now().UTC().Format("20060102T150405.000") + "-" + hex.EncodeToString(b[:])
}
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
// Delete 把笔记(连同 .assets)或文件夹移入回收站
func (v *Vault) Delete(rel string) (TrashItem, error) {
p, err := v.abs(rel)
if err != nil {
return TrashItem{}, err
}
v.mu.Lock()
defer v.mu.Unlock()
info, err := os.Lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return TrashItem{}, ErrNotFound
}
if err != nil {
return TrashItem{}, err
}
if !info.IsDir() && !IsNote(rel) {
return TrashItem{}, ErrInvalidPath
}
item := TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
dir := filepath.Join(v.trash, item.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return TrashItem{}, err
}
if err := os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil {
os.Remove(dir)
return TrashItem{}, err
}
if !item.Dir {
if st, err := os.Stat(assetsDir(p)); err == nil && st.IsDir() {
item.Assets = filepath.Base(assetsDir(p))
if err := os.Rename(assetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err)
}
}
}
meta, _ := json.Marshal(item)
return item, atomicWrite(filepath.Join(dir, "meta.json"), meta)
}
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
if !trashIDPattern.MatchString(id) {
return TrashItem{}, ErrInvalidPath
}
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
if errors.Is(err, fs.ErrNotExist) {
return TrashItem{}, ErrNotFound
}
if err != nil {
return TrashItem{}, err
}
var item TrashItem
if err := json.Unmarshal(data, &item); err != nil {
return TrashItem{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
}
return item, nil
}
func (v *Vault) TrashList() ([]TrashItem, error) {
dirs, err := os.ReadDir(v.trash)
if err != nil {
return nil, err
}
items := []TrashItem{}
for _, d := range dirs {
item, err := v.readTrashItem(d.Name())
if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) {
continue // 非本程序创建的目录或删除未完成的条目
}
if err != nil {
return nil, err
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].DeletedAt > items[j].DeletedAt })
return items, nil
}
func (v *Vault) Restore(id string) (TrashItem, error) {
item, err := v.readTrashItem(id)
if err != nil {
return TrashItem{}, err
}
dst, err := v.abs(item.Path)
if err != nil {
return TrashItem{}, err
}
v.mu.Lock()
defer v.mu.Unlock()
if _, err := os.Lstat(dst); err == nil {
return TrashItem{}, ErrExists
}
if item.Assets != "" {
if _, err := os.Lstat(assetsDir(dst)); err == nil {
return TrashItem{}, ErrExists
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return TrashItem{}, err
}
dir := filepath.Join(v.trash, id)
if err := os.Rename(filepath.Join(dir, filepath.Base(dst)), dst); err != nil {
return TrashItem{}, err
}
if item.Assets != "" {
if err := os.Rename(filepath.Join(dir, item.Assets), assetsDir(dst)); err != nil {
return TrashItem{}, fmt.Errorf("恢复图片目录失败: %w", err)
}
}
return item, os.RemoveAll(dir)
}
func (v *Vault) Purge(id string) error {
if _, err := v.readTrashItem(id); err != nil {
return err
}
return os.RemoveAll(filepath.Join(v.trash, id))
}
// ---- 资源 ----
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
// SaveAsset 把上传的文件存入笔记的 .assets 目录,以内容哈希命名(相同内容只存一份),
// 返回可直接写入 Markdown 的相对链接
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
if !IsNote(note) {
return "", ErrInvalidPath
}
p, err := v.abs(note)
if err != nil {
return "", err
}
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return "", err
}
if int64(len(data)) > limit {
return "", ErrTooLarge
}
ext := strings.ToLower(filepath.Ext(origName))
if !extPattern.MatchString(ext) {
ext = sniffExt(data)
}
sum := sha256.Sum256(data)
name := hex.EncodeToString(sum[:8]) + ext
dir := assetsDir(p)
v.mu.Lock()
defer v.mu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, name)
if _, err := os.Stat(target); errors.Is(err, fs.ErrNotExist) {
if err := atomicWrite(target, data); err != nil {
return "", err
}
} else if err != nil {
return "", err
}
return EscapeLinkPath(path.Base(assetsDir(note))) + "/" + name, nil
}
func sniffExt(data []byte) string {
switch http.DetectContentType(data) {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".bin"
}
}
// EscapeLinkPath 只转义会破坏 Markdown 链接目标的字符,中文等保持原样(与 Typora 写法一致)
func EscapeLinkPath(s string) string {
return linkEscaper.Replace(s)
}
var linkEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
+154
View File
@@ -0,0 +1,154 @@
package vault
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func newVault(t *testing.T) *Vault {
t.Helper()
v, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
return v
}
func TestCleanPathRejectsUnsafe(t *testing.T) {
for _, p := range []string{"", "/a.md", "../a.md", "a/../b.md", "a//b.md", "./a.md", ".rawmark/x.md", "a/.git/b.md", "a\\b.md", "a\x00.md"} {
if _, err := CleanPath(p); !errors.Is(err, ErrInvalidPath) {
t.Errorf("%q 应被拒绝", p)
}
}
for _, p := range []string{"a.md", "目录/笔记 1.md", "a/b/c.md"} {
if _, err := CleanPath(p); err != nil {
t.Errorf("%q 应被接受: %v", p, err)
}
}
}
func TestSymlinkEscapeRejected(t *testing.T) {
v := newVault(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(v.Root(), "link")); err != nil {
t.Skip("无法创建符号链接:", err)
}
if _, err := v.Write("link/x.md", []byte("x"), Precondition{}); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("经符号链接写到仓库外应被拒绝, got %v", err)
}
if _, err := os.Stat(filepath.Join(outside, "x.md")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("仓库外出现了文件")
}
}
func TestWritePreconditions(t *testing.T) {
v := newVault(t)
rev, err := v.Write("a/b.md", []byte("one"), Precondition{IfNoneMatch: true})
if err != nil {
t.Fatal(err)
}
if _, err := v.Write("a/b.md", []byte("x"), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrExists) {
t.Fatalf("重复创建应失败, got %v", err)
}
var conflict *ConflictError
if _, err := v.Write("a/b.md", []byte("x"), Precondition{IfMatch: "stale"}); !errors.As(err, &conflict) || conflict.Rev != rev {
t.Fatalf("旧版本写入应返回冲突与当前版本, got %v", err)
}
rev2, err := v.Write("a/b.md", []byte("two"), Precondition{IfMatch: rev})
if err != nil {
t.Fatal(err)
}
data, got, err := v.Read("a/b.md")
if err != nil || string(data) != "two" || got != rev2 {
t.Fatalf("读取结果不符: %q %s %v", data, got, err)
}
if _, err := v.Write("a.txt", []byte("x"), Precondition{}); !errors.Is(err, ErrInvalidPath) {
t.Fatal("非 .md 文件应被拒绝")
}
}
func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
v := newVault(t)
src := "笔记 A.md\n![](笔记%20A.assets/x.png)\n![](./笔记 A.assets/y.png)\n```\n![](笔记%20A.assets/code.png)\n```\n`笔记%20A.assets/inline`\n"
if _, err := v.Write("dir/笔记 A.md", []byte(src), Precondition{}); err != nil {
t.Fatal(err)
}
link, err := v.SaveAsset("dir/笔记 A.md", "x.png", strings.NewReader("png"), 1<<20)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(link, "笔记%20A.assets/") {
t.Fatalf("资源链接不符: %s", link)
}
if err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil {
t.Fatal(err)
}
data, _, err := v.Read("other/B.md")
if err != nil {
t.Fatal(err)
}
want := "笔记 A.md\n![](B.assets/x.png)\n![](./B.assets/y.png)\n```\n![](笔记%20A.assets/code.png)\n```\n`笔记%20A.assets/inline`\n"
if string(data) != want {
t.Fatalf("链接改写不符:\n%s", data)
}
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
t.Fatal("图片目录未随笔记移动:", err)
}
if err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
t.Fatal("文件夹移入自身应被拒绝")
}
}
func TestDeleteRestore(t *testing.T) {
v := newVault(t)
v.Write("n.md", []byte("x"), Precondition{})
v.SaveAsset("n.md", "a.png", strings.NewReader("img"), 100)
item, err := v.Delete("n.md")
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("图片目录应一并移入回收站")
}
items, _ := v.TrashList()
if len(items) != 1 || items[0].ID != item.ID {
t.Fatalf("回收站列表不符: %+v", items)
}
if _, err := v.Restore(item.ID); err != nil {
t.Fatal(err)
}
if _, _, err := v.Read("n.md"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
t.Fatal("图片目录未恢复")
}
if items, _ := v.TrashList(); len(items) != 0 {
t.Fatal("恢复后回收站应为空")
}
if _, err := v.Restore("../../etc"); !errors.Is(err, ErrInvalidPath) {
t.Fatal("非法回收站 ID 应被拒绝")
}
}
func TestTreeHidesMetaAndAssets(t *testing.T) {
v := newVault(t)
v.Write("a.md", []byte("x"), Precondition{})
v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10)
v.Mkdir("folder")
os.WriteFile(filepath.Join(v.Root(), "note.txt"), []byte("x"), 0o644)
entries, err := v.Tree()
if err != nil {
t.Fatal(err)
}
var paths []string
for _, e := range entries {
paths = append(paths, e.Path)
}
if strings.Join(paths, ",") != "a.md,folder" {
t.Fatalf("目录树不符: %v", paths)
}
}