feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
Replace the Node prototype (server.mjs) with the stack the merge plan calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence. Same wire contract on /api/sidecar/* so the SvelteKit client doesn't change. - Marks move from a JSON file on disk to mule_sidecar.marks (auto- migrated by GORM on first boot). The Node prototype's marks.json was dev-only; not migrated. - Folder/rename/heap-convert/duplicates handlers reproduce the prototype's behaviour, including the path-traversal defence (resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for the duplicate hasher, and the background reindex fire-and-forget pattern. - Auth model unchanged: requireSession middleware proxies the caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1 before any destructive op. - Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the host Go process can reach mule_sidecar.* without joining the container network. - Archive the Node prototype under sidecar/legacy/server.mjs for one cycle as reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
213
sidecar/fs.go
Normal file
213
sidecar/fs.go
Normal file
@@ -0,0 +1,213 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user