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:
1 parent
f42720ac64
commit
17274bb868
4 files changed
+237
-14
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user