Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The old movePhotoFiles moved only the primary (often the poster JPG), orphaning the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while the video stayed behind and broke. Move the whole originals group under one shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail the photo and report it if any sibling can't move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
6.8 KiB
Go
245 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
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
|
|
}
|