// 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"` // 毫秒 Info fs.FileInfo `json:"-"` } 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) 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 } // Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound func (v *Vault) Stat(rel string) (fs.FileInfo, error) { p, err := v.abs(rel) if err != nil { return nil, err } info, err := os.Stat(p) if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular() && !info.IsDir()) { return nil, ErrNotFound } return info, err } // 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 } // ---- 列表 ---- // IsImage 判断当前支持管理的图片扩展名,不用于验证文件内容。 func IsImage(p string) bool { switch strings.ToLower(path.Ext(p)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff": return true default: return false } } // Tree 列出文件夹、笔记和图片,沿用 TreeAt 的隐藏路径与符号链接边界。 func (v *Vault) Tree() ([]Entry, error) { entries, err := v.TreeAt("") if err != nil { return nil, err } visible := []Entry{} for _, e := range entries { if e.Dir { e.Size = 0 } if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) { visible = append(visible, e) } } return visible, nil } // TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。 func (v *Vault) TreeAt(rel string) ([]Entry, error) { root := v.root if rel != "" { var err error root, err = v.abs(rel) if err != nil { return nil, err } } entries := []Entry{} err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if p == root { return nil } if strings.HasPrefix(d.Name(), ".") { if d.IsDir() { return filepath.SkipDir } return nil } if d.Type()&fs.ModeSymlink != 0 { return nil } info, err := d.Info() if err != nil { return err } if !info.IsDir() && !info.Mode().IsRegular() { return nil } name, err := filepath.Rel(root, p) if err != nil { return err } entries = append(entries, Entry{Path: filepath.ToSlash(name), Dir: info.IsDir(), Size: info.Size(), ModTime: info.ModTime().UnixMilli(), Info: info}) return nil }) return entries, err } // ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。 func (v *Vault) ScopedPath(root, rel string) (string, error) { if _, err := CleanPath(rel); err != nil { return "", ErrNotFound } base, err := v.abs(root) if err != nil { return "", err } base, err = filepath.EvalSymlinks(base) if errors.Is(err, fs.ErrNotExist) { return "", ErrNotFound } if err != nil { return "", err } p := path.Join(root, rel) full, err := v.abs(p) if err != nil { return "", err } full, err = filepath.EvalSymlinks(full) if errors.Is(err, fs.ErrNotExist) { return "", ErrNotFound } if err != nil { return "", err } if full != filepath.Join(base, filepath.FromSlash(rel)) { return "", ErrNotFound } return p, nil } // ---- 移动 / 重命名 ---- // Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。 // 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 // commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。 func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) { src, err := v.abs(from) if err != nil { return false, err } dst, err := v.abs(to) if err != nil { return false, err } if from == to || strings.HasPrefix(to, from+"/") { return false, ErrInvalidPath } v.mu.Lock() defer v.mu.Unlock() info, err := os.Lstat(src) if errors.Is(err, fs.ErrNotExist) { return false, ErrNotFound } if err != nil { return false, err } isNote := !info.IsDir() && IsNote(from) isImage := !info.IsDir() && IsImage(from) validTarget := (isNote && IsNote(to)) || (isImage && strings.EqualFold(path.Ext(from), path.Ext(to))) if !info.IsDir() && (!info.Mode().IsRegular() || !validTarget) { return false, ErrInvalidPath } if _, err := os.Lstat(dst); err == nil { return false, 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 false, ErrExists } } } // 笔记、目录和图片的移动都可能改变跨文件相对链接。 links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "") if err != nil { return false, err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return false, err } if err := os.Rename(src, dst); err != nil { return false, err } assetsMoved := false writtenLinks := 0 defer func() { if err == nil { return } for _, change := range links[:writtenLinks] { if rollback := atomicWrite(change.path, change.before); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback)) } } if assetsMoved { if rollback := os.Rename(dstAssets, srcAssets); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback)) } } if rollback := os.Rename(dst, src); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) } }() for _, change := range links { if err = atomicWrite(change.path, change.after); err != nil { return false, err } writtenLinks++ if change.path == dst || info.IsDir() && strings.HasPrefix(change.path, dst+string(filepath.Separator)) { rewritten = true } } if srcAssets != "" { if err := os.Rename(srcAssets, dstAssets); err != nil { return false, fmt.Errorf("移动图片目录失败: %w", err) } assetsMoved = true } if commit != nil { err = commit() } return rewritten, err } // ---- 回收站 ---- 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) (item TrashItem, err 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() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) { return TrashItem{}, ErrInvalidPath } item = TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()} if !item.Dir && IsNote(rel) { st, statErr := os.Stat(AssetsDir(p)) if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { return TrashItem{}, statErr } if statErr == nil && st.IsDir() { item.Assets = filepath.Base(AssetsDir(p)) } } dir := filepath.Join(v.trash, item.ID) if err := os.MkdirAll(dir, 0o755); err != nil { return TrashItem{}, err } noteMoved := false defer func() { if err == nil { return } rollbackFailed := false if noteMoved { if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback)) rollbackFailed = true } } // 回滚失败时保留目录和元数据,避免继续删除仅存的恢复依据。 if !rollbackFailed { if cleanup := os.RemoveAll(dir); cleanup != nil { err = errors.Join(err, cleanup) } } }() meta, _ := json.Marshal(item) if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil { return item, err } if err = os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil { return item, err } noteMoved = true if item.Assets != "" { if err = os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil { return item, fmt.Errorf("移动图片目录到回收站失败: %w", err) } } return item, nil } 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) (item TrashItem, err 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 } assetsMoved, cleaning := false, false defer func() { if err == nil { return } if cleaning { // 清理可能已删除元数据;先重建恢复依据,再撤回本次移动。 if rollback := os.MkdirAll(dir, 0o755); rollback != nil { err = errors.Join(err, rollback) return } meta, _ := json.Marshal(item) if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil { err = errors.Join(err, rollback) } } if assetsMoved { if rollback := os.Rename(AssetsDir(dst), filepath.Join(dir, item.Assets)); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚恢复图片失败: %w", rollback)) } } if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚恢复笔记失败: %w", rollback)) } }() if item.Assets != "" { if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil { return item, fmt.Errorf("恢复图片目录失败: %w", err) } assetsMoved = true } cleaning = true 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 } name := assetName(data, origName) dir := AssetsDir(p) v.mu.Lock() defer v.mu.Unlock() if err := v.checkInside(dir); err != nil { return "", err } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } // 管理界面允许替换图片,哈希名称不再保证内容未变;不能把旧内容上传误判为重复。 base := strings.TrimSuffix(name, path.Ext(name)) for i := 0; ; i++ { candidate := name if i > 0 { candidate = fmt.Sprintf("%s-%d%s", base, i, path.Ext(name)) } target := filepath.Join(dir, candidate) if err := v.checkInside(target); err != nil { return "", err } existing, err := os.ReadFile(target) if errors.Is(err, fs.ErrNotExist) { if err := atomicWrite(target, data); err != nil { return "", err } } else if err != nil { return "", err } else if !bytes.Equal(existing, data) { continue } return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + candidate, nil } } func assetName(data []byte, origName string) string { ext := strings.ToLower(filepath.Ext(origName)) if !extPattern.MatchString(ext) { ext = sniffExt(data) } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:8]) + ext } 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("%", "%25", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")