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:
John O'Keefe committed 2026-10-25 11:06:08 -04:00
1 parent 8733e83f1f
commit 8631aa00bf
107 files changed
+689 -3443

No files matched your search

+1 -11
View File
@@ -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
+7 -5
View File
@@ -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
}
-22
View File
@@ -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:
-94
View File
@@ -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"`
-407
View File
@@ -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)
}
-10
View File
@@ -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.
-5
View File
@@ -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
}
-21
View File
@@ -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")
}
-4
View File
@@ -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
+2 -1
View File
@@ -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 != "" {
-10
View File
@@ -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)
-194
View File
@@ -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,
})
}
-56
View File
@@ -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,
})
}
-86
View File
@@ -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")
-23
View File
@@ -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 {
-1
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
-128
View File
@@ -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()
+5 -155
View File
@@ -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)
}
+6 -64
View File
@@ -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)
}
-9
View File
@@ -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
-4
View File
@@ -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}
}
-8
View File
@@ -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) {
-21
View File
@@ -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)
}
-21
View File
@@ -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})
+2 -2
View File
@@ -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)
}
-16
View File
@@ -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),
-14
View File
@@ -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)
}
-8
View File
@@ -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)
-35
View File
@@ -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))
-40
View File
@@ -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 {
+1 -1
View File
@@ -11,8 +11,8 @@ package sync
// cfi_verify_manual_test.go.
import (
"fmt"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
+4 -2
View File
@@ -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 (
+4 -56
View File
@@ -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
}
-15
View File
@@ -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 {