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, `
Failed to fetch updated configuration
`) } 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(`

✅ Settings saved successfully!

Base URL

The public URL of your Bookhoard instance (e.g., https://books.example.com)

System Defaults

Default timezone for users who haven't set their own.

URL Paths

OPDS: %s/opds

API: %s/api

Device Sync: %s/api/sync

`, 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 "" }