Files
RawMark/internal/vault/vault.go
T

888 lines
24 KiB
Go

// Package vault manages the note directory: files are the source of truth, and all paths are vault-relative with "/" separators.
package vault
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"rawmark/internal/i18n"
"regexp"
"sort"
"strings"
"sync"
"time"
)
var (
ErrInvalidPath = i18n.New("invalid_path_ec93bf")
ErrNotFound = i18n.New("file_not_found_3703e4")
ErrExists = i18n.New("destination_already_exists_9511c3")
ErrTooLarge = i18n.New("file_is_too_large_5bfcab")
)
// ConflictError indicates a failed If-Match check; Rev is the current server revision (empty if the file does not exist).
type ConflictError struct{ Rev string }
func (e *ConflictError) Error() string { return i18n.New("version_conflict").Error() }
const (
NoteExt = ".md"
AssetsExt = ".assets"
metaDir = ".rawmark"
tmpPrefix = ".rawmark-tmp-"
)
type Vault struct {
root string // Absolute path with symlinks resolved.
trash string
// Serialize writes to make compound operations such as revision-check/write and moves atomic.
mu sync.Mutex
}
type Entry struct {
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // Milliseconds.
Info fs.FileInfo `json:"-"`
Icon *TreeIcon `json:"icon,omitempty"`
}
type TrashItem struct {
ID string `json:"id"`
Path string `json:"path"`
Dir bool `json:"dir"`
DeletedAt int64 `json:"deletedAt"` // Milliseconds.
// .assets directory deleted with the note, relative to the recycle-bin entry directory.
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 is the content revision: the first eight SHA-256 bytes in hexadecimal.
func Rev(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:8])
}
// CleanPath validates client-relative paths: reject absolute paths, empty segments, "."/"..", and hidden segments starting with ".".
// Hidden segments include .rawmark metadata and .git; none are exposed through the 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 returns the note's Typora-style image directory: 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 resolves the deepest existing ancestor and ensures it stays inside the vault, preventing symlink escapes.
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
}
}
// ---- Reading and writing ----
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 returns regular-file or directory information, or ErrNotFound if missing.
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 maps HTTP conditions: IfMatch requires the current revision; IfNoneMatch requires a new file; omitting both overwrites unconditionally.
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 writes and fsyncs a sibling temporary file before renaming it over the destination, preventing partial files.
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) // No-op after a successful 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 opens a regular vault file (such as an image); the caller must close it.
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
}
// ---- Listing ----
// IsImage recognizes extensions supported by image management; it does not validate file contents.
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 lists folders, notes, and images within TreeAt's hidden-path and symlink boundaries.
func (v *Vault) Tree() ([]Entry, error) {
entries, err := v.TreeAt("")
if err != nil {
return nil, err
}
icons, err := v.readTreeIcons()
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+"/")) {
if icon, ok := icons[e.Path]; ok {
e.Icon = &icon
}
visible = append(visible, e)
}
}
return visible, nil
}
// TreeAt recursively lists regular files and subfolders relative to the selected directory. An empty path selects the vault root; hidden entries and symlinks are not exposed.
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
}
// ---- Moving / renaming ----
// Move moves or renames a note, image, or folder and reports whether atomic rewriting changed the moved item's file identity.
// The note's .assets directory follows it; renaming also rewrites links to its own image directory.
func (v *Vault) Move(from, to string) (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
}
icons, err := v.readTreeIcons()
if err != nil {
return false, err
}
movedIcons := takeTreeIcons(icons, from, info.IsDir())
iconsChanged := len(movedIcons) > 0
for p, icon := range movedIcons {
icons[movedPath(p, from, to, info.IsDir())] = icon
}
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
}
}
}
if srcAssets != "" {
attached := takeTreeIcons(icons, AssetsDir(from), true)
iconsChanged = iconsChanged || len(attached) > 0
for p, icon := range attached {
icons[movedPath(p, AssetsDir(from), AssetsDir(to), true)] = icon
}
}
var images []imageTransfer
var imageTargets map[string]string
defer func() {
if err == nil {
return
}
for _, image := range images {
if image.removed {
if rollback := atomicWrite(filepath.Join(v.root, filepath.FromSlash(image.source)), image.data); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_the_d7c4fc", rollback))
}
}
}
for _, image := range images {
if image.created {
if rollback := os.Remove(filepath.Join(v.root, filepath.FromSlash(image.target))); rollback != nil {
err = errors.Join(err, i18n.New("could_not_remove_the_copied_e96efd", rollback))
}
}
}
}()
if isNote && path.Dir(from) != path.Dir(to) {
images, imageTargets, err = v.copyNoteImages(from, to)
if err != nil {
return false, err
}
}
// Moving notes, folders, or images can change relative links between files.
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
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
}
writtenLinks := 0
assetsMoved := false
defer func() {
if err == nil {
return
}
if assetsMoved {
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_the_f89084", rollback))
}
}
for _, change := range links[:writtenLinks] {
if rollback := atomicWrite(change.path, change.before); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_note_8554a3", rollback))
}
}
if rollback := os.Rename(dst, src); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_the_36ec22", 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, i18n.New("could_not_move_image_folder_c39928", err)
}
assetsMoved = true
}
for i := range images {
if images[i].removeSource {
if err := os.Remove(filepath.Join(v.root, filepath.FromSlash(images[i].source))); err != nil {
return false, err
}
images[i].removed = true
}
}
if iconsChanged {
err = v.writeTreeIcons(icons)
}
return rewritten, err
}
// ---- Recycle bin ----
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 moves a note (with .assets), image, or folder to the recycle bin.
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))
}
}
icons, err := v.readTreeIcons()
if err != nil {
return TrashItem{}, err
}
metadata := trashMetadata{TrashItem: item, Icons: takeTreeIcons(icons, rel, item.Dir)}
if item.Assets != "" {
for p, icon := range takeTreeIcons(icons, AssetsDir(rel), true) {
metadata.Icons[p] = icon
}
}
dir := filepath.Join(v.trash, item.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return TrashItem{}, err
}
noteMoved, assetsMoved := false, false
defer func() {
if err == nil {
return
}
rollbackFailed := false
if assetsMoved {
if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_trashed_b37d4f", rollback))
rollbackFailed = true
}
}
if noteMoved {
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_trashed_3758a4", rollback))
rollbackFailed = true
}
}
// Retain directories and metadata on rollback failure instead of deleting the last recovery information.
if !rollbackFailed {
if cleanup := os.RemoveAll(dir); cleanup != nil {
err = errors.Join(err, cleanup)
}
}
}()
meta, _ := json.Marshal(metadata)
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, i18n.New("could_not_move_image_folder_7c4620", err)
}
}
assetsMoved = item.Assets != ""
if len(metadata.Icons) > 0 {
err = v.writeTreeIcons(icons)
}
return item, err
}
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
metadata, err := v.readTrashMetadata(id)
return metadata.TrashItem, err
}
func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
if !trashIDPattern.MatchString(id) {
return trashMetadata{}, ErrInvalidPath
}
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
if errors.Is(err, fs.ErrNotExist) {
return trashMetadata{}, ErrNotFound
}
if err != nil {
return trashMetadata{}, err
}
var metadata trashMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return trashMetadata{}, i18n.New("trash_item_s_has_damaged_ba382a", id, err)
}
if _, err := CleanPath(metadata.Path); err != nil || metadata.ID != id || metadata.DeletedAt <= 0 || (metadata.Assets != "" && metadata.Assets != filepath.Base(AssetsDir(metadata.Path))) {
return trashMetadata{}, i18n.New("trash_item_s_has_invalid_006492", id)
}
for p, icon := range metadata.Icons {
if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) {
return trashMetadata{}, i18n.New("trash_item_s_has_damaged_856237", id)
}
}
return metadata, nil
}
func (v *Vault) TrashList() ([]TrashItem, error) {
v.mu.Lock()
defer v.mu.Unlock()
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 // A directory not created by this application, or an incomplete deletion entry.
}
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) {
v.mu.Lock()
defer v.mu.Unlock()
metadata, err := v.readTrashMetadata(id)
item = metadata.TrashItem
if err != nil {
return TrashItem{}, err
}
dst, err := v.abs(item.Path)
if err != nil {
return TrashItem{}, err
}
icons, err := v.readTreeIcons()
if err != nil {
return TrashItem{}, err
}
for p, icon := range metadata.Icons {
icons[p] = icon
}
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 {
// Cleanup may have deleted metadata; recreate recovery information before undoing this move.
if rollback := os.MkdirAll(dir, 0o755); rollback != nil {
err = errors.Join(err, rollback)
return
}
meta, _ := json.Marshal(metadata)
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, i18n.New("could_not_roll_back_restored_952073", rollback))
}
}
if rollback := os.Rename(dst, filepath.Join(dir, filepath.Base(dst))); rollback != nil {
err = errors.Join(err, i18n.New("could_not_roll_back_restored_16b803", rollback))
}
}()
if item.Assets != "" {
if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil {
return item, i18n.New("could_not_restore_image_folder_f6e087", err)
}
assetsMoved = true
}
cleaning = true
if err = os.RemoveAll(dir); err != nil {
return item, err
}
if len(metadata.Icons) > 0 {
err = v.writeTreeIcons(icons)
}
return item, err
}
func (v *Vault) Purge(id string) error {
v.mu.Lock()
defer v.mu.Unlock()
metadata, err := v.readTrashMetadata(id)
if err != nil {
return err
}
return v.purgeTrashItemLocked(metadata)
}
// ---- Resources ----
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
// SaveAsset stores an upload in the note's sibling assets directory, named by content hash (identical contents are stored once),
// and returns a relative link ready for Markdown.
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
if !IsNote(note) {
return "", ErrInvalidPath
}
if _, err := CleanPath(note); err != nil {
return "", err
}
return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit)
}
// SaveFolderAsset uploads to the current folder's shared assets directory for image management, using hash deduplication.
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) {
return "", i18n.New("w_choose_an_image_file_544069", 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
}
target, exists, err := v.assetDestination(relativeDir, name, data)
if err != nil {
return "", err
}
if !exists {
if err := atomicWrite(filepath.Join(v.root, filepath.FromSlash(target)), data); err != nil {
return "", err
}
}
return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil
}
// Image management permits replacement, so filenames do not guarantee unchanged contents; reuse only regular files with matching contents.
func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) {
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
for i := 0; ; i++ {
candidate := name
if i > 0 {
candidate = fmt.Sprintf("%s-%d%s", base, i, ext)
}
rel := path.Join(dir, candidate)
full, err := v.abs(rel)
if err != nil {
return "", false, err
}
info, err := os.Lstat(full)
if errors.Is(err, fs.ErrNotExist) {
return rel, false, nil
}
if err != nil {
return "", false, err
}
if !info.Mode().IsRegular() {
return "", false, ErrInvalidPath
}
existing, err := os.ReadFile(full)
if err != nil {
return "", false, err
}
if bytes.Equal(existing, data) {
return rel, true, 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 escapes only characters that break Markdown link destinations, preserving Unicode such as Chinese to match Typora.
func EscapeLinkPath(s string) string {
return linkEscaper.Replace(s)
}
var linkEscaper = strings.NewReplacer("%", "%25", "&", "%26", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")