Files
bookhoard/internal/handlers/sidecar.go
T
john-okeefe 830741cd65 feat(sidecar): key book map by per-format hashes
The sidecar config's books map is keyed by the primary SHA-256 (UUID
fallback). A device holding a converted format (KEPUB/PDF) whose hash
lives only in media_item_formats could not resolve its file through
the sidecar.

After inserting the primary-keyed entry, also register the same entry
under each per-format hash from media_item_formats (first write wins,
so a primary hash is never shadowed). Devices now resolve converted
files via the sidecar the same way the server's BookResolver does.
Applied to both the GET and download sidecar builders.
2026-08-14 08:26:32 -04:00

631 lines
20 KiB
Go

package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/setupstatus"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SidecarHandler struct {
db *database.Queries
cfg *config.Config
settings *database.SettingsRegistry
}
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db, cfg: cfg}
}
// SetSettings wires the tunable settings registry so the timezone write path
// keeps the cache consistent.
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
type SidecarConfig struct {
Version string `json:"version"`
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
Books map[string]SidecarBook `json:"books"`
Collections []SidecarCollection `json:"collections"`
OPDSEnabled bool `json:"opds_enabled"`
SidecarEnabled bool `json:"sidecar_enabled"`
LastUpdated string `json:"last_updated"`
}
type SidecarBookhoardConfig struct {
OPDSCatalog string `json:"opds_catalog"`
SyncAPI string `json:"sync_api"`
OPDSBaseURL string `json:"opds_base_url"`
APIBaseURL string `json:"api_base_url"`
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token,omitempty"`
}
type SidecarBook struct {
BookhoardUUID string `json:"bookhoard_uuid"`
Title string `json:"title"`
Author string `json:"author"`
AvailableFormats []string `json:"available_formats"`
SHA256 string `json:"sha256,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type SidecarCollection struct {
Name string `json:"name"`
ShelfMapping string `json:"shelf_mapping,omitempty"`
BookIDs []string `json:"book_ids"`
}
// GetSidecarConfig generates and returns sidecar configuration for a device
// GET /api/devices/:device_id/sidecar
func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid device ID",
})
}
ctx := c.Request().Context()
pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true}
// Get device info
device, err := h.db.GetDevice(ctx, pgDeviceID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "device not found",
})
}
// Get user info
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch media items",
})
}
// Build books map (keyed by SHA256, fallback to UUID)
books := make(map[string]SidecarBook)
for _, item := range mediaItems {
bookUUID := uuid.UUID(item.ID.Bytes).String()
// Use SHA256 as key if available, otherwise use UUID
key := bookUUID
if item.FileSha256.Valid && item.FileSha256.String != "" {
key = item.FileSha256.String
}
availableFormats := []string{"epub"}
if item.MimeType.Valid {
if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" {
availableFormats = append(availableFormats, "kepub")
}
}
author := ""
if item.Author.Valid {
author = item.Author.String
}
books[key] = SidecarBook{
BookhoardUUID: bookUUID,
Title: item.Title,
Author: author,
AvailableFormats: availableFormats,
SHA256: item.FileSha256.String,
FilePath: item.FilePath,
}
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
// holding a converted format resolves via the sidecar the same way it
// would via BookResolver on the server.
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
if ferr == nil {
entry := books[key]
for _, f := range formats {
if f.FileSha256.Valid && f.FileSha256.String != "" {
if _, exists := books[f.FileSha256.String]; !exists {
books[f.FileSha256.String] = entry
}
}
}
}
}
// Get collections
collections, err := h.db.GetCollectionsByUser(ctx, pgUserID)
if err != nil {
// Non-fatal error, continue with empty collections
collections = []database.Collections{}
}
// Build collections array
sidecarCollections := []SidecarCollection{}
for _, collection := range collections {
// Get collection items
collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID)
if err != nil {
continue
}
bookIDs := make([]string, len(collectionItems))
for i, item := range collectionItems {
bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String()
}
// Check for device shelf mapping
shelfMapping := collection.Name
mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{
DeviceID: pgDeviceID,
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
})
if err == nil && mapping.DeviceShelfName.Valid {
shelfMapping = mapping.DeviceShelfName.String
}
sidecarCollections = append(sidecarCollections, SidecarCollection{
Name: collection.Name,
ShelfMapping: shelfMapping,
BookIDs: bookIDs,
})
}
// Build sidecar config
sidecarConfig := SidecarConfig{
Version: "1.0",
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
Books: books,
Collections: sidecarCollections,
OPDSEnabled: true,
SidecarEnabled: true,
LastUpdated: time.Now().Format(time.RFC3339),
}
return c.JSON(http.StatusOK, sidecarConfig)
}
// DownloadSidecarConfig generates a .bookhoard.json file for device setup
// GET /api/devices/:device_id/sidecar/download
func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid device ID",
})
}
ctx := c.Request().Context()
pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true}
// Get device info to get device name
device, err := h.db.GetDevice(ctx, pgDeviceID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "device not found",
})
}
// Generate sidecar config
var sidecarConfig SidecarConfig
// Reuse GetSidecarConfig logic by building config inline
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch media items",
})
}
// Build books map
books := make(map[string]SidecarBook)
for _, item := range mediaItems {
bookUUID := uuid.UUID(item.ID.Bytes).String()
key := bookUUID
if item.FileSha256.Valid && item.FileSha256.String != "" {
key = item.FileSha256.String
}
availableFormats := []string{"epub"}
if item.MimeType.Valid {
if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" {
availableFormats = append(availableFormats, "kepub")
}
}
author := ""
if item.Author.Valid {
author = item.Author.String
}
books[key] = SidecarBook{
BookhoardUUID: bookUUID,
Title: item.Title,
Author: author,
AvailableFormats: availableFormats,
SHA256: item.FileSha256.String,
FilePath: item.FilePath,
}
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
// holding a converted format resolves via the sidecar the same way it
// would via BookResolver on the server.
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
if ferr == nil {
entry := books[key]
for _, f := range formats {
if f.FileSha256.Valid && f.FileSha256.String != "" {
if _, exists := books[f.FileSha256.String]; !exists {
books[f.FileSha256.String] = entry
}
}
}
}
}
// Get collections
collections, err := h.db.GetCollectionsByUser(ctx, pgUserID)
if err != nil {
collections = []database.Collections{}
}
// Build collections array
sidecarCollections := []SidecarCollection{}
for _, collection := range collections {
collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID)
if err != nil {
continue
}
bookIDs := make([]string, len(collectionItems))
for i, item := range collectionItems {
bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String()
}
shelfMapping := collection.Name
mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{
DeviceID: pgDeviceID,
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
})
if err == nil && mapping.DeviceShelfName.Valid {
shelfMapping = mapping.DeviceShelfName.String
}
sidecarCollections = append(sidecarCollections, SidecarCollection{
Name: collection.Name,
ShelfMapping: shelfMapping,
BookIDs: bookIDs,
})
}
sidecarConfig = SidecarConfig{
Version: "1.0",
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
Books: books,
Collections: sidecarCollections,
OPDSEnabled: true,
SidecarEnabled: true,
LastUpdated: time.Now().Format(time.RFC3339),
}
// Marshal to JSON with pretty formatting
configJSON, err := json.MarshalIndent(sidecarConfig, "", " ")
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to generate config",
})
}
// Set headers for file download
filename := fmt.Sprintf("%s.bookhoard.json", sanitizeFilename(device.DeviceName))
c.Response().Header().Set("Content-Type", "application/json")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
return c.JSONBlob(http.StatusOK, configJSON)
}
// GetSystemConfiguration returns system-wide configuration
// GET /api/system/config
func (h *SidecarHandler) GetSystemConfiguration(c *echo.Context) error {
ctx := c.Request().Context()
// Get all system config
configs, err := h.db.GetAllSystemConfig(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch system config",
})
}
// Build config map
result := make(map[string]string)
for _, systemConfig := range configs {
result[systemConfig.Key] = systemConfig.Value
}
return c.JSON(http.StatusOK, result)
}
// UpdateSystemConfiguration updates system-wide configuration
// PUT /api/system/config
func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
}
userInfo := user.(database.Users)
if userInfo.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "admin access required",
})
}
var req map[string]string
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
})
}
ctx := c.Request().Context()
pgUserID := pgtype.UUID{Bytes: userInfo.ID.Bytes, Valid: true}
// Update each config value
for key, value := range req {
if key == "default_timezone" {
if _, err := time.LoadLocation(value); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid timezone",
})
}
err := h.db.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: value,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update default timezone",
})
}
if h.settings != nil {
h.settings.Reload(ctx)
}
continue
}
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: key,
Value: value,
UpdatedBy: pgUserID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to update config key: %s", key),
})
}
}
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
derivedConfigs := map[string]string{
"opds_base_url": newBaseURL + "/opds",
"api_base_url": newBaseURL + "/api",
}
for derivedKey, derivedValue := range derivedConfigs {
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: derivedKey,
Value: derivedValue,
UpdatedBy: pgUserID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
})
}
}
// Invalidate setup status cache so the middleware picks up the new
// base_url immediately (setup is not complete until base_url is set).
setupstatus.Invalidate()
}
// Check for HTMX request
if c.Request().Header.Get("HX-Request") == "true" {
// Fetch updated base_url for template
baseURL, err := h.db.GetSystemConfig(ctx, "base_url")
if err != nil || baseURL.Value == "" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
}
defaultTimezone := "UTC"
tz, err := h.db.GetSystemTimezone(ctx)
if err == nil && tz != "" {
defaultTimezone = tz
}
// Render success message with updated form
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
</div>
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Base URL</label>
<input
type="url"
name="base_url"
value="%s"
placeholder="https://books.example.com"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
required
/>
<p class="text-sm mt-1" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com)</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
Save Settings
</button>
</div>
</div>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="UTC"%s>UTC (UTC+0)</option>
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
<option value="Europe/London"%s>British (UTC+0/+1)</option>
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
</select>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
Save Settings
</button>
</div>
</div>
</form>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">URL Paths</h3>
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
<p><strong>OPDS:</strong> %s/opds</p>
<p><strong>API:</strong> %s/api</p>
<p><strong>Device Sync:</strong> %s/api/sync</p>
</div>
</div>
`, baseURL.Value,
selectedAttr(defaultTimezone, "UTC"),
selectedAttr(defaultTimezone, "Pacific/Honolulu"),
selectedAttr(defaultTimezone, "America/Anchorage"),
selectedAttr(defaultTimezone, "America/Los_Angeles"),
selectedAttr(defaultTimezone, "America/Denver"),
selectedAttr(defaultTimezone, "America/Phoenix"),
selectedAttr(defaultTimezone, "America/Chicago"),
selectedAttr(defaultTimezone, "America/New_York"),
selectedAttr(defaultTimezone, "America/Sao_Paulo"),
selectedAttr(defaultTimezone, "Europe/London"),
selectedAttr(defaultTimezone, "Europe/Paris"),
selectedAttr(defaultTimezone, "Europe/Helsinki"),
selectedAttr(defaultTimezone, "Europe/Moscow"),
selectedAttr(defaultTimezone, "Asia/Tehran"),
selectedAttr(defaultTimezone, "Asia/Dubai"),
selectedAttr(defaultTimezone, "Asia/Karachi"),
selectedAttr(defaultTimezone, "Asia/Kolkata"),
selectedAttr(defaultTimezone, "Asia/Dhaka"),
selectedAttr(defaultTimezone, "Asia/Bangkok"),
selectedAttr(defaultTimezone, "Asia/Shanghai"),
selectedAttr(defaultTimezone, "Asia/Tokyo"),
selectedAttr(defaultTimezone, "Australia/Darwin"),
selectedAttr(defaultTimezone, "Australia/Sydney"),
selectedAttr(defaultTimezone, "Pacific/Auckland"),
baseURL.Value, baseURL.Value, baseURL.Value))
}
return c.JSON(http.StatusOK, map[string]string{
"status": "success",
"message": "System configuration updated",
})
}
func sanitizeFilename(name string) string {
// Simple sanitization - replace problematic characters
sanitized := name
for _, ch := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} {
sanitized = sanitizeAll(sanitized, ch, "_")
}
return sanitized
}
func sanitizeAll(s string, old string, new string) string {
result := ""
for _, ch := range s {
c := string(ch)
if c == old {
result += new
} else {
result += c
}
}
return result
}
func selectedAttr(current, value string) string {
if current == value {
return " selected"
}
return ""
}