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
+234
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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