765 lines
20 KiB
Go
765 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) (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")
|