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.
277 lines
7.5 KiB
Go
277 lines
7.5 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/templates"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, error) {
|
|
userID := c.Get("user_id").(string)
|
|
userEmail := c.Get("user_email").(string)
|
|
userUsername := c.Get("user_username").(string)
|
|
userRole := c.Get("user_role").(string)
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
log.Printf("getTemplateUserWithTheme failed: invalid UUID '%s': %v", userID, err)
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("getTemplateUserWithTheme failed: database query error for user ID %s: %v", userID, err)
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userTheme := "tokyo-night"
|
|
if userDB.Theme.Valid {
|
|
userTheme = userDB.Theme.String
|
|
}
|
|
|
|
userTimezone := "UTC"
|
|
if userDB.Timezone.Valid {
|
|
userTimezone = userDB.Timezone.String
|
|
}
|
|
|
|
// Extract JWT token for WebSocket authentication
|
|
token := ""
|
|
if cookie, err := c.Cookie("token"); err == nil {
|
|
token = cookie.Value
|
|
}
|
|
|
|
return templates.User{
|
|
ID: userID,
|
|
Email: userEmail,
|
|
Username: userUsername,
|
|
Role: userRole,
|
|
Theme: userTheme,
|
|
Token: token,
|
|
Timezone: userTimezone,
|
|
FirstName: getText(userDB.FirstName),
|
|
LastName: getText(userDB.LastName),
|
|
CreatedAt: userDB.CreatedAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData {
|
|
result := make([]templates.PendingRegistrationData, len(pending))
|
|
for i, p := range pending {
|
|
result[i] = templates.PendingRegistrationData{
|
|
RegistrationID: p["registration_id"].(string),
|
|
DeviceName: p["device_name"].(string),
|
|
DeviceType: p["device_type"].(string),
|
|
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func pingDB(cfg *Config, ctx context.Context) error {
|
|
if cfg.DBPool != nil {
|
|
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
|
return pool.Ping(ctx)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseUUID(s string) (uuid.UUID, error) {
|
|
return uuid.Parse(s)
|
|
}
|
|
|
|
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
|
return pgtype.UUID{Bytes: u, Valid: true}
|
|
}
|
|
|
|
const selectedLibraryCookie = "selectedLibrary"
|
|
const allLibrariesSentinel = "__all__"
|
|
|
|
type LibraryResolution struct {
|
|
LibraryID string
|
|
IsAll bool
|
|
LibUUID pgtype.UUID
|
|
Libraries []templates.LibraryData
|
|
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
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
|
|
res := LibraryResolution{}
|
|
|
|
userU, _ := uuid.Parse(userUUID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
}
|
|
visibleSet := make(map[string]bool, len(libraries))
|
|
for _, lib := range libraries {
|
|
if !lib.ID.Valid {
|
|
continue
|
|
}
|
|
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
|
|
visibleSet[id.String()] = true
|
|
}
|
|
}
|
|
|
|
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
|
if countErr != nil {
|
|
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
|
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
|
}
|
|
countMap := make(map[string]int64, len(counts))
|
|
for _, mc := range counts {
|
|
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
|
countMap[mcUUID.String()] = mc.MediaCount
|
|
}
|
|
|
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
// Steam Deck SD-card behavior: a library whose folders are all
|
|
// missing (unmounted drive) renders offline - hidden contents, no
|
|
// marking, no purging - and returns when the storage does.
|
|
offline := false
|
|
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
|
|
if folderErr != nil || len(folders) == 0 {
|
|
offline = true
|
|
} else {
|
|
anyFolderExists := false
|
|
for _, folder := range folders {
|
|
if folderReachableCached(folder.FolderPath) {
|
|
anyFolderExists = true
|
|
break
|
|
}
|
|
}
|
|
offline = !anyFolderExists
|
|
}
|
|
res.Libraries[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: getText(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
MediaCount: countMap[libUUID.String()],
|
|
Offline: offline,
|
|
}
|
|
}
|
|
|
|
if len(libraries) > 0 {
|
|
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
res.FirstID = libUUID.String()
|
|
}
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
|
|
val, _ := url.QueryUnescape(cookie.Value)
|
|
if val == allLibrariesSentinel {
|
|
res.IsAll = true
|
|
res.LibraryID = ""
|
|
return res
|
|
}
|
|
if _, parseErr := uuid.Parse(val); parseErr == nil {
|
|
libraryID = val
|
|
}
|
|
}
|
|
}
|
|
|
|
if libraryID == "" {
|
|
res.LibraryID = res.FirstID
|
|
if res.LibraryID != "" {
|
|
parsed, _ := uuid.Parse(res.LibraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// A requested library outside the requester's visible set is not
|
|
// honored — fall back to the first visible library instead of leaking
|
|
// hidden-library contents through a hand-typed query param or cookie.
|
|
if !visibleSet[libraryID] {
|
|
res.LibraryID = res.FirstID
|
|
if res.LibraryID != "" {
|
|
parsed, _ := uuid.Parse(res.LibraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
return res
|
|
}
|
|
|
|
res.LibraryID = libraryID
|
|
parsed, _ := uuid.Parse(libraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
return res
|
|
}
|
|
|
|
// 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)
|
|
if !ok {
|
|
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
userU, err := uuid.Parse(userUUIDStr)
|
|
if err != nil {
|
|
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
set := make(map[string]bool, len(libraries))
|
|
ids := make([]pgtype.UUID, 0, len(libraries))
|
|
for _, lib := range libraries {
|
|
if !lib.ID.Valid {
|
|
continue
|
|
}
|
|
set[lib.ID.String()] = true
|
|
ids = append(ids, lib.ID)
|
|
}
|
|
return set, ids, nil
|
|
}
|