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 }