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 }