config: simplify to single base_url with computed paths

Refactor configuration to use one source of truth for base URL

- Add GetBaseURL() to config package: queries system_config table first,
  falls back to BASE_URL env var
- Update SidecarHandler to accept config and use single base_url
- Compute opds/api paths from base_url instead of storing separately:
  - OPDS: base_url + /opds
  - API: base_url + /api
  - Device Sync: base_url + /api/sync
- Simplify OPDSHandler.getBaseURLs() to compute opds path
- Remove need for separate opds_base_url and api_base_url columns

Previously the system stored three separate URL config values that were
usually the same domain with different paths. Now store only base_url
and compute the paths, eliminating configuration redundancy.
This commit is contained in:
2026-03-11 16:42:21 -04:00
parent c1b664dbe5
commit 5a61d9e321
3 changed files with 101 additions and 24 deletions
+25
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@@ -44,6 +45,30 @@ func (c *Config) DatabaseURL() string {
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName) c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
} }
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
func GetBaseURL(ctx context.Context, db interface{}) string {
// Try to get from database first
type SystemConfigQuerier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
}
if querier, ok := db.(SystemConfigQuerier); ok {
config, err := querier.GetSystemConfig(ctx, "base_url")
if err == nil && config.Value != "" {
return config.Value
}
}
// Fallback: return empty string - caller should use their own fallback
return ""
}
// SystemConfigRow represents a system configuration row
type SystemConfigRow struct {
Key string
Value string
}
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value return value
+3 -7
View File
@@ -38,19 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
} }
} }
// Helper function to get base URLs from system config // Helper function to get base URL from system config
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) { func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url") baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to get base_url from config: %w", err) return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
} }
opdsBaseURL, err := h.db.GetSystemConfig(c.Request().Context(), "opds_base_url") opdsBaseURL := baseURL.Value + "/opds"
if err != nil { return baseURL.Value, opdsBaseURL, nil
return "", "", fmt.Errorf("failed to get opds_base_url from config: %w", err)
}
return baseURL.Value, opdsBaseURL.Value, nil
} }
// GetDeviceCatalog returns the OPDS catalog feed for a device // GetDeviceCatalog returns the OPDS catalog feed for a device
+72 -16
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -14,10 +15,11 @@ import (
type SidecarHandler struct { type SidecarHandler struct {
db *database.Queries db *database.Queries
cfg *config.Config
} }
func NewSidecarHandler(db *database.Queries) *SidecarHandler { func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db} return &SidecarHandler{db: db, cfg: cfg}
} }
type SidecarConfig struct { type SidecarConfig struct {
@@ -79,13 +81,15 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true} pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get system config // Get base URL and compute paths
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url") baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url") if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs // Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String()) opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value) syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items // Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -172,8 +176,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{ Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL, OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL, SyncAPI: syncAPIURL,
OPDSBaseURL: opdsBaseURL.Value, OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: apiBaseURL.Value, APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(), DeviceID: deviceID.String(),
DeviceToken: device.AuthToken, DeviceToken: device.AuthToken,
}, },
@@ -215,13 +219,15 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true} pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get system config // Get base URL and compute paths
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url") baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url") if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs // Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String()) opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value) syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items // Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -303,8 +309,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{ Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL, OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL, SyncAPI: syncAPIURL,
OPDSBaseURL: opdsBaseURL.Value, OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: apiBaseURL.Value, APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(), DeviceID: deviceID.String(),
DeviceToken: device.AuthToken, DeviceToken: device.AuthToken,
}, },
@@ -394,6 +400,56 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
} }
} }
// 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>`)
}
// 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>
</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, baseURL.Value, baseURL.Value))
}
return c.JSON(http.StatusOK, map[string]string{ return c.JSON(http.StatusOK, map[string]string{
"status": "success", "status": "success",
"message": "System configuration updated", "message": "System configuration updated",