TestProgressWeb_KoboIntegration 500s'd on every markup with a last-read-place bookmark: the panic-recovered stack was a nil pointer dereference in LibraryService.ResolveMediaPath (s.db deref), reached from KoboHandler.convertKoboCFIToStandard during kepub CFI conversion. Two-layer bug: 1. Test harness (root cause): the router.Config built in test_helpers_test.go omitted LibraryService, so router/sync.go's SetLibraryService(cfg.LibraryService) stored a nil *services.LibraryService in the interface field. Production wiring (main.go:246) was always correct. 2. Latent trap (hardening): `h.libraryService != nil` at the convertKoboCFIToStandard call site can never catch a typed nil — an interface holding a nil *LibraryService is non-nil. ResolveMediaPath now degrades with a "library service not configured" error instead of panicking on a nil receiver; callers already handle the error by skipping CFI conversion. Harness-only fix, zero production behavior change: the endpoint now returns a clean 200 with the position saved wherever kepub conversion is unavailable instead of a panic-recovered 500. Verified: TestProgressWeb_KoboIntegration passes all three subtests (401 unauthenticated, ReadingSync→last-read-place percentage preservation, last-read-place without prior ReadingSync) and on immediate rerun — previously it failed every run.
330 lines
10 KiB
Go
330 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type LibraryService struct {
|
|
db *database.Queries
|
|
}
|
|
|
|
func NewLibraryService(db *database.Queries) *LibraryService {
|
|
return &LibraryService{
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// Library type definitions and file extensions
|
|
const (
|
|
LibraryTypeEbooks = "ebooks"
|
|
LibraryTypeComics = "comics"
|
|
LibraryTypeManga = "manga"
|
|
)
|
|
|
|
var AllowedExtensions = map[string][]string{
|
|
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
|
|
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
|
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
|
}
|
|
|
|
var MimeTypes = map[string]string{
|
|
// Images (manga)
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".bmp": "image/bmp",
|
|
".webp": "image/webp",
|
|
".avif": "image/avif",
|
|
".tiff": "image/tiff",
|
|
".tif": "image/tiff",
|
|
// Comics
|
|
".cbz": "application/vnd.comicbook+zip",
|
|
".cbr": "application/vnd.comicbook-rar",
|
|
".cb7": "application/x-cb7",
|
|
".cbt": "application/x-cbt",
|
|
// Ebooks
|
|
".epub": "application/epub+zip",
|
|
".pdf": "application/pdf",
|
|
".mobi": "application/x-mobipocket-ebook",
|
|
".azw": "application/vnd.amazon.ebook",
|
|
".azw3": "application/vnd.amazon.ebook",
|
|
".txt": "text/plain",
|
|
".rtf": "application/rtf",
|
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
".fb2": "application/x-fictionbook+xml",
|
|
".pdb": "application/vnd.palm",
|
|
}
|
|
|
|
// GetLibraryTypes retrieves all available library types
|
|
func (s *LibraryService) GetLibraryTypes(ctx context.Context) ([]database.LibraryTypes, error) {
|
|
return s.db.GetLibraryTypes(ctx)
|
|
}
|
|
|
|
// CreateLibrary creates a new library with the given parameters
|
|
func (s *LibraryService) CreateLibrary(ctx context.Context, name, description, libraryType string, adminID pgtype.UUID) (*database.Libraries, error) {
|
|
// Get library type ID
|
|
libType, err := s.db.GetLibraryTypeByName(ctx, libraryType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid library type: %w", err)
|
|
}
|
|
|
|
// Create library
|
|
library, err := s.db.CreateLibrary(ctx, database.CreateLibraryParams{
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: true},
|
|
LibraryTypeID: libType.ID,
|
|
CreatedByAdminID: adminID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create library: %w", err)
|
|
}
|
|
|
|
return &library, nil
|
|
}
|
|
|
|
// GetLibrary retrieves a library by ID with type information
|
|
func (s *LibraryService) GetLibrary(ctx context.Context, libraryID pgtype.UUID) (*database.GetLibraryRow, error) {
|
|
library, err := s.db.GetLibrary(ctx, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &library, nil
|
|
}
|
|
|
|
// ListLibraries retrieves all libraries
|
|
func (s *LibraryService) ListLibraries(ctx context.Context) ([]database.ListLibrariesRow, error) {
|
|
return s.db.ListLibraries(ctx)
|
|
}
|
|
|
|
// UpdateLibrary updates an existing library
|
|
func (s *LibraryService) UpdateLibrary(ctx context.Context, libraryID pgtype.UUID, name, description string) (*database.Libraries, error) {
|
|
library, err := s.db.UpdateLibrary(ctx, database.UpdateLibraryParams{
|
|
ID: libraryID,
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &library, nil
|
|
}
|
|
|
|
// DeleteLibrary deletes a library and all its associated data
|
|
func (s *LibraryService) DeleteLibrary(ctx context.Context, libraryID pgtype.UUID) error {
|
|
return s.db.DeleteLibrary(ctx, libraryID)
|
|
}
|
|
|
|
// AddLibraryFolder adds a folder to a library
|
|
func (s *LibraryService) AddLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) (*database.LibraryFolders, error) {
|
|
folder, err := s.db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
|
LibraryID: libraryID,
|
|
FolderPath: folderPath,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &folder, nil
|
|
}
|
|
|
|
// GetLibraryFolders retrieves all folders for a library
|
|
func (s *LibraryService) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]database.LibraryFolders, error) {
|
|
return s.db.GetLibraryFolders(ctx, libraryID)
|
|
}
|
|
|
|
// DeleteLibraryFolder removes a folder from a library
|
|
func (s *LibraryService) DeleteLibraryFolder(ctx context.Context, libraryID pgtype.UUID, folderPath string) error {
|
|
_, err := s.db.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
|
|
LibraryID: libraryID,
|
|
FolderPath: folderPath,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// SetLibraryVisibility sets library visibility for a user
|
|
func (s *LibraryService) SetLibraryVisibility(ctx context.Context, userID, libraryID pgtype.UUID, isVisible bool) (*database.LibraryVisibility, error) {
|
|
visibility, err := s.db.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
|
UserID: userID,
|
|
LibraryID: libraryID,
|
|
IsVisible: isVisible,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &visibility, nil
|
|
}
|
|
|
|
// GetUserVisibleLibraries retrieves all libraries visible to a user
|
|
func (s *LibraryService) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
|
return s.db.GetUserVisibleLibraries(ctx, userID)
|
|
}
|
|
|
|
// IsFileExtensionAllowed checks if a file extension is allowed for a library type
|
|
func (s *LibraryService) IsFileExtensionAllowed(libraryType, extension string) bool {
|
|
extensions, exists := AllowedExtensions[libraryType]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
for _, ext := range extensions {
|
|
if strings.EqualFold(ext, extension) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetLibraryFileExtensions returns all allowed file extensions for a library type
|
|
func (s *LibraryService) GetLibraryFileExtensions(libraryType string) []string {
|
|
extensions, exists := AllowedExtensions[libraryType]
|
|
if !exists {
|
|
return []string{}
|
|
}
|
|
return extensions
|
|
}
|
|
|
|
// GetLibraryTypeFromFileExtension determines the library type based on file extension
|
|
func (s *LibraryService) GetLibraryTypeFromFileExtension(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
|
|
for libType, extensions := range AllowedExtensions {
|
|
for _, allowedExt := range extensions {
|
|
if ext == allowedExt {
|
|
return libType
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ValidateLibraryPath checks if a path is valid for the given library type
|
|
func (s *LibraryService) ValidateLibraryPath(libraryType, folderPath string) error {
|
|
// You could add more validation here like:
|
|
// - Check if path exists
|
|
// - Check if path is readable
|
|
// - Validate path format for specific library types
|
|
// - Check for appropriate file structures (e.g., for manga with image folders)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLibraryStats returns statistics for a library (media count, etc.)
|
|
func (s *LibraryService) GetLibraryStats(ctx context.Context, libraryID pgtype.UUID) (map[string]interface{}, error) {
|
|
// For now, return basic info. This can be expanded with more detailed stats
|
|
mediaItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"media_count": len(mediaItems),
|
|
}, nil
|
|
}
|
|
|
|
// HasFolders checks if a library has at least one folder configured
|
|
func (s *LibraryService) HasFolders(ctx context.Context, libraryID pgtype.UUID) (bool, error) {
|
|
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check library folders: %w", err)
|
|
}
|
|
return len(folders) > 0, nil
|
|
}
|
|
|
|
// BrowseDirectories lists directories at a given path for folder browser UI
|
|
// Returns: (directories, currentPath, parentPath, error)
|
|
func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) {
|
|
// Security: path traversal protection
|
|
if strings.Contains(path, "..") {
|
|
return nil, "", "", fmt.Errorf("path traversal not allowed")
|
|
}
|
|
|
|
cleanPath := filepath.Clean(path)
|
|
|
|
// Check if path exists and is accessible
|
|
fileInfo, err := os.Stat(cleanPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, "", "", fmt.Errorf("path does not exist")
|
|
}
|
|
return nil, "", "", fmt.Errorf("path not accessible: %w", err)
|
|
}
|
|
|
|
if !fileInfo.IsDir() {
|
|
return nil, "", "", fmt.Errorf("not a directory")
|
|
}
|
|
|
|
// Read directory contents
|
|
entries, err := os.ReadDir(cleanPath)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("failed to read directory: %w", err)
|
|
}
|
|
|
|
// Filter only directories
|
|
var dirs []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
dirs = append(dirs, entry.Name())
|
|
}
|
|
}
|
|
|
|
// Get parent path for navigation
|
|
parentPath := filepath.Dir(cleanPath)
|
|
if parentPath == cleanPath {
|
|
parentPath = "" // At root
|
|
}
|
|
|
|
return dirs, cleanPath, parentPath, nil
|
|
}
|
|
|
|
// SyncAllowedExtensions syncs the Go AllowedExtensions map into the database.
|
|
// This ensures library_types.allowed_extensions stays in sync with the Go source of truth.
|
|
func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
|
|
for typeName, exts := range AllowedExtensions {
|
|
if err := s.db.SyncLibraryTypeExtensions(ctx, database.SyncLibraryTypeExtensionsParams{
|
|
Name: typeName,
|
|
AllowedExtensions: exts,
|
|
}); err != nil {
|
|
log.Printf("Warning: failed to sync allowed extensions for library type %s: %v", typeName, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
|
// Typed-nil interface guard: a nil *LibraryService held in an
|
|
// interface passes `!= nil` checks at call sites; degrade instead
|
|
// of panicking (kobo markup 500s were exactly this path).
|
|
if s == nil || s.db == nil {
|
|
return "", fmt.Errorf("library service not configured")
|
|
}
|
|
|
|
// Get library folders for this library
|
|
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil || len(folders) == 0 {
|
|
return "", fmt.Errorf("no library folders found for library")
|
|
}
|
|
|
|
// Try each folder - find one where the relative path makes sense
|
|
for _, folder := range folders {
|
|
fullPath := filepath.Join(folder.FolderPath, relativePath)
|
|
if _, err := os.Stat(fullPath); err == nil {
|
|
return fullPath, nil
|
|
}
|
|
}
|
|
|
|
// Fallback: use first folder (file might not exist yet during scan)
|
|
if len(folders) > 0 {
|
|
return filepath.Join(folders[0].FolderPath, relativePath), nil
|
|
}
|
|
|
|
return "", fmt.Errorf("could not resolve path")
|
|
}
|