Files
mule-image/sidecar/fs.go
dtoro 75008f238a feat(review): fast keyboard-first Stacks & Duplicates resolve queue
Both tabs get a resolve-and-advance queue instead of independent
click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a
group removes it optimistically and auto-advances focus, and a sticky
header tracks reclaimable bytes + a running "resolved this session"
tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the
usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires
its own).

Sidecar (handlers_dups.go, fs.go, main.go):
- POST /duplicates/restore — inverse of /duplicates/archive, moves
  quarantined files back to their original path with the same BasePath
  guards and async reindex-with-cleanup.
- Scan results now include each file's mtime so the UI can label
  older/newer copies.

Stack losers now go through the same sidecar quarantine as
cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead
of a hard PhotoPrism DELETE, so both tabs share one recoverable,
undoable resolution path (services/duplicateActions.svelte.ts).

StackGroupCard: comparison-first — fact rows highlight the best
size/resolution per file, a "Suggested" badge appears when one file
wins outright, and Space opens a fullscreen CompareLightbox that flips
between candidates while preserving zoom/pan (extracted the zoom/pan
gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts
action so both consumers share one implementation).

CrossFolderGroupCard: since every copy is byte-identical, the old grid
of N identical thumbnails told the user nothing — replaced with one
thumbnail plus a path list that highlights the differing folder
segment and flags the indexed/newest copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 11:00:36 +02:00

248 lines
6.8 KiB
Go

package main
import (
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// sanitizeFilename trims a user-supplied filename and rejects anything
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
// with most filename shapes; we lock down the ones a malicious or
// careless caller might use to escape the folder.
func sanitizeFilename(name string) (string, bool) {
s := strings.TrimSpace(name)
if s == "" || len(s) > 240 {
return "", false
}
if strings.HasPrefix(s, ".") {
return "", false
}
if s == "." || s == ".." {
return "", false
}
if strings.ContainsAny(s, "/\\\x00") {
return "", false
}
return s, true
}
// resolveUnderRoot takes a user-supplied relative path and returns its
// absolute form, but only when the resolved location lives under the
// configured originals root. Symlink escapes are caught by resolving the
// parent through filepath.EvalSymlinks first.
//
// `mustExist=false` is for the *target* of a rename/create where the
// terminal path isn't on disk yet; the parent still has to exist and
// still has to be inside the root.
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
if rel == "" {
return "", errors.New("empty path")
}
clean := strings.TrimLeft(rel, "/")
if clean == "" || clean == "." {
return "", errors.New("empty path")
}
for _, seg := range strings.Split(clean, "/") {
if seg == "" || seg == ".." {
return "", errors.New("path traversal")
}
}
abs := filepath.Join(root, clean)
parent := filepath.Dir(abs)
parentReal, err := filepath.EvalSymlinks(parent)
if err != nil {
return "", err
}
if !sameOrUnder(parentReal, root) {
return "", errors.New("parent escapes originals root")
}
if mustExist {
if _, err := os.Stat(abs); err != nil {
return "", err
}
}
return abs, nil
}
// ensureWithinOriginals checks that an absolute path's parent resolves to
// somewhere inside the root after symlink evaluation. Used for the
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
func ensureWithinOriginals(root, absPath string) bool {
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
if err != nil {
return false
}
return sameOrUnder(real, root)
}
func sameOrUnder(p, root string) bool {
if p == root {
return true
}
return strings.HasPrefix(p, root+string(os.PathSeparator))
}
// uniqueName resolves "destDir/basename" against collisions by appending
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
// can't pin the goroutine forever.
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
ext := filepath.Ext(basename)
stem := strings.TrimSuffix(basename, ext)
for i := 0; i < 1000; i++ {
candidate := basename
if i > 0 {
candidate = stem + "-" + itoa(i) + ext
}
p := filepath.Join(destDir, candidate)
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
return p, candidate, true
}
}
return "", "", false
}
// uniqueStem finds a base name (extension stripped) that is free for *every*
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
// PhotoPrism stacking them as one photo after reindex; picking the stem once
// for the whole group is what stops the video from being orphaned under a
// differently-suffixed name than its poster. Caps at 1000 attempts to match
// uniqueName. The passed extensions keep their on-disk case (we compare
// case-sensitively via os.Stat, which is correct on the case-sensitive
// volumes PhotoPrism targets).
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
for i := 0; i < 1000; i++ {
candidate := base
if i > 0 {
candidate = base + "-" + itoa(i)
}
free := true
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
free = false
break
}
}
if free {
return candidate, true
}
}
return "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// fileEntry is the per-file row walkFiles emits. relPath stays root-
// relative so it can land in API responses unchanged.
type fileEntry struct {
RelPath string
AbsPath string
Size int64
ModTime time.Time
}
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
// itself walks the same set; we keep the list in lock-step so callers
// don't see "duplicate" warnings about files PhotoPrism would ignore.
var supportedExts = map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
".orf": {}, ".rw2": {}, ".raw": {},
}
// walkFiles enumerates every supported media file under root, skipping
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
// folder). Errors on individual entries are swallowed so a single
// permission-denied dir doesn't abort the whole scan.
func walkFiles(root string) ([]fileEntry, error) {
var out []fileEntry
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
// Permission errors etc. — skip the offending subtree but
// keep walking. The dup-scan endpoint is best-effort.
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
name := d.Name()
if p != root && strings.HasPrefix(name, ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if _, ok := supportedExts[ext]; !ok {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
out = append(out, fileEntry{
RelPath: rel,
AbsPath: p,
Size: info.Size(),
ModTime: info.ModTime(),
})
return nil
})
return out, err
}
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
// doesn't blow the process's RAM. Returns the hex digest.
func sha1File(absPath string) (string, error) {
f, err := os.Open(absPath)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}