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
+234 -11

No files matched your search

+26
View File
@@ -127,6 +127,32 @@ The first user to register automatically becomes an admin.
---
## 💾 Network Storage (NAS / NFS / SMB)
Libraries on network mounts are supported for everyday use — serving,
reading, progress, and annotation sync all work over the mount — with a
few behaviors worth knowing:
- **Outage hysteresis.** When the mount drops, files miss one scan
(marked missing, hidden) then a second (archived, still recoverable).
When the mount returns — by path or by identical content elsewhere —
everything is restored automatically: annotations, progress, and
covers included.
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
(default **90**, `0` = never auto-purge) are deleted for good by
library scans, annotations included. A NAS offline longer than the
retention window loses those books' metadata on return.
- **Deleting while the storage is down is refused.** Deleting a book
cascades its annotations, so deletes are rejected with a clear error
while the item's library storage is unreachable. Archive the item
instead, or retry when the mount is back.
- **Watch mode does not fire on network mounts** (inotify doesn't work
over NFS/SMB). The periodic scan is the change detector.
- **Latency** — folder reachability checks are cached ~30s; large
libraries on slow mounts will still feel slower than local disk.
---
## 🛠 Tech Stack
- **Backend**: Go 1.25+ with Echo framework
+88 -5
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
@@ -27,12 +29,16 @@ type OwnershipAndAdminTestSuite struct {
setup *TestServerSetup
token string
itemID string
libraryID string
absFile string
}
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
s.itemID = s.createItemViaDB()
var libID string
s.itemID, libID, s.absFile = s.createItemViaDB(true)
s.libraryID = libID
// createTestMediaItemID's t.Cleanup path can't be used at suite level
// (it fires after TearDownSuite has closed the server), so the item is
// created directly through the DB handle; suite cleanup relies on the
@@ -42,7 +48,10 @@ func (s *OwnershipAndAdminTestSuite) SetupSuite() {
// createItemViaDB makes a throwaway library + media item directly through
// the test DB handle — no HTTP, no t.Cleanup entanglement.
func (s *OwnershipAndAdminTestSuite) createItemViaDB() string {
// With withFolder=true the library gets a REAL temp folder row and the
// file is created on disk inside it, so deletes exercise real file
// removal; the folder path is returned alongside the item id.
func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) {
ctx := s.setup.QueueCtx
db := s.setup.DB
@@ -53,23 +62,39 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB() string {
require.NoError(s.T(), err, "dev admin user should exist")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Library %d", time.Now().UnixNano()),
Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "annotation ownership suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano())
absFile = ""
if withFolder {
dir, dirErr := os.MkdirTemp("", "ownership-suite-*")
require.NoError(s.T(), dirErr)
_, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: dir,
})
require.NoError(s.T(), ferr)
absFile = filepath.Join(dir, relPath)
if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil {
require.NoError(s.T(), werr)
}
}
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
FilePath: fmt.Sprintf("/tmp/ownership-suite-%d.epub", time.Now().UnixNano()),
FilePath: relPath,
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
return uuid.UUID(item.ID.Bytes).String()
return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile
}
func (s *OwnershipAndAdminTestSuite) TearDownSuite() {
@@ -227,6 +252,64 @@ func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
}
// TestDelete_StorageDownRefused pins the NAS guard: deleting a media item
// while its library storage is unreachable is REFUSED (row + annotations
// survive) instead of cascading annotations for a file that will re-import
// bare on the next scan.
func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
ctx := s.setup.QueueCtx
db := s.setup.DB
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(s.T(), err)
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(s.T(), err)
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "storage-down suite", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: admin.ID,
})
require.NoError(s.T(), err)
// Folder row pointing at a mount that does not exist.
_, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: library.ID,
FolderPath: "/nonexistent-mount-xyz",
})
require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Offline Storage Item",
FilePath: "offline.epub",
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(s.T(), err)
itemID := uuid.UUID(item.ID.Bytes).String()
payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.token)
resp, err := (&http.Client{}).Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors")
var body map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
results := body["results"].([]interface{})
require.Len(s.T(), results, 1)
first := results[0].(map[string]interface{})
assert.Contains(s.T(), first["error"], "storage unreachable")
// The row survived the refused delete.
check, err := db.GetMediaItem(ctx, item.ID)
require.NoError(s.T(), err)
assert.Equal(s.T(), "Offline Storage Item", check.Title)
}
func TestOwnershipAndAdminTestSuite(t *testing.T) {
suite.Run(t, new(OwnershipAndAdminTestSuite))
}
+89 -3
View File
@@ -439,6 +439,20 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
continue
}
// Storage-down guard: deleting cascades the item's annotations, so
// refuse while the library storage is unreachable — the file would
// survive as an orphan and re-import bare on the next scan.
storage := h.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
if !storage.Reachable {
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "error",
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
})
failedCount++
continue
}
err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID)
if err != nil {
results = append(results, map[string]interface{}{
@@ -450,11 +464,11 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
continue
}
if media.FilePath != "" {
if err := os.Remove(media.FilePath); err != nil {
if storage.AbsolutePath != "" {
if err := os.Remove(storage.AbsolutePath); err != nil {
// Log file removal failure but don't fail the request
// DB record is already deleted, which is the primary concern
log.Printf("Warning: failed to remove file %s: %v", media.FilePath, err)
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
}
}
@@ -649,6 +663,54 @@ func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []p
return set, ids, nil
}
// mediaStorageState reports whether the library storage holding a media
// item's file is currently reachable, and resolves the file's absolute
// path when it can be located.
//
// Why this exists: deleting a media item cascades its annotations, so the
// delete must never proceed while the storage is unreachable — otherwise
// a NAS outage turns an admin cleanup into permanent annotation loss
// (the DB row dies, the orphaned file re-imports bare on the next scan).
type mediaStorageState struct {
// Absolute path of the file when located on reachable storage;
// empty when the file is missing or reachability is undeterminable.
AbsolutePath string
// Reachable is false ONLY when the library has folders and every one
// of them fails to stat — positive evidence the mount is down. When
// no folders are configured reachability is undeterminable and this
// is true (delete proceeds; behavior unchanged from before this check).
Reachable bool
}
func (mh *MediaHandler) resolveMediaStorage(ctx context.Context, libraryID string, relativePath string) mediaStorageState {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return mediaStorageState{Reachable: true}
}
folders, err := mh.libraryService.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil || len(folders) == 0 {
return mediaStorageState{Reachable: true}
}
anyFolderUp := false
for _, f := range folders {
if _, err := os.Stat(f.FolderPath); err != nil {
continue // folder root unreachable
}
anyFolderUp = true
candidate := filepath.Join(f.FolderPath, filepath.FromSlash(relativePath))
if _, err := os.Stat(candidate); err == nil {
return mediaStorageState{AbsolutePath: candidate, Reachable: true}
}
}
if !anyFolderUp {
return mediaStorageState{Reachable: false}
}
// Storage is up but the file is genuinely gone: a stale row is safe
// to delete.
return mediaStorageState{Reachable: true}
}
// ListMediaItems handles GET /api/media-items
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
libraryID := c.QueryParam("library_id")
@@ -1599,11 +1661,35 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
media, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Storage-down guard (see HandleBulkDelete): refuse while the library
// storage is unreachable so the cascade can't destroy annotations for
// a file that will re-import on the next scan.
storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
if !storage.Reachable {
return c.JSON(http.StatusConflict, map[string]string{
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
})
}
err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if storage.AbsolutePath != "" {
if err := os.Remove(storage.AbsolutePath); err != nil {
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
}
}
return c.NoContent(http.StatusNoContent)
}
+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")
}