diff --git a/README.md b/README.md index 6dc25f72..b3276f2c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/server/tests/ownership_and_admin_test.go b/cmd/server/tests/ownership_and_admin_test.go index 5693947f..ff64dc4a 100644 --- a/cmd/server/tests/ownership_and_admin_test.go +++ b/cmd/server/tests/ownership_and_admin_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "testing" "time" @@ -24,15 +26,19 @@ import ( // registration disabled. type OwnershipAndAdminTestSuite struct { suite.Suite - setup *TestServerSetup - token string - itemID string + 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)) } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 06bf44c2..84a4a1b6 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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) } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 9e463177..a4740a60 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -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") }