fix(nas): refuse deletes while library storage is unreachable; real file removal; stat cache

Three changes from the network-drive audit:

1. Delete-ordering fix. Deleting a media item cascades its annotations,
   so the delete must never proceed while the item's library storage is
   unreachable — the DB row died, the file survived as an orphan, and
   the next scan re-imported it bare (annotations gone forever).
   resolveMediaStorage stats the library's folders: reachable → delete
   proceeds and the file is removed by ABSOLUTE path (the old code
   passed a library-relative path to os.Remove, which never resolved —
   file deletion has been a silent no-op); every folder unreachable →
   409 with 'storage unreachable', row and annotations intact.

2. Latency: resolveLibrary stats every library folder on every SSR
   render; folder reachability is now cached with a 30s TTL.

3. README: Network Storage section documenting the outage hysteresis
   (mark → archive → auto-restore), the 90-day retention purge
   (ARCHIVE_RETENTION_DAYS, 0 = never), refused deletes while storage
   is down, and watch mode not firing on network mounts.

Integration tests: storage-down bulk delete refuses with the row
intact; real-file delete removes the file from disk.
This commit is contained in:
John O'Keefe committed 2026-10-14 09:00:08 -04:00
1 parent f42720ac64
commit 17274bb868
4 files changed
+237 -14

No files matched your search

+31 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"os"
"sync"
"time"
"bookhoard/internal/database"
@@ -106,6 +107,34 @@ type LibraryResolution struct {
FirstID string
}
// folderStatEntry caches one folder-reachability result.
type folderStatEntry struct {
exists bool
at time.Time
}
const folderStatTTL = 30 * time.Second
var folderStatCache sync.Map
// folderReachableCached stats a library folder with a short TTL cache:
// resolveLibrary runs on every SSR render, and a stat per library folder
// over a network mount adds real latency to each page. The cache trades
// ~30s of stale online/offline state for snappy renders.
func folderReachableCached(path string) bool {
if v, ok := folderStatCache.Load(path); ok {
if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL {
return e.exists
}
}
exists := false
if _, err := os.Stat(path); err == nil {
exists = true
}
folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()})
return exists
}
func getText(t pgtype.Text) string {
if t.Valid {
return t.String
@@ -156,7 +185,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
} else {
anyFolderExists := false
for _, folder := range folders {
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
if folderReachableCached(folder.FolderPath) {
anyFolderExists = true
break
}
@@ -222,8 +251,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
// visibleLibraryIDsFor returns the requester's visible-library id set (for
// membership checks) plus the ids themselves (for SQL ANY() filters).
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) {
userUUIDStr, ok := c.Get("user_id").(string)
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) { userUUIDStr, ok := c.Get("user_id").(string)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}