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>
248 lines
6.8 KiB
Go
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
|
|
}
|