Files
RawMark/internal/vault/vault.go
T
eezd a716794e41 feat(ui): improve readability and fix workspace regressions
Separate interface typography from document text and enlarge controls across the workspace and public reader. Keep tree menus within the viewport and make constrained dialogs scrollable.

Preserve nested image references, escape moved link paths, synchronize note creation and daily-note opening, and hydrate shared headings. Add regression coverage for settings and workspace behavior.
2026-10-23 10:55:53 +08:00

789 lines
20 KiB
Go

// 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) (TrashItem, error) {
v.mu.Lock()
defer v.mu.Unlock()
return v.deleteLocked(rel)
}
func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
p, err := v.abs(rel)
if err != nil {
return TrashItem{}, err
}
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
}
return v.saveAssetAt(AssetsDir(note), origName, r, limit)
}
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) {
return "", fmt.Errorf("%w:请选择图片文件", ErrInvalidPath)
}
if folder != "" {
info, err := v.Stat(folder)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", ErrInvalidPath
}
}
return v.saveAssetAt(path.Join(folder, "assets"), origName, r, limit)
}
func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int64) (string, error) {
dir, err := v.abs(relativeDir)
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)
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(relativeDir)) + "/" + 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", "&", "%26", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")