fix: OPDS base_url placeholder bug + setup gate requires base_url
Three bugs fixed: 1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'. Removed seed; startup now seeds from BASE_URL env var only if DB row is empty (admin changes persist across restarts). One-time UPDATE clears the placeholder in existing installs. 2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow vs database.SystemConfig) that always failed, returning . Admin panel showed env var fallback instead of actual DB value. Fixed with a function-type getter that properly wraps the DB query. 3. OPDS handler read base_url only from DB with no fallback. When DB had the placeholder, all feed links pointed to an unreachable domain, breaking KOReader search/download. Added deriveBaseURL() helper that falls back to the request Host/scheme when DB value is empty. Setup gate improvements: - isSetupComplete now requires both admin user AND non-empty base_url - Setup middleware no longer exempts all /api/ routes; only allows /api/auth/register, /api/auth/login, /api/system/config before setup is complete. All other API routes get 503. - Cache invalidated when base_url is saved via admin settings Dev workflow: - New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup - NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
info:
|
||||||
|
name: SetBaseUrl
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/system/config'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: |-
|
||||||
|
{
|
||||||
|
"base_url": "http://localhost:8765"
|
||||||
|
}
|
||||||
|
headers:
|
||||||
|
- key: Authorization
|
||||||
|
value: Bearer {{token}}
|
||||||
|
- key: Content-Type
|
||||||
|
value: application/json
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Set Base URL
|
||||||
|
|
||||||
|
Configures the server's base_url during initial dev database setup.
|
||||||
|
|
||||||
|
Must be run after RegisterUser (which provides the auth token) and before
|
||||||
|
any library/device creation (which require setup to be complete).
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
**Endpoint:** /api/system/config
|
||||||
|
**Auth:** Bearer token (from RegisterUser)
|
||||||
@@ -48,6 +48,35 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||||
|
|
||||||
|
// Seed base_url from env var if not already configured. Uses conditional
|
||||||
|
// UPDATE so admin-set values are never overwritten on restart.
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
_, err = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ('base_url', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Could not seed base_url: %v", err)
|
||||||
|
} else {
|
||||||
|
// Also seed derived URLs
|
||||||
|
for key, suffix := range map[string]string{
|
||||||
|
"opds_base_url": "/opds",
|
||||||
|
"api_base_url": "/api",
|
||||||
|
} {
|
||||||
|
_, _ = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, key, cfg.BaseURL+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
||||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||||||
|
|
||||||
|
|||||||
@@ -1149,12 +1149,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Pre-seeded values
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||||
INSERT INTO system_config (key, value) VALUES
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||||
('base_url', 'https://bookhoard.example.com'),
|
UPDATE system_config SET value = ''
|
||||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||||
('api_base_url', 'https://bookhoard.example.com/api')
|
UPDATE system_config SET value = ''
|
||||||
ON CONFLICT (key) DO NOTHING;
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||||
|
UPDATE system_config SET value = ''
|
||||||
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||||
|
|
||||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||||
|
|||||||
@@ -45,30 +45,19 @@ 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
|
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||||
// Try to get from database first
|
|
||||||
type SystemConfigQuerier interface {
|
|
||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
// string if not set. The getter abstraction avoids importing the database package.
|
||||||
if err == nil && config.Value != "" {
|
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||||
return config.Value
|
val, err := getter(ctx, "base_url")
|
||||||
}
|
if err == nil && val != "" {
|
||||||
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty string - caller should use their own fallback
|
|
||||||
return ""
|
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
|
||||||
|
|||||||
@@ -38,15 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get base URL from system config
|
// Helper function to get base URL from system config with request-derived fallback
|
||||||
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")
|
var dbBaseURL string
|
||||||
if err != nil {
|
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
dbBaseURL = config.Value
|
||||||
}
|
}
|
||||||
|
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||||
opdsBaseURL := baseURL.Value + "/opds"
|
opdsBaseURL := baseURL + "/opds"
|
||||||
return baseURL.Value, opdsBaseURL, nil
|
return baseURL, opdsBaseURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -81,15 +82,13 @@ 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 base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -176,8 +175,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -219,15 +218,13 @@ 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 base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -309,8 +306,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -434,6 +431,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Check for HTMX request
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deriveBaseURL returns the base URL to use for constructing self-referential
|
||||||
|
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
|
||||||
|
// base_url when available, and falls back to deriving the URL from the incoming
|
||||||
|
// HTTP request (Host header + scheme), which is always reachable by the client.
|
||||||
|
//
|
||||||
|
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
|
||||||
|
// that deployments behind TLS-terminating reverse proxies advertise the correct
|
||||||
|
// external URL.
|
||||||
|
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
|
||||||
|
if dbBaseURL != "" {
|
||||||
|
return strings.TrimRight(dbBaseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request().TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.Request().Host
|
||||||
|
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
|
||||||
|
host = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/config"
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
@@ -734,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
@@ -995,10 +991,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch current system configuration - just base_url
|
// Fetch current system configuration - just base_url
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
systemConfig := map[string]string{
|
systemConfig := map[string]string{
|
||||||
"base_url": baseURL,
|
"base_url": baseURL,
|
||||||
@@ -1086,10 +1079,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
|
|||||||
@@ -72,6 +72,24 @@ type Config struct {
|
|||||||
LibraryService *services.LibraryService
|
LibraryService *services.LibraryService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
|
// the env var / config default. Uses a closure to adapt the database query to
|
||||||
|
// config.SystemConfigGetter.
|
||||||
|
func (cfg *Config) getBaseURL(ctx context.Context) string {
|
||||||
|
getter := func(ctx context.Context, key string) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
baseURL := config.GetBaseURL(ctx, getter)
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = cfg.Cfg.BaseURL
|
||||||
|
}
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
|
||||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||||
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||||
return echojwt.WithConfig(echojwt.Config{
|
return echojwt.WithConfig(echojwt.Config{
|
||||||
|
|||||||
+40
-10
@@ -14,7 +14,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func isSetupComplete(cfg *Config) bool {
|
func isSetupComplete(cfg *Config) bool {
|
||||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
getter := func(ctx context.Context) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
|
||||||
|
// initial setup is complete so the server can be configured via API.
|
||||||
|
var setupAllowedAPIRoutes = []string{
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/system/config",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedDuringSetup reports whether a request path should bypass the setup
|
||||||
|
// gate. This includes the setup page itself, static assets, health checks, and
|
||||||
|
// the minimal set of API routes needed to perform initial configuration.
|
||||||
|
func isAllowedDuringSetup(path string) bool {
|
||||||
|
if path == "/setup" || path == "/setup/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, route := range setupAllowedAPIRoutes {
|
||||||
|
if path == route || strings.HasPrefix(path, route+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||||
@@ -22,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
|||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
path := c.Request().URL.Path
|
path := c.Request().URL.Path
|
||||||
|
|
||||||
if path == "/setup" || path == "/setup/" {
|
if isAllowedDuringSetup(path) {
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api/") {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSetupComplete(cfg) {
|
if !isSetupComplete(cfg) {
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
|
||||||
|
})
|
||||||
|
}
|
||||||
return c.Redirect(http.StatusFound, "/setup")
|
return c.Redirect(http.StatusFound, "/setup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Package setupstatus reports whether the application's initial setup has been
|
// Package setupstatus reports whether the application's initial setup has been
|
||||||
// completed. Setup is considered complete as soon as at least one admin user
|
// completed. Setup is considered complete when at least one admin user exists
|
||||||
// exists, regardless of how that user was created (setup wizard, API, or a
|
// AND a non-empty base_url has been configured, regardless of how those were
|
||||||
// future CLI). This keeps the setup gate a derived property of real data
|
// created (setup wizard, API, or a future CLI). This keeps the setup gate a
|
||||||
// rather than a manually-flipped flag that can drift out of sync.
|
// derived property of real data rather than a manually-flipped flag that can
|
||||||
|
// drift out of sync.
|
||||||
package setupstatus
|
package setupstatus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,6 +19,12 @@ type AdminCounter interface {
|
|||||||
CountAdmins(ctx context.Context) (int64, error)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BaseURLGetter returns the configured base_url value from the database, or an
|
||||||
|
// error if it cannot be read. Defined as a function type (not an interface) so
|
||||||
|
// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig
|
||||||
|
// without importing the database package.
|
||||||
|
type BaseURLGetter func(ctx context.Context) (string, error)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheComplete bool = true
|
cacheComplete bool = true
|
||||||
@@ -26,10 +33,11 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||||
// least one admin user exists. A short in-memory cache avoids hammering the
|
// least one admin user exists AND base_url is configured. A short in-memory
|
||||||
// database on every request. On a database error the function fails open
|
// cache avoids hammering the database on every request. On a database error the
|
||||||
// (returns true) so a transient outage does not lock users out of the app.
|
// function fails open (returns true) so a transient outage does not lock users
|
||||||
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
// out of the app.
|
||||||
|
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
|
||||||
cacheMu.RLock()
|
cacheMu.RLock()
|
||||||
if time.Now().Before(cacheExpiry) {
|
if time.Now().Before(cacheExpiry) {
|
||||||
complete := cacheComplete
|
complete := cacheComplete
|
||||||
@@ -38,12 +46,20 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
|||||||
}
|
}
|
||||||
cacheMu.RUnlock()
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
count, err := q.CountAdmins(ctx)
|
|
||||||
complete := true
|
complete := true
|
||||||
|
|
||||||
|
count, err := q.CountAdmins(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
complete = count > 0
|
complete = count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if complete && baseURLGetter != nil {
|
||||||
|
baseURL, err := baseURLGetter(ctx)
|
||||||
|
if err == nil {
|
||||||
|
complete = baseURL != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cacheMu.Lock()
|
cacheMu.Lock()
|
||||||
cacheComplete = complete
|
cacheComplete = complete
|
||||||
cacheExpiry = time.Now().Add(cacheTTL)
|
cacheExpiry = time.Now().Add(cacheTTL)
|
||||||
@@ -53,7 +69,8 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
|||||||
|
|
||||||
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
||||||
// re-reads from the database. Call this after any write that could change the
|
// re-reads from the database. Call this after any write that could change the
|
||||||
// admin user count (user creation, role promotion/demotion, user deletion).
|
// admin user count (user creation, role promotion/demotion, user deletion) or
|
||||||
|
// the base_url configuration.
|
||||||
func Invalidate() {
|
func Invalidate() {
|
||||||
cacheMu.Lock()
|
cacheMu.Lock()
|
||||||
cacheComplete = true
|
cacheComplete = true
|
||||||
|
|||||||
Reference in New Issue
Block a user