tagliatelle

Simple self-hosted tagging filesystem webapp
Log | Files | Refs

admin_duplicates.go (15528B)


      1 package app
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"encoding/json"
      7 	"errors"
      8 	"fmt"
      9 	"io"
     10 	"log"
     11 	"net/http"
     12 	"os"
     13 	"os/exec"
     14 	"path/filepath"
     15 	"regexp"
     16 	"strings"
     17 	"sync"
     18 )
     19 
     20 // Binary name
     21 var CzkawkaBin = "czkawka_cli"
     22 // dupEntryRe matches lines like: "/path/to/img.jpg" - 1280x1280 - 124.57 KiB - Original
     23 var dupEntryRe = regexp.MustCompile(`^"(.*)" - (\d+x\d+) - (.+?) - ([^-]+)$`)
     24 
     25 type DupItem struct {
     26 	Path       string `json:"path"`
     27 	Dims       string `json:"dims"`
     28 	Size       string `json:"size"`
     29 	Similarity string `json:"similarity"`
     30 }
     31 
     32 type dupState struct {
     33 	mu      sync.Mutex
     34 	dirs    []string
     35 	groups  [][]DupItem
     36 	allowed map[string]bool
     37 }
     38 var dups = &dupState{allowed: map[string]bool{}}
     39 
     40 // Only allow one czkawka scan at a time.
     41 var scanMu sync.Mutex
     42 
     43 // read report text output and parse into groups
     44 func parseDuplicateResults(file string) ([][]DupItem, error) {
     45 	data, err := os.ReadFile(file)
     46 	if err != nil {
     47 		return nil, err
     48 	}
     49 
     50 	var groups [][]DupItem
     51 	var cur []DupItem
     52 	flush := func() {
     53 		if len(cur) >= 2 {
     54 			groups = append(groups, cur)
     55 		}
     56 		cur = nil
     57 	}
     58 
     59 	for _, line := range strings.Split(string(data), "\n") {
     60 		line = strings.TrimRight(line, "\r")
     61 		if m := dupEntryRe.FindStringSubmatch(line); m != nil {
     62 			cur = append(cur, DupItem{
     63 				Path:       m[1],
     64 				Dims:       m[2],
     65 				Size:       m[3],
     66 				Similarity: strings.TrimSpace(m[4]),
     67 			})
     68 			continue
     69 		}
     70 		// Anything else (blank line, "Found N images...", headers) ends a group.
     71 		flush()
     72 	}
     73 	flush()
     74 	return groups, nil
     75 }
     76 
     77 func (s *dupState) set(dirs []string, groups [][]DupItem) {
     78 	s.mu.Lock()
     79 	defer s.mu.Unlock()
     80 	s.dirs = dirs
     81 	s.groups = groups
     82 	s.allowed = map[string]bool{}
     83 	for _, g := range groups {
     84 		for _, it := range g {
     85 			s.allowed[it.Path] = true
     86 		}
     87 	}
     88 }
     89 
     90 // liveGroups drops groups missing files and with less than 2 files
     91 func (s *dupState) liveGroups() [][]DupItem {
     92 	out := make([][]DupItem, 0, len(s.groups))
     93 	for _, g := range s.groups {
     94 		var live []DupItem
     95 		for _, it := range g {
     96 			if _, err := os.Stat(it.Path); err == nil {
     97 				live = append(live, it)
     98 			}
     99 		}
    100 		if len(live) >= 2 {
    101 			out = append(out, live)
    102 		}
    103 	}
    104 	return out
    105 }
    106 
    107 // sameGroup reports whether a and b appear together in one results group.
    108 func (s *dupState) sameGroup(a, b string) bool {
    109 	for _, g := range s.groups {
    110 		var hasA, hasB bool
    111 		for _, it := range g {
    112 			hasA = hasA || it.Path == a
    113 			hasB = hasB || it.Path == b
    114 		}
    115 		if hasA && hasB {
    116 			return true
    117 		}
    118 	}
    119 	return false
    120 }
    121 
    122 // remainingInGroup counts the existing files in the group containing path.
    123 func (s *dupState) remainingInGroup(path string) int {
    124 	for _, g := range s.groups {
    125 		found := false
    126 		for _, it := range g {
    127 			if it.Path == path {
    128 				found = true
    129 				break
    130 			}
    131 		}
    132 		if !found {
    133 			continue
    134 		}
    135 		n := 0
    136 		for _, it := range g {
    137 			if _, err := os.Stat(it.Path); err == nil {
    138 				n++
    139 			}
    140 		}
    141 		return n
    142 	}
    143 	return 0
    144 }
    145 
    146 // dupResponse is what the scan and groups endpoints return.
    147 type dupResponse struct {
    148 	Dirs   []string    `json:"dirs"`
    149 	Groups [][]DupItem `json:"groups"`
    150 }
    151 
    152 func writeJSON(w http.ResponseWriter, v any) {
    153 	w.Header().Set("Content-Type", "application/json")
    154 	if err := json.NewEncoder(w).Encode(v); err != nil {
    155 		log.Printf("Warning: writeJSON: %v", err)
    156 	}
    157 }
    158 
    159 // check JSON POST for CORS so other sites can't request actions
    160 func decodeJSONPost(w http.ResponseWriter, r *http.Request, v any) bool {
    161 	if r.Method != http.MethodPost {
    162 		http.Error(w, "POST only", http.StatusMethodNotAllowed)
    163 		return false
    164 	}
    165 	if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
    166 		http.Error(w, "expected application/json", http.StatusUnsupportedMediaType)
    167 		return false
    168 	}
    169 	if err := json.NewDecoder(r.Body).Decode(v); err != nil {
    170 		http.Error(w, "bad request", http.StatusBadRequest)
    171 		return false
    172 	}
    173 	return true
    174 }
    175 
    176 // cleans submitted directories, checks they exist and  drops blanks and duplicates
    177 func resolveScanDirs(inputs []string) ([]string, error) {
    178 	var dirs []string
    179 	seen := map[string]bool{}
    180 	for _, in := range inputs {
    181 		in = strings.TrimSpace(in)
    182 		if in == "" {
    183 			continue
    184 		}
    185 		abs, err := filepath.Abs(in)
    186 		if err != nil {
    187 			return nil, fmt.Errorf("invalid path %q: %w", in, err)
    188 		}
    189 		info, err := os.Stat(abs)
    190 		if err != nil {
    191 			return nil, fmt.Errorf("cannot read %s: %w", abs, err)
    192 		}
    193 		if !info.IsDir() {
    194 			return nil, fmt.Errorf("%s is not a directory", abs)
    195 		}
    196 		if !seen[abs] {
    197 			seen[abs] = true
    198 			dirs = append(dirs, abs)
    199 		}
    200 	}
    201 	if len(dirs) == 0 {
    202 		return nil, errors.New("enter at least one directory to scan")
    203 	}
    204 	return dirs, nil
    205 }
    206 
    207 // pathWithin reports whether p is dir itself or located beneath it.
    208 func pathWithin(p, dir string) bool {
    209 	rel, err := filepath.Rel(dir, p)
    210 	if err != nil {
    211 		return false
    212 	}
    213 	return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
    214 }
    215 
    216 // run binary and write report
    217 func runCzkawka(ctx context.Context, resultsFile string, dirs []string) error {
    218 	args := []string{"image"}
    219 	for _, d := range dirs {
    220 		args = append(args, "-d", d)
    221 	}
    222 	// Video thumbnails live in the uploads directory; they are derived files, not duplicates.
    223 	if uploadAbs, err := filepath.Abs(Cfg.UploadDir); err == nil {
    224 		thumbDir := filepath.Join(uploadAbs, "thumbnails")
    225 		for _, d := range dirs {
    226 			if pathWithin(thumbDir, d) {
    227 				if info, err := os.Stat(thumbDir); err == nil && info.IsDir() {
    228 					args = append(args, "-e", thumbDir)
    229 				}
    230 				break
    231 			}
    232 		}
    233 	}
    234 	args = append(args, "-f", resultsFile)
    235 
    236 	log.Printf("Info: runCzkawka: %s %s", CzkawkaBin, strings.Join(args, " "))
    237 	var out bytes.Buffer
    238 	cmd := exec.CommandContext(ctx, CzkawkaBin, args...)
    239 	cmd.Stdout = &out
    240 	cmd.Stderr = &out
    241 	err := cmd.Run()
    242 
    243 	// czkawka_cli exits with 11 when it found results; that's not a failure.
    244 	var ee *exec.ExitError
    245 	if errors.As(err, &ee) && ee.ExitCode() == 11 {
    246 		return nil
    247 	}
    248 	if err != nil {
    249 		if errors.Is(err, exec.ErrNotFound) {
    250 			return fmt.Errorf("%s was not found; install czkawka_cli or pass its location with the -czkawka flag", CzkawkaBin)
    251 		}
    252 		tail := strings.TrimSpace(out.String())
    253 		if len(tail) > 600 {
    254 			tail = "..." + tail[len(tail)-600:]
    255 		}
    256 		if tail != "" {
    257 			return fmt.Errorf("czkawka failed: %w: %s", err, tail)
    258 		}
    259 		return fmt.Errorf("czkawka failed: %w", err)
    260 	}
    261 	return nil
    262 }
    263 
    264 func duplicatesScanHandler(w http.ResponseWriter, r *http.Request) {
    265 	var req struct {
    266 		Dirs []string `json:"dirs"`
    267 	}
    268 	if !decodeJSONPost(w, r, &req) {
    269 		return
    270 	}
    271 
    272 	dirs, err := resolveScanDirs(req.Dirs)
    273 	if err != nil {
    274 		http.Error(w, err.Error(), http.StatusBadRequest)
    275 		return
    276 	}
    277 
    278 	if !scanMu.TryLock() {
    279 		http.Error(w, "a scan is already running", http.StatusConflict)
    280 		return
    281 	}
    282 	defer scanMu.Unlock()
    283 
    284 	tmpDir, err := os.MkdirTemp("", "tagliatelle-duplicates-")
    285 	if err != nil {
    286 		log.Printf("Error: duplicatesScanHandler: failed to create temp dir: %v", err)
    287 		http.Error(w, "failed to create temporary directory", http.StatusInternalServerError)
    288 		return
    289 	}
    290 	defer os.RemoveAll(tmpDir)
    291 	resultsFile := filepath.Join(tmpDir, "duplicates.log")
    292 
    293 	if err := runCzkawka(r.Context(), resultsFile, dirs); err != nil {
    294 		log.Printf("Error: duplicatesScanHandler: %v", err)
    295 		http.Error(w, err.Error(), http.StatusInternalServerError)
    296 		return
    297 	}
    298 
    299 	groups, err := parseDuplicateResults(resultsFile)
    300 	if errors.Is(err, os.ErrNotExist) {
    301 		groups, err = nil, nil // czkawka writes no file when nothing was found
    302 	}
    303 	if err != nil {
    304 		log.Printf("Error: duplicatesScanHandler: failed to read results: %v", err)
    305 		http.Error(w, "failed to read czkawka results", http.StatusInternalServerError)
    306 		return
    307 	}
    308 	log.Printf("Info: duplicatesScanHandler: found %d groups in %s", len(groups), strings.Join(dirs, ", "))
    309 
    310 	dups.set(dirs, groups)
    311 	dups.mu.Lock()
    312 	defer dups.mu.Unlock()
    313 	writeJSON(w, dupResponse{Dirs: dirs, Groups: dups.liveGroups()})
    314 }
    315 
    316 func duplicatesGroupsHandler(w http.ResponseWriter, r *http.Request) {
    317 	dups.mu.Lock()
    318 	defer dups.mu.Unlock()
    319 	dirs := dups.dirs
    320 	if dirs == nil {
    321 		dirs = []string{}
    322 	}
    323 	writeJSON(w, dupResponse{Dirs: dirs, Groups: dups.liveGroups()})
    324 }
    325 
    326 func duplicatesImageHandler(w http.ResponseWriter, r *http.Request) {
    327 	p := r.URL.Query().Get("path")
    328 	dups.mu.Lock()
    329 	ok := dups.allowed[p]
    330 	dups.mu.Unlock()
    331 	if !ok {
    332 		http.Error(w, "not found", http.StatusNotFound)
    333 		return
    334 	}
    335 	w.Header().Set("Cache-Control", "private, max-age=300")
    336 	http.ServeFile(w, r, p)
    337 }
    338 
    339 func duplicatesDeleteHandler(w http.ResponseWriter, r *http.Request) {
    340 	var req struct {
    341 		Path string `json:"path"`
    342 	}
    343 	if !decodeJSONPost(w, r, &req) {
    344 		return
    345 	}
    346 
    347 	dups.mu.Lock()
    348 	defer dups.mu.Unlock()
    349 	if !dups.allowed[req.Path] {
    350 		http.Error(w, "path not in results", http.StatusForbidden)
    351 		return
    352 	}
    353 	if dups.remainingInGroup(req.Path) < 2 {
    354 		http.Error(w, "refusing to delete the last remaining file in a group", http.StatusConflict)
    355 		return
    356 	}
    357 	if err := os.Remove(req.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
    358 		log.Printf("Error: duplicatesDeleteHandler: failed to delete %s: %v", req.Path, err)
    359 		http.Error(w, err.Error(), http.StatusInternalServerError)
    360 		return
    361 	}
    362 	forgetUploadRecord(req.Path)
    363 	log.Printf("Info: duplicatesDeleteHandler: deleted %s", req.Path)
    364 	w.WriteHeader(http.StatusNoContent)
    365 }
    366 
    367 // duplicatesDeleteGroupHandler deletes every listed file of one results group,
    368 func duplicatesDeleteGroupHandler(w http.ResponseWriter, r *http.Request) {
    369 	var req struct {
    370 		Paths []string `json:"paths"`
    371 	}
    372 	if !decodeJSONPost(w, r, &req) {
    373 		return
    374 	}
    375 	if len(req.Paths) == 0 {
    376 		http.Error(w, "no paths given", http.StatusBadRequest)
    377 		return
    378 	}
    379 
    380 	dups.mu.Lock()
    381 	defer dups.mu.Unlock()
    382 	for _, p := range req.Paths {
    383 		if !dups.allowed[p] || !dups.sameGroup(req.Paths[0], p) {
    384 			http.Error(w, "paths are not in one group of the results", http.StatusForbidden)
    385 			return
    386 		}
    387 	}
    388 	var failed []string
    389 	for _, p := range req.Paths {
    390 		if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
    391 			log.Printf("Error: duplicatesDeleteGroupHandler: failed to delete %s: %v", p, err)
    392 			failed = append(failed, p)
    393 			continue
    394 		}
    395 		forgetUploadRecord(p)
    396 		log.Printf("Info: duplicatesDeleteGroupHandler: deleted %s", p)
    397 	}
    398 	if len(failed) > 0 {
    399 		http.Error(w, "could not delete: "+strings.Join(failed, ", "), http.StatusInternalServerError)
    400 		return
    401 	}
    402 	w.WriteHeader(http.StatusNoContent)
    403 }
    404 
    405 func duplicatesReplaceHandler(w http.ResponseWriter, r *http.Request) {
    406 	var req struct {
    407 		Src string `json:"src"` // the better file; it is moved
    408 		Dst string `json:"dst"` // the file being replaced
    409 	}
    410 	if !decodeJSONPost(w, r, &req) {
    411 		return
    412 	}
    413 
    414 	dups.mu.Lock()
    415 	defer dups.mu.Unlock()
    416 	if req.Src == req.Dst {
    417 		http.Error(w, "src and dst are the same file", http.StatusBadRequest)
    418 		return
    419 	}
    420 	if !dups.allowed[req.Src] || !dups.allowed[req.Dst] || !dups.sameGroup(req.Src, req.Dst) {
    421 		http.Error(w, "paths are not a matching pair in the results", http.StatusForbidden)
    422 		return
    423 	}
    424 
    425 	dest, err := replaceFile(req.Src, req.Dst)
    426 	if err != nil {
    427 		log.Printf("Error: duplicatesReplaceHandler: %s -> %s: %v", req.Src, req.Dst, err)
    428 		http.Error(w, err.Error(), http.StatusInternalServerError)
    429 		return
    430 	}
    431 	syncUploadRecordsAfterReplace(req.Src, req.Dst, dest)
    432 	log.Printf("Info: duplicatesReplaceHandler: replaced %s with %s (now at %s)", req.Dst, req.Src, dest)
    433 	writeJSON(w, map[string]string{"path": dest})
    434 }
    435 
    436 // moves src into dst's location
    437 // On extension mismatch, keep src's extension and remove dst's
    438 func replaceFile(src, dst string) (string, error) {
    439 	dstInfo, err := os.Stat(dst)
    440 	if err != nil {
    441 		return "", err
    442 	}
    443 	if _, err := os.Stat(src); err != nil {
    444 		return "", err
    445 	}
    446 
    447 	dest := dst
    448 	if sx, dx := filepath.Ext(src), filepath.Ext(dst); !strings.EqualFold(sx, dx) {
    449 		dest = strings.TrimSuffix(dst, dx) + sx
    450 		if _, err := os.Lstat(dest); err == nil {
    451 			return "", fmt.Errorf("%s already exists", dest)
    452 		}
    453 	}
    454 
    455 	if err := moveFile(src, dest); err != nil {
    456 		return "", err
    457 	}
    458 	// try to keep existing permissions
    459 	_ = os.Chmod(dest, dstInfo.Mode().Perm())
    460 
    461 	if dest != dst {
    462 		if err := os.Remove(dst); err != nil {
    463 			return dest, fmt.Errorf("moved to %s, but could not remove old file: %w", dest, err)
    464 		}
    465 	}
    466 	return dest, nil
    467 }
    468 
    469 // rename src to dest. If unable, copy to temp and rename in place then remove src
    470 func moveFile(src, dest string) error {
    471 	if err := os.Rename(src, dest); err == nil {
    472 		return nil
    473 	}
    474 
    475 	in, err := os.Open(src)
    476 	if err != nil {
    477 		return err
    478 	}
    479 	defer in.Close()
    480 
    481 	tmp, err := os.CreateTemp(filepath.Dir(dest), ".replace-*")
    482 	if err != nil {
    483 		return err
    484 	}
    485 	defer os.Remove(tmp.Name()) // no-op once renamed
    486 
    487 	if _, err := io.Copy(tmp, in); err != nil {
    488 		tmp.Close()
    489 		return err
    490 	}
    491 	if err := tmp.Sync(); err != nil {
    492 		tmp.Close()
    493 		return err
    494 	}
    495 	if err := tmp.Close(); err != nil {
    496 		return err
    497 	}
    498 	if err := os.Rename(tmp.Name(), dest); err != nil {
    499 		return err
    500 	}
    501 	if err := os.Remove(src); err != nil {
    502 		return fmt.Errorf("copied to %s, but could not remove source: %w", dest, err)
    503 	}
    504 	return nil
    505 }
    506 
    507 // try to keep the tagliatelle database in sync
    508 func uploadRecord(absPath string) (id int64, filename, relPath string, ok bool) {
    509 	uploadAbs, err := filepath.Abs(Cfg.UploadDir)
    510 	if err != nil || !pathWithin(absPath, uploadAbs) {
    511 		return 0, "", "", false
    512 	}
    513 	rel, err := filepath.Rel(uploadAbs, absPath)
    514 	if err != nil {
    515 		return 0, "", "", false
    516 	}
    517 	if err := DB.QueryRow("SELECT id, filename FROM files WHERE path = ?", rel).Scan(&id, &filename); err != nil {
    518 		return 0, "", "", false
    519 	}
    520 	return id, filename, rel, true
    521 }
    522 
    523 func removeThumbnail(filename string) {
    524 	thumb := filepath.Join(Cfg.UploadDir, "thumbnails", filename+".jpg")
    525 	if err := os.Remove(thumb); err != nil && !errors.Is(err, os.ErrNotExist) {
    526 		log.Printf("Warning: removeThumbnail: %s: %v", thumb, err)
    527 	}
    528 }
    529 
    530 // remove DB records for removed files in upload dir
    531 func forgetUploadRecord(absPath string) {
    532 	id, filename, _, ok := uploadRecord(absPath)
    533 	if !ok {
    534 		return
    535 	}
    536 	tx, err := DB.Begin()
    537 	if err != nil {
    538 		log.Printf("Warning: forgetUploadRecord: failed to start transaction for %s: %v", absPath, err)
    539 		return
    540 	}
    541 	defer tx.Rollback()
    542 	for _, q := range []string{
    543 		"DELETE FROM file_tags WHERE file_id = ?",
    544 		"DELETE FROM file_properties WHERE file_id = ?",
    545 		"DELETE FROM files WHERE id = ?",
    546 	} {
    547 		if _, err := tx.Exec(q, id); err != nil {
    548 			log.Printf("Warning: forgetUploadRecord: %s (id=%d): %v", q, id, err)
    549 			return
    550 		}
    551 	}
    552 	if err := tx.Commit(); err != nil {
    553 		log.Printf("Warning: forgetUploadRecord: commit failed for id=%d: %v", id, err)
    554 		return
    555 	}
    556 	removeThumbnail(filename)
    557 }
    558 
    559 func syncUploadRecordsAfterReplace(src, dst, dest string) {
    560 	forgetUploadRecord(src)
    561 
    562 	id, filename, _, ok := uploadRecord(dst)
    563 	if !ok {
    564 		return
    565 	}
    566 
    567 	if dest != dst {
    568 		uploadAbs, err := filepath.Abs(Cfg.UploadDir)
    569 		if err == nil {
    570 			if newRel, err := filepath.Rel(uploadAbs, dest); err == nil {
    571 				newName := filepath.Base(dest)
    572 				removeThumbnail(filename)
    573 				if _, err := DB.Exec("UPDATE files SET filename = ?, path = ? WHERE id = ?", newName, newRel, id); err != nil {
    574 					log.Printf("Warning: syncUploadRecordsAfterReplace: failed to update record id=%d: %v", id, err)
    575 				}
    576 			}
    577 		}
    578 	} else {
    579 		removeThumbnail(filename) // its content changed, so the old thumbnail is stale
    580 	}
    581 
    582 	if _, err := DB.Exec("DELETE FROM file_properties WHERE file_id = ?", id); err != nil {
    583 		log.Printf("Warning: syncUploadRecordsAfterReplace: failed to clear properties for id=%d: %v", id, err)
    584 	}
    585 	computeProperties(id, dest)
    586 }