Files
bookhoard/internal/handlers/sidecar.go
T
john-okeefe 885f6d8187 feat(api): unified tunable settings endpoints with typed validation
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.

system_settings.go:
- GET /api/system/settings returns every known setting with full
  metadata (value, type, min, max, requires_restart, category, group,
  description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
  up the compiled Default for the key, runs type-aware validation
  (int range, bool parse, non-empty string, timezone via
  time.LoadLocation), upserts via UpsertSystemSetting, reloads the
  registry, and reports whether a restart is needed for the change to
  take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
  now reload the registry after writing and prefer the registry when
  reading, so the cache stays consistent regardless of entry point.

sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
  UpdateSystemConfiguration (PUT /api/system/config) calls
  settings.Reload() after the write so the new value is visible
  immediately. base_url handling is unchanged.

system.go:
- Register GET/PUT /api/system/settings under the existing admin
  /api/system group.
2026-08-10 08:02:22 -04:00

601 lines
19 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,
}
}
// 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,
}
}
// 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 ""
}