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
import (
"context"
"fmt"
"os"
"strconv"
@@ -44,6 +45,30 @@ func (c *Config) DatabaseURL() string {
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 {
if value := os.Getenv(key); 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) {
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
if err != nil {
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
}
opdsBaseURL, err := h.db.GetSystemConfig(c.Request().Context(), "opds_base_url")
if err != nil {
return "", "", fmt.Errorf("failed to get opds_base_url from config: %w", err)
}
return baseURL.Value, opdsBaseURL.Value, nil
opdsBaseURL := baseURL.Value + "/opds"
return baseURL.Value, opdsBaseURL, nil
}
// GetDeviceCatalog returns the OPDS catalog feed for a device
+73 -17
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"encoding/json"
"fmt"
@@ -13,11 +14,12 @@ import (
)
type SidecarHandler struct {
db *database.Queries
db *database.Queries
cfg *config.Config
}
func NewSidecarHandler(db *database.Queries) *SidecarHandler {
return &SidecarHandler{db: db}
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db, cfg: cfg}
}
type SidecarConfig struct {
@@ -79,13 +81,15 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get system config
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url")
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url")
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -172,8 +176,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: opdsBaseURL.Value,
APIBaseURL: apiBaseURL.Value,
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
@@ -215,13 +219,15 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get system config
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url")
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url")
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -303,8 +309,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: opdsBaseURL.Value,
APIBaseURL: apiBaseURL.Value,
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(),
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{
"status": "success",
"message": "System configuration updated",