diff --git a/internal/api/handlers_disk.go b/internal/api/handlers_disk.go index c9e1e57..14e0e6b 100644 --- a/internal/api/handlers_disk.go +++ b/internal/api/handlers_disk.go @@ -65,6 +65,10 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) { jsonErr(w, http.StatusConflict, "disk already initialized") return } + if err := disk.CheckWritable(info.MountPath); err != nil { + jsonErr(w, http.StatusUnprocessableEntity, "disk is not writable: "+err.Error()) + return + } diskID, err := disk.InitDisk(info.MountPath) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index a97c6c2..1848630 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,12 +5,17 @@ import ( "errors" "os" "path/filepath" + "strings" + + "jukebox_maker/internal/disk" ) type OverwriteMode string type FileSelectMode string const ( + DefaultDestFolder = "media" + OverwriteSkip OverwriteMode = "skip" OverwriteDelete OverwriteMode = "delete" @@ -30,12 +35,14 @@ type Config struct { OverwriteMode OverwriteMode `json:"overwrite_mode"` FileSelectMode FileSelectMode `json:"file_select_mode"` AutoCopy bool `json:"auto_copy"` + FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"` + DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"` } func defaults() Config { return Config{ ReserveFreeGB: 2.0, - DestFolder: "media", + DestFolder: DefaultDestFolder, OverwriteMode: OverwriteSkip, FileSelectMode: SelectNew, AutoCopy: false, @@ -55,6 +62,11 @@ func Load(path string) (*Config, error) { if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } + if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil { + cfg.DestFolder = destFolder + } else { + cfg.DestFolder = defaults().DestFolder + } return &cfg, nil } @@ -77,6 +89,9 @@ func (c *Config) Validate() error { if c.ReserveFreeGB < 0 { return errors.New("reserve_free_gb must be >= 0") } + if _, err := NormalizeDestFolder(c.DestFolder); err != nil { + return err + } switch c.OverwriteMode { case OverwriteSkip, OverwriteDelete: default: @@ -89,3 +104,26 @@ func (c *Config) Validate() error { } return nil } + +func NormalizeDestFolder(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return DefaultDestFolder, nil + } + + clean := filepath.ToSlash(filepath.Clean(value)) + clean = strings.TrimPrefix(clean, "./") + clean = strings.TrimPrefix(clean, "/") + + switch clean { + case "", ".", "..": + return "", errors.New("dest_folder must be a subfolder on disk, not the disk root") + } + if strings.HasPrefix(clean, "../") { + return "", errors.New("dest_folder must stay inside the disk") + } + if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") { + return "", errors.New("dest_folder conflicts with internal disk metadata") + } + return clean, nil +} diff --git a/internal/disk/disk.go b/internal/disk/disk.go index 6ca17d8..44d7f22 100644 --- a/internal/disk/disk.go +++ b/internal/disk/disk.go @@ -26,7 +26,7 @@ type DiskInfo struct { MountPath string `json:"mount_path"` } -const markerDir = ".jukebox" +const MarkerDir = ".jukebox" const idFile = "disk.id" func Probe(mountPath string) (DiskInfo, error) { @@ -43,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) { info.TotalBytes = total info.FreeBytes = free - idPath := filepath.Join(mountPath, markerDir, idFile) + idPath := filepath.Join(mountPath, MarkerDir, idFile) data, err := os.ReadFile(idPath) if errors.Is(err, os.ErrNotExist) { info.State = DiskForeign @@ -59,8 +59,45 @@ func Probe(mountPath string) (DiskInfo, error) { return info, nil } +func IsMountPoint(path string) bool { + pathInfo, err := os.Stat(path) + if err != nil { + return false + } + + parent := filepath.Dir(filepath.Clean(path)) + parentInfo, err := os.Stat(parent) + if err != nil { + return false + } + + pathStat, ok := pathInfo.Sys().(*syscall.Stat_t) + if !ok { + return false + } + parentStat, ok := parentInfo.Sys().(*syscall.Stat_t) + if !ok { + return false + } + + return pathStat.Dev != parentStat.Dev +} + +func CheckWritable(path string) error { + f, err := os.CreateTemp(path, ".jukebox-writecheck-*") + if err != nil { + return err + } + name := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(name) + return err + } + return os.Remove(name) +} + func InitDisk(mountPath string) (string, error) { - dir := filepath.Join(mountPath, markerDir) + dir := filepath.Join(mountPath, MarkerDir) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } @@ -73,7 +110,7 @@ func InitDisk(mountPath string) (string, error) { } func DBPath(mountPath string) string { - return filepath.Join(mountPath, markerDir, "history.db") + return filepath.Join(mountPath, MarkerDir, "history.db") } func DiskUsage(mountPath string) (total, free int64, err error) { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index d690164..fa93308 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -135,6 +135,9 @@ func discoverDisks(root string) map[string]disk.DiskInfo { continue } mountPath := filepath.Join(root, entry.Name()) + if !disk.IsMountPoint(mountPath) { + continue + } info, _ := disk.Probe(mountPath) if info.State == disk.DiskAbsent { continue @@ -144,8 +147,10 @@ func discoverDisks(root string) map[string]disk.DiskInfo { // If no child mountpoints were detected, the disk may be mounted directly at root. if len(disks) == 0 { - if info, _ := disk.Probe(root); info.State != disk.DiskAbsent { - disks[root] = info + if disk.IsMountPoint(root) { + if info, _ := disk.Probe(root); info.State != disk.DiskAbsent { + disks[root] = info + } } } return disks diff --git a/web/templates/settings.html b/web/templates/settings.html index 4da5dc4..02541ea 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -38,7 +38,7 @@
.jukebox are never allowed here.