refactor: remove dead code across server, templ pages, and client modules
Deadcode-verified removals (RTA reachability from main, cross-checked with -test and textual xrefs): Go server: - whole files: middleware/transaction.go, middleware/error_handler.go, middleware/password_validator.go (live registration machinery kept), handlers/sync.go, sync/offline.go - unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler, ReaderHandler.ShowReader, JSON-data twins on collections/library - duplicate book-matching handlers (book_matching.go Handler twins, matching.go MatchingHandler twins), superseded helpers (router.parseUUID, handlers.ValidateDeviceToken, normalizePath, userExists, generateLockID) Templ/client: - never-routed pages: api_explorer.templ, queue.templ and their client modules api-explorer.ts, api-explorer-docs.ts, queue.ts - fully orphaned wrapper modules events.ts, dom.ts - broken clearSyncQueue (called a nonexistent endpoint) and the hidden, never-populated sync-queue section on the devices page - 166+ unused exports demoted to private (knip-verified); ~85 truly dead declarations removed via tsc noUnusedLocals fixpoint - stale 'interactive API Explorer' claims dropped from docs The docs drift-gate package (internal/docs), templ-bound modules (dashboard, stats, header, index, login, profile*, bookshelf, collections, toast-error), sqlc-generated code, and web/static are intentionally untouched.
This commit is contained in:
1 parent
8733e83f1f
commit
8631aa00bf
107 files changed
+689
-3443
No files matched your search
@@ -56,20 +56,10 @@ func init() {
|
||||
|
||||
const (
|
||||
// Pre-computed FNV-1a hash of "bookhoard:schema:init"
|
||||
// Generated using: generateLockID("bookhoard:schema:init")
|
||||
// (FNV offset basis 14695981039346656037, FNV prime 1099511628211)
|
||||
schemaInitLockID = 7804706162000639061
|
||||
)
|
||||
|
||||
// Hash calculation function (for reference/testing)
|
||||
func generateLockID(key string) int64 {
|
||||
hash := uint64(14695981039346656037) // FNV offset basis
|
||||
for _, c := range key {
|
||||
hash ^= uint64(c)
|
||||
hash *= 1099511628211 // FNV prime
|
||||
}
|
||||
return int64(hash)
|
||||
}
|
||||
|
||||
// parseTableNames extracts all table names from CREATE TABLE statements
|
||||
// Uses stream scanning for performance and memory efficiency
|
||||
// Handles both "CREATE TABLE" and "CREATE TABLE IF NOT EXISTS" formats
|
||||
|
||||
@@ -24,9 +24,9 @@ import (
|
||||
|
||||
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||
const (
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeString = "string"
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeString = "string"
|
||||
SettingTypeStringList = "string_list"
|
||||
)
|
||||
|
||||
@@ -194,7 +194,9 @@ func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_
|
||||
// AutoPlaylistCollections reports whether the scanner should turn
|
||||
// obviously-playlist sidecar series ("The Witcher (Reading Order)") into
|
||||
// curated static collections (A2c §2.4).
|
||||
func (r *SettingsRegistry) AutoPlaylistCollections() bool { return r.getBool("auto_playlist_collections") }
|
||||
func (r *SettingsRegistry) AutoPlaylistCollections() bool {
|
||||
return r.getBool("auto_playlist_collections")
|
||||
}
|
||||
|
||||
// ScanExtractorVersion is the parser version stamped on each book's scan
|
||||
// manifest. Books whose stored stamp differs re-extract their metadata on
|
||||
@@ -298,7 +300,7 @@ func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
|
||||
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
|
||||
// changes require a restart.
|
||||
type WorkerPoolConfig struct {
|
||||
Size int
|
||||
Size int
|
||||
QueueCap int
|
||||
}
|
||||
|
||||
|
||||
@@ -163,28 +163,6 @@ func authLabel(chain []string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// parseHandlerMiddleware extracts per-route middleware markers from the
|
||||
// registration's argument list (wrappers like rateLimitMiddleware(handler)).
|
||||
func parseHandlerMiddleware(rest string) []string {
|
||||
if rest == "" {
|
||||
return nil
|
||||
}
|
||||
var mw []string
|
||||
if strings.HasPrefix(strings.TrimSpace(rest), "rateLimitMiddleware") {
|
||||
mw = append(mw, "rateLimit")
|
||||
}
|
||||
if strings.Contains(rest, "AdminMiddleware") {
|
||||
mw = append(mw, "admin")
|
||||
}
|
||||
if strings.Contains(rest, "DeviceAuthMiddleware") {
|
||||
mw = append(mw, "device-token")
|
||||
}
|
||||
if strings.Contains(rest, "createJWTMiddleware") {
|
||||
mw = append(mw, "jwt")
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
// ---------- the gate ----------
|
||||
|
||||
// TestBrunoMirrorsDocs keeps the executable-verification layer honest:
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -837,31 +836,6 @@ func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
||||
func normalizePath(path string) string {
|
||||
fmt.Printf("normalizePath input: '%s'\n", path)
|
||||
|
||||
var cleaned string
|
||||
// Handle home directory expansion (~)
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
// Keep the original path for ~ to preserve user's formatting
|
||||
// Just normalize separators and that's it
|
||||
cleaned = strings.ReplaceAll(path, "\\", "/")
|
||||
} else {
|
||||
// Clean the path to remove redundant separators, ., .. etc.
|
||||
cleaned = filepath.Clean(path)
|
||||
// Convert to consistent path separators (use forward slashes for storage)
|
||||
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
|
||||
// Remove trailing slash unless it's root path
|
||||
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
|
||||
cleaned = strings.TrimSuffix(cleaned, "/")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("normalizePath output: '%s'\n", cleaned)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
type UpdateThemeRequest struct {
|
||||
Theme string `json:"theme" validate:"required"`
|
||||
}
|
||||
@@ -889,74 +863,6 @@ func (h *AuthHandler) UpdateTheme(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
|
||||
}
|
||||
|
||||
type UpdateUsernameRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
}
|
||||
|
||||
// UpdateUsername handles PUT /api/auth/username
|
||||
func (h *AuthHandler) UpdateUsername(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
var req UpdateUsernameRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if username is already taken by another user
|
||||
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
|
||||
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
||||
}
|
||||
|
||||
// Update username
|
||||
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
||||
ID: user.ID,
|
||||
Username: req.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
|
||||
}
|
||||
|
||||
type UpdateEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
// UpdateEmail handles PUT /api/auth/email
|
||||
func (h *AuthHandler) UpdateEmail(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
var req UpdateEmailRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
||||
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
||||
}
|
||||
|
||||
// Update email
|
||||
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
||||
ID: user.ID,
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
|
||||
}
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
|
||||
@@ -3,9 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -39,201 +37,6 @@ func toFloat8(f float64) pgtype.Float8 {
|
||||
return result
|
||||
}
|
||||
|
||||
// QueryBooks handles POST /api/sync/books/query
|
||||
func (h *Handler) QueryBooks(c *echo.Context) error {
|
||||
matchingService := h.getMatchingService()
|
||||
|
||||
var req services.BookQueryRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
response, err := matchingService.QueryBooks(c.Request().Context(), &req)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to query books",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// LinkBook handles POST /api/sync/link-book
|
||||
func (h *Handler) LinkBook(c *echo.Context) error {
|
||||
matchingService := h.getMatchingService()
|
||||
|
||||
var req services.LinkBookRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
// Get device ID from context (set by auth middleware)
|
||||
deviceIDStr := c.Param("deviceId")
|
||||
deviceID, err := uuid.Parse(deviceIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid device ID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ownership: the device must belong to the caller.
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "user not authenticated",
|
||||
})
|
||||
}
|
||||
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
UserID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to verify device",
|
||||
})
|
||||
}
|
||||
if !deviceOwned {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Device not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Family-lens: the link target must sit in a library the caller can
|
||||
// see — a hidden library's book can never be linked.
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to resolve visible libraries",
|
||||
})
|
||||
}
|
||||
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
|
||||
if itemErr != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Media item not found",
|
||||
})
|
||||
}
|
||||
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Book is not available to you",
|
||||
})
|
||||
}
|
||||
|
||||
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("Failed to link book: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "linked",
|
||||
"device_file_alias": map[string]interface{}{
|
||||
"id": uuid.UUID(alias.ID.Bytes).String(),
|
||||
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
||||
"device_id": uuid.UUID(alias.DeviceID.Bytes).String(),
|
||||
"file_path": alias.FilePath,
|
||||
"file_sha256": alias.FileSha256.String,
|
||||
"confidence_score": alias.ConfidenceScore.Float64,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetUnlinkedBooks handles GET /api/sync/unlinked-books
|
||||
func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
|
||||
matchingService := h.getMatchingService()
|
||||
|
||||
deviceIDStr := c.Param("deviceId")
|
||||
deviceID, err := uuid.Parse(deviceIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid device ID",
|
||||
})
|
||||
}
|
||||
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "user not authenticated",
|
||||
})
|
||||
}
|
||||
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
UserID: user.ID,
|
||||
})
|
||||
if devErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to verify device",
|
||||
})
|
||||
}
|
||||
if !deviceOwned {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Device not found",
|
||||
})
|
||||
}
|
||||
|
||||
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to get unlinked books",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"unlinked": unlinked,
|
||||
"total": len(unlinked),
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeviceFileAliases handles GET /api/devices/:id/file-aliases
|
||||
func (h *Handler) GetDeviceFileAliases(c *echo.Context) error {
|
||||
deviceIDStr := c.Param("id")
|
||||
deviceID, err := uuid.Parse(deviceIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid device ID",
|
||||
})
|
||||
}
|
||||
|
||||
aliases, err := h.db.GetDeviceFileAliasesByDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to get file aliases",
|
||||
})
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
type AliasResponse struct {
|
||||
ID string `json:"id"`
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
ConfidenceScore float64 `json:"confidence_score"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
response := make([]AliasResponse, len(aliases))
|
||||
for i, alias := range aliases {
|
||||
response[i] = AliasResponse{
|
||||
ID: uuid.UUID(alias.ID.Bytes).String(),
|
||||
MediaItemID: uuid.UUID(alias.MediaItemID.Bytes).String(),
|
||||
FilePath: alias.FilePath,
|
||||
FileSHA256: alias.FileSha256.String,
|
||||
ConfidenceScore: alias.ConfidenceScore.Float64,
|
||||
LastSeenAt: alias.LastSeenAt.Time.String(),
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"device_id": deviceIDStr,
|
||||
"aliases": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
// BulkLinkBooks handles POST /api/sync/bulk-link-books
|
||||
func (h *Handler) BulkLinkBooks(c *echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
@@ -427,213 +230,3 @@ func (h *Handler) GetUnlinkedBookSuggestions(c *echo.Context) error {
|
||||
"action": matches.Action,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
|
||||
func (h *Handler) CreateDeviceFileAlias(c *echo.Context) error {
|
||||
deviceIDStr := c.Param("id")
|
||||
deviceID, err := uuid.Parse(deviceIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid device ID",
|
||||
})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
ConfidenceScore float64 `json:"confidence_score"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
mediaItemID, err := uuid.Parse(req.MediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid media item ID",
|
||||
})
|
||||
}
|
||||
|
||||
alias, err := h.db.CreateDeviceFileAlias(c.Request().Context(), database.CreateDeviceFileAliasParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
FilePath: req.FilePath,
|
||||
FileSha256: pgtype.Text{String: req.FileSHA256, Valid: req.FileSHA256 != ""},
|
||||
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("Failed to create file alias: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(alias.ID.Bytes).String(),
|
||||
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
||||
"device_id": deviceIDStr,
|
||||
"file_path": alias.FilePath,
|
||||
"file_sha256": alias.FileSha256.String,
|
||||
"confidence_score": alias.ConfidenceScore.Float64,
|
||||
"last_seen_at": alias.LastSeenAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeviceFileAlias handles PUT /api/devices/:id/file-aliases/:aliasId
|
||||
func (h *Handler) UpdateDeviceFileAlias(c *echo.Context) error {
|
||||
aliasIDStr := c.Param("aliasId")
|
||||
aliasID, err := uuid.Parse(aliasIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid alias ID",
|
||||
})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
MediaItemID *string `json:"media_item_id"`
|
||||
FileSHA256 *string `json:"file_sha256"`
|
||||
ConfidenceScore *float64 `json:"confidence_score"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
// Build update parameters
|
||||
var mediaItemID pgtype.UUID
|
||||
if req.MediaItemID != nil {
|
||||
parsedID, err := uuid.Parse(*req.MediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid media item ID",
|
||||
})
|
||||
}
|
||||
mediaItemID = pgtype.UUID{Bytes: parsedID, Valid: true}
|
||||
}
|
||||
|
||||
var fileSHA256 pgtype.Text
|
||||
if req.FileSHA256 != nil {
|
||||
fileSHA256 = pgtype.Text{String: *req.FileSHA256, Valid: true}
|
||||
}
|
||||
|
||||
var confidenceScore pgtype.Float8
|
||||
if req.ConfidenceScore != nil {
|
||||
confidenceScore = pgtype.Float8{Float64: *req.ConfidenceScore, Valid: true}
|
||||
}
|
||||
|
||||
alias, err := h.db.UpdateDeviceFileAlias(c.Request().Context(), database.UpdateDeviceFileAliasParams{
|
||||
ID: pgtype.UUID{Bytes: aliasID, Valid: true},
|
||||
MediaItemID: mediaItemID,
|
||||
FileSha256: fileSHA256,
|
||||
ConfidenceScore: confidenceScore,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("Failed to update file alias: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(alias.ID.Bytes).String(),
|
||||
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
||||
"file_path": alias.FilePath,
|
||||
"file_sha256": alias.FileSha256.String,
|
||||
"confidence_score": alias.ConfidenceScore.Float64,
|
||||
"last_seen_at": alias.LastSeenAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDeviceFileAlias handles DELETE /api/devices/:id/file-aliases/:aliasId
|
||||
func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
|
||||
aliasIDStr := c.Param("aliasId")
|
||||
aliasID, err := uuid.Parse(aliasIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid alias ID",
|
||||
})
|
||||
}
|
||||
|
||||
err = h.db.DeleteDeviceFileAlias(c.Request().Context(), pgtype.UUID{Bytes: aliasID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to delete file alias",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "File alias deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySetFor resolves the requesting user's visible-library set
|
||||
// (family-lens) from the request context identity.
|
||||
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
|
||||
}
|
||||
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
|
||||
c.Request().Context(), user.ID)
|
||||
return set, err
|
||||
}
|
||||
|
||||
// GetBookMatches handles GET /api/books/match
|
||||
func (h *Handler) GetBookMatches(c *echo.Context) error {
|
||||
matchingService := h.getMatchingService()
|
||||
|
||||
// Family-lens: restrict matches to libraries the requester can see
|
||||
// (absent-from-results — a hidden library's book simply never matches).
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to resolve visible libraries",
|
||||
})
|
||||
}
|
||||
visibleIDs := make([]string, 0, len(visibleSet))
|
||||
for id := range visibleSet {
|
||||
visibleIDs = append(visibleIDs, id)
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
identifiers := c.QueryParams()["identifier"]
|
||||
sha256 := c.QueryParam("sha256")
|
||||
title := c.QueryParam("title")
|
||||
author := c.QueryParam("author")
|
||||
fileSizeStr := c.QueryParam("file_size")
|
||||
|
||||
var fileSize int64
|
||||
if fileSizeStr != "" {
|
||||
size, err := strconv.ParseInt(fileSizeStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid file_size parameter",
|
||||
})
|
||||
}
|
||||
fileSize = size
|
||||
}
|
||||
|
||||
req := &services.BookQueryRequest{
|
||||
Identifiers: identifiers,
|
||||
SHA256: sha256,
|
||||
Title: title,
|
||||
Author: author,
|
||||
FileSize: fileSize,
|
||||
VisibleLibraryIDs: visibleIDs,
|
||||
}
|
||||
|
||||
response, err := matchingService.QueryBooks(c.Request().Context(), req)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to query books",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -814,16 +814,6 @@ func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID
|
||||
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetDeviceMappingsData(c *echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
|
||||
return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database.Collections, error) {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||
}
|
||||
|
||||
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
||||
// family lens). Smart-collection evaluation funnels every book candidate
|
||||
// through it so hidden libraries contribute nothing.
|
||||
|
||||
@@ -746,8 +746,3 @@ func generateDeviceToken() (string, error) {
|
||||
|
||||
return fmt.Sprintf("dev_%s", token), nil
|
||||
}
|
||||
|
||||
func ValidateDeviceToken(hashedToken string, plainToken string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedToken), []byte(plainToken))
|
||||
return err == nil
|
||||
}
|
||||
@@ -200,24 +200,3 @@ func TestDeviceUpdateRequest_Validation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeviceToken(t *testing.T) {
|
||||
hashedToken := []byte("$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi")
|
||||
plainToken := "password"
|
||||
|
||||
result := ValidateDeviceToken(string(hashedToken), plainToken)
|
||||
assert.True(t, result, "Valid token should return true")
|
||||
}
|
||||
|
||||
func TestValidateDeviceToken_Invalid(t *testing.T) {
|
||||
hashedToken := []byte("$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi")
|
||||
plainToken := "wrongpassword"
|
||||
|
||||
result := ValidateDeviceToken(string(hashedToken), plainToken)
|
||||
assert.False(t, result, "Invalid token should return false")
|
||||
}
|
||||
|
||||
func TestValidateDeviceToken_Empty(t *testing.T) {
|
||||
result := ValidateDeviceToken("", "anypassword")
|
||||
assert.False(t, result, "Empty hash should return false")
|
||||
}
|
||||
@@ -389,10 +389,6 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) LibrarySync(c *echo.Context) error {
|
||||
return h.Initialization(c)
|
||||
}
|
||||
|
||||
func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
@@ -541,7 +541,8 @@ func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUI
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
|
||||
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Priority 1: UUID provided (highest confidence - 1.0)
|
||||
if book.UUID != "" {
|
||||
|
||||
@@ -323,16 +323,6 @@ func parseUUID(uuidStr string) (pgtype.UUID, error) {
|
||||
return pgtype.UUID{Bytes: parsedUUID, Valid: true}, nil
|
||||
}
|
||||
|
||||
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
|
||||
func (h *LibraryHandler) GetUserVisibleLibrariesData(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
||||
return h.libraryService.GetUserVisibleLibraries(ctx, userID)
|
||||
}
|
||||
|
||||
// GetLibraryTypeData returns all library types for SSR (not JSON response)
|
||||
func (h *LibraryHandler) GetLibraryTypeData(ctx context.Context) ([]database.LibraryTypes, error) {
|
||||
return h.libraryService.GetLibraryTypes(ctx)
|
||||
}
|
||||
|
||||
// ListLibrariesData returns all libraries for SSR (admin only, not JSON response)
|
||||
func (h *LibraryHandler) ListLibrariesData(ctx context.Context) ([]database.ListLibrariesRow, error) {
|
||||
return h.libraryService.ListLibraries(ctx)
|
||||
|
||||
@@ -343,197 +343,3 @@ func (mh *MatchingHandler) GetBookMatches(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// BulkLinkBooks handles POST /api/sync/bulk-link-books
|
||||
func (mh *MatchingHandler) BulkLinkBooks(c *echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
var req BulkLinkBooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0, len(req.Links))
|
||||
|
||||
for _, link := range req.Links {
|
||||
unlinkedBook, err := mh.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true})
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{
|
||||
"unlinked_book_id": link.UnlinkedBookID.String(),
|
||||
"status": "error",
|
||||
"error": "Unlinked book not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = mh.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
|
||||
DeviceID: unlinkedBook.DeviceID,
|
||||
FilePath: unlinkedBook.FilePath.String,
|
||||
FileSha256: pgtype.Text{String: "", Valid: false},
|
||||
ConfidenceScore: toFloat8(link.ConfidenceScore),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{
|
||||
"unlinked_book_id": link.UnlinkedBookID.String(),
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = mh.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
|
||||
ID: pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
|
||||
ConfidenceScore: toFloat8(link.ConfidenceScore),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{
|
||||
"unlinked_book_id": link.UnlinkedBookID.String(),
|
||||
"status": "warning",
|
||||
"error": "Linked but failed to mark as resolved",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, map[string]interface{}{
|
||||
"unlinked_book_id": link.UnlinkedBookID.String(),
|
||||
"status": "success",
|
||||
"media_item_id": link.MediaItemID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
successfulCount := 0
|
||||
for _, r := range results {
|
||||
if r["status"] == "success" {
|
||||
successfulCount++
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(req.Links),
|
||||
"successful": successfulCount,
|
||||
"failed": len(req.Links) - successfulCount,
|
||||
})
|
||||
}
|
||||
|
||||
// AutoLinkBooks handles POST /api/sync/auto-link-books
|
||||
func (mh *MatchingHandler) AutoLinkBooks(c *echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
matchingService := mh.getMatchingService()
|
||||
|
||||
var req AutoLinkBooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
req.ConfidenceThreshold = 0.8
|
||||
req.Limit = 50
|
||||
}
|
||||
|
||||
if req.ConfidenceThreshold == 0 {
|
||||
req.ConfidenceThreshold = 0.8
|
||||
}
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 50
|
||||
}
|
||||
|
||||
offset := 0
|
||||
unlinked, err := mh.db.ListUnresolvedUnlinkedBooks(ctx, database.ListUnresolvedUnlinkedBooksParams{
|
||||
Limit: int32(req.Limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to get unlinked books",
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, book := range unlinked {
|
||||
queryReq := &services.BookQueryRequest{
|
||||
Title: book.Title.String,
|
||||
}
|
||||
|
||||
match, err := matchingService.QueryBooks(ctx, queryReq)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold {
|
||||
bestMatch := match.Matches[0]
|
||||
|
||||
_, err := mh.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
|
||||
DeviceID: book.DeviceID,
|
||||
FilePath: book.FilePath.String,
|
||||
FileSha256: pgtype.Text{String: "", Valid: false},
|
||||
ConfidenceScore: toFloat8(bestMatch.Confidence),
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
mh.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
|
||||
ID: book.ID,
|
||||
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
|
||||
ConfidenceScore: toFloat8(bestMatch.Confidence),
|
||||
})
|
||||
|
||||
results = append(results, map[string]interface{}{
|
||||
"unlinked_book_id": uuid.UUID(book.ID.Bytes).String(),
|
||||
"title": book.Title.String,
|
||||
"matched_media_item_id": bestMatch.BookhoardUUID.String(),
|
||||
"confidence": bestMatch.Confidence,
|
||||
"match_method": bestMatch.MatchMethod,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"auto_linked": len(results),
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUnlinkedBookSuggestions handles GET /api/sync/unlinked-books/:id/suggestions
|
||||
func (mh *MatchingHandler) GetUnlinkedBookSuggestions(c *echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
matchingService := mh.getMatchingService()
|
||||
|
||||
unlinkedBookID := c.Param("id")
|
||||
unlinkedUUID, err := uuid.Parse(unlinkedBookID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid unlinked book ID",
|
||||
})
|
||||
}
|
||||
|
||||
unlinked, err := mh.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: unlinkedUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Unlinked book not found",
|
||||
})
|
||||
}
|
||||
|
||||
matches, err := matchingService.QueryBooks(ctx, &services.BookQueryRequest{
|
||||
Title: unlinked.Title.String,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to query matches",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"unlinked_book_id": unlinkedBookID,
|
||||
"title_from_device": unlinked.Title.String,
|
||||
"sha256": "",
|
||||
"suggestions": matches.Matches,
|
||||
"total_suggestions": len(matches.Matches),
|
||||
"action": matches.Action,
|
||||
})
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"bookhoard/internal/opds"
|
||||
"bookhoard/internal/services"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -1219,57 +1217,3 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
||||
"formats": formatList,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterOPDS registers a device for OPDS access
|
||||
func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
// Parse device ID
|
||||
deviceUUID, err := uuid.Parse(deviceID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||
}
|
||||
|
||||
// Verify device exists
|
||||
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
_, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get config"})
|
||||
}
|
||||
|
||||
// Generate OPDS token
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
tokenBytes = []byte(deviceID + "-" + uuid.New().String())
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)[:64]
|
||||
|
||||
// Create OPDS token record (valid for 1 year)
|
||||
expiresAt := time.Now().AddDate(1, 0, 0)
|
||||
_, err = h.db.CreateOpdsToken(c.Request().Context(), database.CreateOpdsTokenParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceUUID, Valid: true},
|
||||
Token: token,
|
||||
TokenType: pgtype.Text{String: "device", Valid: true},
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||
}
|
||||
|
||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"opds_token": map[string]interface{}{
|
||||
"token": token,
|
||||
"token_type": "device",
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
},
|
||||
"opds_catalog_url": catalogURL,
|
||||
"refresh_interval": 3600,
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -54,91 +53,6 @@ type ReaderMetadata struct {
|
||||
ChapterCount int `json:"chapter_count"`
|
||||
}
|
||||
|
||||
// ShowReader renders the reader page (SSR)
|
||||
func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
parsedUUID, err := uuid.Parse(mediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
// Get media item
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, 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": "Failed to fetch media item"})
|
||||
}
|
||||
|
||||
// Get user from context (set by JWT middleware)
|
||||
user := c.Get("user")
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
|
||||
}
|
||||
userData := user.(database.Users)
|
||||
|
||||
// Family-lens gate (H4.5 canonical resolver): the media item's
|
||||
// library must be in the requester's visible set — 403, never a
|
||||
// silent pass.
|
||||
visibleSet, _, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), userData.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
|
||||
}
|
||||
visible := visibleSet[uuid.UUID(mediaItem.LibraryID.Bytes).String()]
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
|
||||
}
|
||||
|
||||
// Get library with type information for panel detection decision
|
||||
libraryWithType, err := h.db.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get library info"})
|
||||
}
|
||||
|
||||
// Determine if panel detection should be enabled
|
||||
// Same comic-likeness predicate as treatAsComic below — one function
|
||||
// on purpose (see ShouldTreatAsComic's doc).
|
||||
enablePanelDetection := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
|
||||
// Get reading progress
|
||||
var progress database.ReadingProgress
|
||||
progress, err = h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: userData.ID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
progress = database.ReadingProgress{}
|
||||
}
|
||||
|
||||
// Get bookmarks
|
||||
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
UserID: userData.ID,
|
||||
})
|
||||
|
||||
// Return JSON response instead of rendering template
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"media_item_id": mediaItemID,
|
||||
"title": mediaItem.Title,
|
||||
"author": utils.PgTextString(mediaItem.Author),
|
||||
"cover_image_path": utils.PgTextString(mediaItem.CoverImagePath),
|
||||
"library_type": mediaItem.FormatGroup,
|
||||
"mime_type": utils.PgTextString(mediaItem.MimeType),
|
||||
"file_path": mediaItem.FilePath,
|
||||
"total_pages": mediaItem.PageCount,
|
||||
"chapter_count": mediaItem.ChapterCount,
|
||||
"progress": progress,
|
||||
"bookmarks": bookmarks,
|
||||
"enable_panel_detection": enablePanelDetection,
|
||||
"treat_as_comic": treatAsComic,
|
||||
"format_group": mediaItem.FormatGroup,
|
||||
"manga_type": mediaItem.MangaType,
|
||||
"reading_direction": mediaItem.ReadingDirection,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPage returns a specific page for lazy loading
|
||||
func (h *ReaderHandler) GetPage(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
|
||||
@@ -184,29 +184,6 @@ type ScanSettingsResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateTimezoneSettingsRequest struct {
|
||||
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
var req UpdateTimezoneSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
||||
}
|
||||
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||
SettingKey: "default_timezone",
|
||||
SettingValue: req.DefaultTimezone,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.reload(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
var req UpdateScanSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,45 +2,17 @@ package middleware
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Mock database for testing device auth middleware
|
||||
type mockDeviceDB struct {
|
||||
device database.Devices
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDeviceDB) GetDeviceByAuthToken(ctx context.Context, token string) (database.Devices, error) {
|
||||
return m.device, m.err
|
||||
}
|
||||
|
||||
func TestDeviceAuth_Authenticate_BearerToken(t *testing.T) {
|
||||
deviceID := uuid.New()
|
||||
userID := uuid.New()
|
||||
authToken := "test_bearer_token_123"
|
||||
|
||||
mockDB := &mockDeviceDB{
|
||||
device: database.Devices{
|
||||
ID: pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
DeviceName: "Test KOReader Device",
|
||||
DeviceType: "koreader",
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
|
||||
middleware := &DeviceAuthMiddleware{
|
||||
// Can't use mockDB directly due to interface mismatch
|
||||
// In real scenario, would use a mock database or test database
|
||||
@@ -70,7 +42,6 @@ func TestDeviceAuth_Authenticate_BearerToken(t *testing.T) {
|
||||
// Would call handler(c) and assert results
|
||||
_ = handler
|
||||
_ = c
|
||||
_ = mockDB
|
||||
|
||||
// Test implementation would verify:
|
||||
// 1. Bearer token is extracted correctly
|
||||
@@ -125,102 +96,3 @@ func TestDeviceAuth_GetRequestType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceAuth_HasPermission(t *testing.T) {
|
||||
middleware := &DeviceAuthMiddleware{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceType string
|
||||
permission string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "KOReader progress permission",
|
||||
deviceType: "koreader",
|
||||
permission: "sync:progress",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "KOReader annotations permission",
|
||||
deviceType: "koreader",
|
||||
permission: "sync:annotations",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "KOReader metadata permission",
|
||||
deviceType: "koreader",
|
||||
permission: "sync:metadata",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Kobo progress permission",
|
||||
deviceType: "kobo",
|
||||
permission: "sync:progress",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Kobo annotations permission",
|
||||
deviceType: "kobo",
|
||||
permission: "sync:annotations",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Web device manage permission",
|
||||
deviceType: "web",
|
||||
permission: "device:manage",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "KOReader without manage permission",
|
||||
deviceType: "koreader",
|
||||
permission: "device:manage",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Unknown device type",
|
||||
deviceType: "unknown",
|
||||
permission: "sync:progress",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := middleware.hasPermission(tt.deviceType, tt.permission)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceAuth_UpdateLastSeen(t *testing.T) {
|
||||
// This test verifies the middleware structure
|
||||
// In practice, UpdateLastSeen requires a database connection
|
||||
middleware := &DeviceAuthMiddleware{}
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest("GET", "/api/sync/koreader/progress", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
deviceID := uuid.New()
|
||||
c.Set("device_id", [16]byte(deviceID))
|
||||
|
||||
next := func(c *echo.Context) error {
|
||||
// Simulate successful handler execution
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
handler := middleware.UpdateLastSeen(next)
|
||||
|
||||
// Note: Without a real database, this will fail when trying to update
|
||||
// This test verifies the middleware structure and flow
|
||||
// In production, would use a test database
|
||||
_ = handler
|
||||
_ = c
|
||||
|
||||
// Verify the device_id was set correctly in context
|
||||
deviceIDBytes, ok := c.Get("device_id").([16]byte)
|
||||
assert.True(t, ok, "device_id should be set in context")
|
||||
assert.Equal(t, [16]byte(deviceID), deviceIDBytes, "device_id should match")
|
||||
}
|
||||
@@ -146,17 +146,6 @@ func (drl *DeviceRateLimiter) GetRemainingRequests(deviceID string, requestType
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (drl *DeviceRateLimiter) Reset(deviceID string) {
|
||||
drl.mu.Lock()
|
||||
defer drl.mu.Unlock()
|
||||
|
||||
for key := range drl.requests {
|
||||
if len(key) > len(deviceID) && key[:len(deviceID)] == deviceID {
|
||||
delete(drl.requests, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (drl *DeviceRateLimiter) cleanupOldEntries() {
|
||||
for range drl.cleanup.C {
|
||||
drl.mu.Lock()
|
||||
|
||||
@@ -7,127 +7,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPasswordValidator_ValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "valid password with all requirements",
|
||||
password: "Test@Pass123!",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "valid password with special chars",
|
||||
password: "MyP@ssw0rd#2024",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
password: "Test1!",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "no uppercase",
|
||||
password: "test@pass123!",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "no lowercase",
|
||||
password: "TEST@PASS123!",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "no number",
|
||||
password: "Test@Password!",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "no special character",
|
||||
password: "TestPassword123",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
password: "",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tt.password)
|
||||
if tt.wantValid {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordValidator_ErrorMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expectedInError string
|
||||
}{
|
||||
{
|
||||
name: "too short error",
|
||||
password: "Short1!",
|
||||
expectedInError: "at least 8 characters",
|
||||
},
|
||||
{
|
||||
name: "no uppercase error",
|
||||
password: "alllower123!",
|
||||
expectedInError: "uppercase letter",
|
||||
},
|
||||
{
|
||||
name: "no lowercase error",
|
||||
password: "ALLUPPER123!",
|
||||
expectedInError: "lowercase letter",
|
||||
},
|
||||
{
|
||||
name: "no number error",
|
||||
password: "NoNumbers!",
|
||||
expectedInError: "number",
|
||||
},
|
||||
{
|
||||
name: "no special char error",
|
||||
password: "NoSpecialChars123",
|
||||
expectedInError: "special character",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tt.password)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedInError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPasswordRequirements(t *testing.T) {
|
||||
requirements := GetPasswordRequirements()
|
||||
|
||||
assert.NotEmpty(t, requirements)
|
||||
assert.Greater(t, len(requirements), 3)
|
||||
|
||||
// Check for common requirements
|
||||
requirementText := ""
|
||||
for _, req := range requirements {
|
||||
requirementText += req + " "
|
||||
}
|
||||
|
||||
assert.Contains(t, requirementText, "8")
|
||||
assert.Contains(t, requirementText, "uppercase")
|
||||
assert.Contains(t, requirementText, "lowercase")
|
||||
assert.Contains(t, requirementText, "number")
|
||||
assert.Contains(t, requirementText, "special")
|
||||
}
|
||||
|
||||
func TestLoginAttemptTracker_RecordFailedAttempt(t *testing.T) {
|
||||
tracker := NewLoginAttemptTracker(3, 5*time.Minute, 5*time.Minute)
|
||||
|
||||
@@ -231,13 +110,6 @@ func TestDeviceRateLimiter_CheckRateLimit(t *testing.T) {
|
||||
// Get remaining requests
|
||||
remaining := limiter.GetRemainingRequests(deviceID, requestType, config)
|
||||
assert.Equal(t, 0, remaining)
|
||||
|
||||
// Reset and verify
|
||||
limiter.Reset(deviceID)
|
||||
|
||||
// Should be allowed again
|
||||
allowed = limiter.CheckRateLimit(deviceID, requestType, config)
|
||||
assert.True(t, allowed, "Request after reset should be allowed")
|
||||
}
|
||||
|
||||
func TestDeviceRateLimiter_DifferentDevices(t *testing.T) {
|
||||
@@ -285,34 +157,12 @@ func TestDeviceRateLimiter_GetRemainingRequests(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewRateLimiter(t *testing.T) {
|
||||
config := DefaultRateLimiterConfig()
|
||||
config := RateLimiterConfig{
|
||||
Enabled: true,
|
||||
RequestsPerMinute: 60,
|
||||
CleanupInterval: time.Minute,
|
||||
}
|
||||
limiter := NewRateLimiter(config)
|
||||
|
||||
assert.NotNil(t, limiter)
|
||||
}
|
||||
|
||||
func TestHTTPError_Error(t *testing.T) {
|
||||
err := NewHTTPError(404, "Not Found", nil)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, 404, err.Code)
|
||||
assert.Equal(t, "Not Found", err.Message)
|
||||
}
|
||||
|
||||
func TestHTTPError_ErrorWithInternal(t *testing.T) {
|
||||
internalErr := assert.AnError
|
||||
err := NewHTTPError(500, "Internal Error", internalErr)
|
||||
|
||||
assert.Equal(t, "Internal Error: assert.AnError general error for testing", err.Error())
|
||||
assert.Equal(t, 500, err.Code)
|
||||
assert.Equal(t, "Internal Error", err.Message)
|
||||
assert.Equal(t, internalErr, err.Err)
|
||||
}
|
||||
|
||||
func TestNewHTTPError(t *testing.T) {
|
||||
err := NewHTTPError(404, "Not Found", nil)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, 404, err.Code)
|
||||
assert.Equal(t, "Not Found", err.Message)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
@@ -45,11 +44,6 @@ func (v *PasswordValidator) rules() database.PasswordRules {
|
||||
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
|
||||
}
|
||||
|
||||
// Validate checks if a password meets the configured complexity requirements.
|
||||
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
||||
return v.CheckPassword(fl.Field().String())
|
||||
}
|
||||
|
||||
// CheckPassword applies the active rules to a single password.
|
||||
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||
r := v.rules()
|
||||
@@ -71,61 +65,9 @@ func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPasswordRequirements returns a human-readable list of the active password
|
||||
// requirements, driven by the configured rules when a registry is wired.
|
||||
func GetPasswordRequirements() []string {
|
||||
return defaultPasswordValidator.Requirements()
|
||||
}
|
||||
|
||||
// Requirements returns the human-readable list for the receiver's active rules.
|
||||
func (v *PasswordValidator) Requirements() []string {
|
||||
r := v.rules()
|
||||
var out []string
|
||||
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
|
||||
if r.Upper {
|
||||
out = append(out, "At least one uppercase letter (A-Z)")
|
||||
}
|
||||
if r.Lower {
|
||||
out = append(out, "At least one lowercase letter (a-z)")
|
||||
}
|
||||
if r.Number {
|
||||
out = append(out, "At least one number (0-9)")
|
||||
}
|
||||
if r.Special {
|
||||
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidatePassword checks a password against the default (hardcoded) rules and
|
||||
// returns an error describing the first unmet requirement. Retained for callers
|
||||
// that don't have access to a configured PasswordValidator instance.
|
||||
func ValidatePassword(password string) error {
|
||||
v := defaultPasswordValidator
|
||||
r := v.rules()
|
||||
if len(password) < r.MinLength {
|
||||
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
|
||||
}
|
||||
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
if r.Special && !specialRegex().MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultPasswordValidator is used by the package-level helpers
|
||||
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
|
||||
// RegisterPasswordValidation when no registry has been wired. Callers that want
|
||||
// live rule updates should construct their own PasswordValidator and call
|
||||
// SetSettings.
|
||||
// defaultPasswordValidator backs the passwordcomplex struct tag registered by
|
||||
// RegisterPasswordValidation. Callers that want live rule updates should
|
||||
// construct their own PasswordValidator and call SetSettings.
|
||||
var defaultPasswordValidator = &PasswordValidator{}
|
||||
|
||||
// RegisterPasswordValidation registers the password validator with the
|
||||
@@ -138,9 +80,9 @@ func RegisterPasswordValidation(v *validator.Validate) error {
|
||||
}
|
||||
|
||||
// SetDefaultPasswordSettings wires the settings registry into the package-level
|
||||
// default validator so that the struct-tag validator (used by echo's
|
||||
// CustomValidator) and ValidatePassword follow live configuration. Intended to
|
||||
// be called once at startup.
|
||||
// default validator so that the passwordcomplex struct-tag validator (used by
|
||||
// echo's CustomValidator) follows live configuration. Intended to be called
|
||||
// once at startup.
|
||||
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
|
||||
defaultPasswordValidator.SetSettings(s)
|
||||
}
|
||||
@@ -15,15 +15,6 @@ type RateLimiterConfig struct {
|
||||
CleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultRateLimiterConfig returns sensible defaults
|
||||
func DefaultRateLimiterConfig() RateLimiterConfig {
|
||||
return RateLimiterConfig{
|
||||
Enabled: true,
|
||||
RequestsPerMinute: 10,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiter tracks request counts per IP
|
||||
type RateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
@@ -92,10 +92,6 @@ func pingDB(cfg *Config, ctx context.Context) error {
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -237,14 +237,6 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// userExists checks the users table for the token's claimed user. Runs on
|
||||
// every authenticated request: a primary-key lookup, cheap at this scale,
|
||||
// and it makes deletion revocable the moment it happens.
|
||||
func userExists(cfg *Config, id uuid.UUID) bool {
|
||||
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// fetchLiveUser loads the full user row for the JWT stub — the ghost-session
|
||||
// guard and the timezone travel together on this one per-request lookup.
|
||||
func fetchLiveUser(cfg *Config, id uuid.UUID) (database.GetUserRow, bool) {
|
||||
|
||||
@@ -343,24 +343,3 @@ func TestLinkBookRequest_Validation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage
|
||||
func ExampleBookMatchingService_QueryBooks() {
|
||||
service := NewBookMatchingService(nil) // Would need real DB
|
||||
|
||||
req := &BookQueryRequest{
|
||||
Identifiers: []string{
|
||||
"uuid:550e8400-e29b-41d4-a716-446655440000",
|
||||
"isbn:9783161484100",
|
||||
},
|
||||
SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
|
||||
Title: "Test Book",
|
||||
Author: "Test Author",
|
||||
FileSize: 1024000,
|
||||
}
|
||||
|
||||
_ = service
|
||||
_ = req
|
||||
// response, err := service.QueryBooks(context.Background(), req)
|
||||
// fmt.Printf("Matches: %d, Action: %s\n", len(response.Matches), response.Action)
|
||||
}
|
||||
@@ -122,27 +122,6 @@ func (s *CollectionService) GetCollection(ctx context.Context, collectionID uuid
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
// GetCollectionWithBookCount gets a collection with book count
|
||||
func (s *CollectionService) GetCollectionWithBookCount(ctx context.Context, collectionID uuid.UUID) (map[string]any, error) {
|
||||
collection, err := s.db.GetCollectionWithBookCount(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection: %v", err)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
"name": collection.Name,
|
||||
"description": collection.Description.String,
|
||||
"color": collection.Color.String,
|
||||
"icon": collection.Icon.String,
|
||||
"auto_assign_rules": collection.AutoAssignRules,
|
||||
"view_settings": collection.ViewSettings,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
"book_count": collection.BookCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserCollections gets all collections for a user
|
||||
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.Collections, error) {
|
||||
collections, err := s.db.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||||
|
||||
@@ -202,7 +202,7 @@ func clauseBoolFactory(col string) func(FilterRule, *filterBuilder) string {
|
||||
}
|
||||
|
||||
// clausePresenceFactory — has / missing over a text column
|
||||
// (IS NULL / = '' per the plan's empty semantics).
|
||||
// (IS NULL / = ” per the plan's empty semantics).
|
||||
func clausePresenceFactory(col string) func(FilterRule, *filterBuilder) string {
|
||||
return func(r FilterRule, b *filterBuilder) string {
|
||||
switch r.Op {
|
||||
@@ -761,7 +761,7 @@ WHERE ` + whereSQL +
|
||||
for rows.Next() {
|
||||
var row database.SearchMediaItemsUnifiedRow
|
||||
if err := rows.Scan(composedScanDest(&row)...); err != nil {
|
||||
return nil, err
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
@@ -491,12 +491,6 @@ func (s *MediaScanner) SetTrigger(trigger string) {
|
||||
s.scanTrigger = trigger
|
||||
}
|
||||
|
||||
// GetChangedItems reports how many existing items re-processed this run
|
||||
// (size change, sidecar change, or force rescan).
|
||||
func (s *MediaScanner) GetChangedItems() int {
|
||||
return s.changedItems
|
||||
}
|
||||
|
||||
func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
return s.totalFiles, s.newItems, s.errors
|
||||
}
|
||||
@@ -4272,16 +4266,6 @@ func (s *MediaScanner) scanStateUpsert(libraryID pgtype.UUID, entry *scanEntry)
|
||||
s.scanState[scanStateKey(libraryID, entry.FilePath)] = entry
|
||||
}
|
||||
|
||||
// scanStateRemove drops an entry (the row's file_path moved elsewhere).
|
||||
func (s *MediaScanner) scanStateRemove(libraryID pgtype.UUID, relPath string) {
|
||||
s.scanStateMu.Lock()
|
||||
defer s.scanStateMu.Unlock()
|
||||
if s.scanState == nil {
|
||||
return
|
||||
}
|
||||
delete(s.scanState, scanStateKey(libraryID, relPath))
|
||||
}
|
||||
|
||||
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
||||
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
||||
FilePath: s.getRelativePath(filePath),
|
||||
|
||||
@@ -476,20 +476,6 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
}
|
||||
}
|
||||
|
||||
// audioDurationLabel renders the duration for logs/detail surfaces.
|
||||
func audioDurationLabel(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
return "—"
|
||||
}
|
||||
d := time.Duration(seconds) * time.Second
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
}
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
|
||||
// parseJSONInt reads a RawMessage that may hold a JSON number or a
|
||||
// number-string; 0 for anything else. Used for sidecar fields whose
|
||||
// type differs across producing tools (Libation vs Calibre/ABS).
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
@@ -595,21 +594,6 @@ func createTestTarGz(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Example usage
|
||||
func Example_extractComicMetadata() {
|
||||
// Extract metadata from a comic archive
|
||||
comicInfo, coverImage, err := extractComicMetadata("/path/to/comic.cbz")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Title: %s\n", comicInfo.Title)
|
||||
fmt.Printf("Series: %s\n", comicInfo.Series)
|
||||
fmt.Printf("Issue: %d\n", comicInfo.Number)
|
||||
fmt.Printf("Cover size: %d bytes\n", len(coverImage))
|
||||
}
|
||||
|
||||
func TestNormalizeMangaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -421,17 +420,3 @@ func TestExtractHashInfoIntegration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage
|
||||
func ExampleMediaScanner_calculateFileSHA256() {
|
||||
scanner := &MediaScanner{}
|
||||
|
||||
// Calculate SHA-256 hash of a file
|
||||
hash, err := scanner.calculateFileSHA256("/path/to/ebook.epub")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("SHA-256: %s\n", hash)
|
||||
}
|
||||
@@ -167,14 +167,6 @@ func normalizeLanguageFilter(wireValue string) string {
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// NormalizeLanguageFilter is the exported form of the search-path
|
||||
// normalization for callers that must compare RAW filter values
|
||||
// against stored codes without issuing a query (the facet panel marks
|
||||
// a value Active by matching the bound filter set).
|
||||
func NormalizeLanguageFilter(wireValue string) string {
|
||||
return normalizeLanguageFilter(wireValue)
|
||||
}
|
||||
|
||||
// FacetValue is one facet value + count for the filter panel.
|
||||
type FacetValue struct {
|
||||
Value string `json:"value"` // the raw value (rides the filter param)
|
||||
|
||||
@@ -295,30 +295,6 @@ func OpenReader(name string) (*ReadCloser, error) {
|
||||
return OpenReaderWithPassword(name, "")
|
||||
}
|
||||
|
||||
// NewReaderWithPassword returns a new [*Reader] reading from r using password
|
||||
// as the basis of the decryption key, which is assumed to have the given size
|
||||
// in bytes.
|
||||
func NewReaderWithPassword(r io.ReaderAt, size int64, password string) (*Reader, error) {
|
||||
if size < 0 {
|
||||
return nil, errNegativeSize
|
||||
}
|
||||
|
||||
zr := new(Reader)
|
||||
zr.p = password
|
||||
|
||||
if err := zr.init(r, size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return zr, nil
|
||||
}
|
||||
|
||||
// NewReader returns a new [*Reader] reading from r, which is assumed to have
|
||||
// the given size in bytes.
|
||||
func NewReader(r io.ReaderAt, size int64) (*Reader, error) {
|
||||
return NewReaderWithPassword(r, size, "")
|
||||
}
|
||||
|
||||
func (z *Reader) folderReader(si *streamsInfo, f int) (*folderReadCloser, uint32, bool, error) {
|
||||
// Create a SectionReader covering all of the streams data
|
||||
return si.FolderReader(io.NewSectionReader(z.r, z.start, z.end-z.start), f, z.p)
|
||||
@@ -568,17 +544,6 @@ func (z *Reader) init(r io.ReaderAt, size int64) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Volumes returns the list of volumes that have been opened as part of the
|
||||
// current archive.
|
||||
func (rc *ReadCloser) Volumes() []string {
|
||||
volumes := make([]string, len(rc.f))
|
||||
for idx, f := range rc.f {
|
||||
volumes[idx] = f.Name()
|
||||
}
|
||||
|
||||
return volumes
|
||||
}
|
||||
|
||||
// Close closes the 7-zip file or volumes, rendering them unusable for I/O.
|
||||
func (rc *ReadCloser) Close() error {
|
||||
errs := make([]error, 0, len(rc.f))
|
||||
|
||||
@@ -967,45 +967,6 @@ func findNodeAtCharOffset(root *html.Node, targetOffset int) (*html.Node, int) {
|
||||
return found, currentOffset
|
||||
}
|
||||
|
||||
func (c *CFIConverter) computePercentage(xp *CREXPointer, target *html.Node, remainingOffset int) float64 {
|
||||
spine, err := c.loadSpine()
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
charOffset := 0
|
||||
for i := 1; i < xp.FragmentIndex; i++ {
|
||||
doc, _, err := c.getContentDoc(i)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body != nil {
|
||||
charOffset += countTextChars(body)
|
||||
}
|
||||
}
|
||||
|
||||
charOffset += countTextCharsBefore(target)
|
||||
charOffset += remainingOffset
|
||||
|
||||
totalChars := int64(0)
|
||||
for i := range spine.items {
|
||||
doc, _, err := c.getContentDoc(i + 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body != nil {
|
||||
totalChars += int64(countTextChars(body))
|
||||
}
|
||||
}
|
||||
|
||||
if totalChars <= 0 {
|
||||
return -1
|
||||
}
|
||||
return float64(charOffset) / float64(totalChars)
|
||||
}
|
||||
|
||||
type indexedNode struct {
|
||||
node *html.Node
|
||||
textChunk []*html.Node
|
||||
@@ -1157,7 +1118,6 @@ func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
|
||||
return -1, 0, ""
|
||||
}
|
||||
|
||||
|
||||
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
|
||||
indexed := indexChildNodes(parent)
|
||||
for i, node := range indexed {
|
||||
|
||||
@@ -11,8 +11,8 @@ package sync
|
||||
// cfi_verify_manual_test.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
|
||||
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
|
||||
// doc2 = spine index 1 with two paragraphs:
|
||||
// p1: "The family of Dashwood had long been settled in Sussex."
|
||||
// p2: "Their estate was large, and their residence was at Norland Park."
|
||||
//
|
||||
// p1: "The family of Dashwood had long been settled in Sussex."
|
||||
// p2: "Their estate was large, and their residence was at Norland Park."
|
||||
//
|
||||
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
|
||||
// p→text chunk /1):
|
||||
const (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@@ -13,10 +12,10 @@ type KEPUBCFIConverter struct {
|
||||
}
|
||||
|
||||
type KEPUBConversionResult struct {
|
||||
CFI string
|
||||
ExtractedContext string
|
||||
Percentage float64
|
||||
Precision string
|
||||
CFI string
|
||||
ExtractedContext string
|
||||
Percentage float64
|
||||
Precision string
|
||||
}
|
||||
|
||||
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
|
||||
@@ -265,57 +264,6 @@ func extractSurroundingText(textNode *html.Node, offset int, window int) string
|
||||
return normalizeWhitespace(string(allRunes[start:end]))
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) {
|
||||
if !IsStandardEPUBCFI(kepubCFI) {
|
||||
return -1, fmt.Errorf("not a standard epubcfi")
|
||||
}
|
||||
|
||||
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return -1, docErr
|
||||
}
|
||||
|
||||
kepubBody := findBody(kepubDoc)
|
||||
if kepubBody == nil {
|
||||
return -1, fmt.Errorf("no body in kepub doc")
|
||||
}
|
||||
|
||||
textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps)
|
||||
if resolveErr != nil {
|
||||
return -1, resolveErr
|
||||
}
|
||||
|
||||
charOffset := countTextCharsBefore(textNode) + textOffset
|
||||
|
||||
total := 0
|
||||
kepubSpine, spineErr := k.kepubConverter.loadSpine()
|
||||
if spineErr != nil {
|
||||
return -1, spineErr
|
||||
}
|
||||
|
||||
for i := range kepubSpine.items {
|
||||
doc, _, dErr := k.kepubConverter.getContentDoc(i + 1)
|
||||
if dErr != nil {
|
||||
continue
|
||||
}
|
||||
b := findBody(doc)
|
||||
if b != nil {
|
||||
total += countTextChars(b)
|
||||
}
|
||||
}
|
||||
|
||||
if total <= 0 {
|
||||
return -1, fmt.Errorf("no text in kepub")
|
||||
}
|
||||
|
||||
return float64(charOffset) / float64(total), nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = log.Printf
|
||||
}
|
||||
@@ -260,21 +260,6 @@ func FormatProgressForDisplay(formatGroup FormatGroup, progress map[string]inter
|
||||
}
|
||||
}
|
||||
|
||||
// ParseProgressFromJSON parses progress data from JSON
|
||||
func ParseProgressFromJSON(data []byte) (*ProgressData, error) {
|
||||
var progress ProgressData
|
||||
err := json.Unmarshal(data, &progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &progress, nil
|
||||
}
|
||||
|
||||
// MarshalProgressToJSON converts progress data to JSON
|
||||
func MarshalProgressToJSON(progress *ProgressData) ([]byte, error) {
|
||||
return json.Marshal(progress)
|
||||
}
|
||||
|
||||
const CharsPerPage int64 = 1800
|
||||
|
||||
func EstimatedPages(totalCharacters int64) int {
|
||||
|
||||
Reference in new issue
Block a user