Construct the SettingsRegistry at boot, load it, and thread it through
every consumer so the configurable values take effect and stay cached.
cmd/server/main.go:
- Build the registry from the Queries handle and Load() it right after
schema init; a load failure logs and continues (getters fall back to
compiled defaults, so startup is never blocked).
- Wire the registry into the package-level password validator
(SetDefaultPasswordSettings) and call SetSettings on every handler/
service that reads tunables: AuthHandler, DeviceAuthMiddleware,
OPDSHandler, SidecarHandler, SystemSettingsHandler,
AnnotationService, ConversionService.
- Source the restart-time values from the registry: login lockout
(max attempts + duration) feeds NewLoginAttemptTracker, and the new
NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync
queue and worker pool configs.
router.go:
- Config gains a Settings *database.SettingsRegistry field.
- The global auth rate limiter now reads RequestsPerMinute from
registry.AuthRateLimit() (env stays as the enabled/disabled switch
and as the fallback if the registry is unset).
admin_library.go:
- The HTMX scan-settings save endpoint reloads the registry after
writing so the change is visible without a page reload.
- Add PUT /admin/settings/tunable: a small HTMX endpoint that calls
SystemSettingsHandler.ApplySetting and returns a colored status
snippet ("Saved" or "Saved — restart required") for the admin UI's
per-row forms.
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package router
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/templates"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
|
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
|
|
|
// HTMX: Create library
|
|
g.POST("/admin/library/create", func(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
|
|
name := c.FormValue("name")
|
|
desc := c.FormValue("description")
|
|
libType := c.FormValue("type")
|
|
if name == "" || libType == "" {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
|
|
}
|
|
|
|
_, err := cfg.LibraryService.CreateLibrary(
|
|
c.Request().Context(),
|
|
name,
|
|
desc,
|
|
libType,
|
|
user.ID,
|
|
)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
|
|
}
|
|
|
|
return renderLibraryList(c, cfg)
|
|
})
|
|
|
|
// HTMX: Update library
|
|
g.PUT("/admin/library/:id", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
name := c.FormValue("name")
|
|
desc := c.FormValue("description")
|
|
if name == "" {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
|
|
}
|
|
|
|
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
|
|
}
|
|
|
|
return renderLibraryList(c, cfg)
|
|
})
|
|
|
|
// HTMX: Delete library
|
|
g.DELETE("/admin/library/:id", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
|
|
}
|
|
|
|
return renderLibraryList(c, cfg)
|
|
})
|
|
|
|
// HTMX: Library expanded panel
|
|
g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
})
|
|
|
|
// HTMX: Add folder
|
|
g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
folderPath := c.FormValue("folder_path")
|
|
if folderPath == "" {
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
}
|
|
|
|
if strings.Contains(folderPath, "..") {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
|
|
}
|
|
|
|
cleanPath := filepath.Clean(folderPath)
|
|
fileInfo, err := os.Stat(cleanPath)
|
|
if err != nil {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
|
|
}
|
|
if !fileInfo.IsDir() {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
|
|
}
|
|
|
|
_, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
|
|
if err != nil {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
|
|
}
|
|
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
})
|
|
|
|
// HTMX: Remove folder
|
|
g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
folderPath := c.FormValue("folder_path")
|
|
if folderPath == "" {
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
}
|
|
|
|
err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
|
|
if err != nil {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
|
|
}
|
|
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
})
|
|
|
|
// HTMX: Folder browser
|
|
g.GET("/admin/library/browse", func(c *echo.Context) error {
|
|
path := c.QueryParam("path")
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
targetInput := c.QueryParam("target_input")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
|
|
}
|
|
|
|
entries := make([]templates.DirEntry, len(dirs))
|
|
for i, d := range dirs {
|
|
fullPath := filepath.Join(currentPath, d)
|
|
entries[i] = templates.DirEntry{Name: d, Path: fullPath}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// HTMX: Set user visibility for library
|
|
g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
|
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
|
}
|
|
|
|
userIDStr := c.FormValue("user_id")
|
|
isVisible := c.FormValue("is_visible") == "true"
|
|
|
|
userID, err := parseAdminUUID(userIDStr)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
|
|
}
|
|
|
|
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
|
|
}
|
|
|
|
return renderLibraryPanel(c, cfg, libraryID)
|
|
})
|
|
}
|
|
|
|
// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
|
|
func renderLibraryList(c *echo.Context, cfg *Config) error {
|
|
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
|
|
}
|
|
|
|
libData := make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
|
|
libData[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: getText(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
TypeValue: lib.TypeName,
|
|
FolderCount: folderCount,
|
|
}
|
|
}
|
|
|
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListUsers failed: %v", err)
|
|
users = []database.ListUsersRow{}
|
|
}
|
|
userData := make([]templates.User, len(users))
|
|
for i, u := range users {
|
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
|
userData[i] = templates.User{
|
|
ID: userUUID.String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}
|
|
|
|
// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
|
|
func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
|
|
return renderLibraryPanelWithError(c, cfg, libraryID, "")
|
|
}
|
|
|
|
func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
|
|
ctx := c.Request().Context()
|
|
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
|
|
|
|
// Get library details
|
|
lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
|
|
}
|
|
|
|
libData := templates.LibraryData{
|
|
ID: libraryIDStr,
|
|
Name: lib.Name,
|
|
Description: getText(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
TypeValue: lib.TypeName,
|
|
}
|
|
|
|
// Get folders
|
|
dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil {
|
|
log.Printf("GetLibraryFolders failed: %v", err)
|
|
}
|
|
folders := make([]templates.FolderData, len(dbFolders))
|
|
for i, f := range dbFolders {
|
|
folders[i] = templates.FolderData{FolderPath: f.FolderPath}
|
|
}
|
|
libData.FolderCount = len(folders)
|
|
|
|
// Get users
|
|
dbUsers, err := cfg.Queries.ListUsers(ctx)
|
|
if err != nil {
|
|
log.Printf("ListUsers failed: %v", err)
|
|
dbUsers = []database.ListUsersRow{}
|
|
}
|
|
userData := make([]templates.User, len(dbUsers))
|
|
for i, u := range dbUsers {
|
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
|
userData[i] = templates.User{
|
|
ID: userUUID.String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
}
|
|
}
|
|
|
|
// Get visibility for all users
|
|
visibility := make([]templates.UserVisibilityData, len(userData))
|
|
for i, u := range userData {
|
|
userUUID, _ := parseAdminUUID(u.ID)
|
|
visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
}
|
|
isVisible := false
|
|
for _, vl := range visibleLibs {
|
|
if vl.ID.Bytes == libraryID.Bytes {
|
|
isVisible = true
|
|
break
|
|
}
|
|
}
|
|
visibility[i] = templates.UserVisibilityData{
|
|
UserID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
IsVisible: isVisible,
|
|
}
|
|
}
|
|
|
|
// Get issue count
|
|
issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
|
|
if err != nil {
|
|
log.Printf("GetProcessingIssueStats failed: %v", err)
|
|
}
|
|
issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
|
|
|
|
// Get current user for template
|
|
tmplUser := templates.User{}
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
|
tmplUser = templates.User{
|
|
ID: userUUID.String(),
|
|
Username: u.Username,
|
|
Role: u.Role,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
html := buf.String()
|
|
if errMsg != "" {
|
|
html = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + html
|
|
}
|
|
return c.HTML(http.StatusOK, html)
|
|
}
|
|
|
|
func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
|
|
folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(folders)
|
|
}
|
|
|
|
func parseAdminUUID(s string) (pgtype.UUID, error) {
|
|
parsed, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
|
|
}
|
|
|
|
func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
|
|
stats := templates.AdminStats{}
|
|
|
|
libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
|
|
stats.LibraryCount = len(libs)
|
|
|
|
users, _ := cfg.Queries.ListUsers(ctx)
|
|
stats.UserCount = len(users)
|
|
|
|
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
|
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
|
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
|
|
|
g.PUT("/admin/settings/scan", func(c *echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
|
|
autoScan := c.FormValue("auto_scan_enabled") == "true"
|
|
intervalStr := c.FormValue("scan_poll_interval_seconds")
|
|
interval, err := strconv.Atoi(intervalStr)
|
|
if err != nil || interval < 1 || interval > 3600 {
|
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Interval must be between 1 and 3600 seconds</div>`)
|
|
}
|
|
|
|
autoScanStr := "false"
|
|
if autoScan {
|
|
autoScanStr = "true"
|
|
}
|
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
|
SettingKey: "auto_scan_enabled",
|
|
SettingValue: autoScanStr,
|
|
})
|
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
|
SettingKey: "scan_poll_interval_seconds",
|
|
SettingValue: strconv.Itoa(interval),
|
|
})
|
|
|
|
// Refresh the registry cache so the change is visible immediately.
|
|
if cfg.Settings != nil {
|
|
cfg.Settings.Reload(ctx)
|
|
}
|
|
|
|
scanSettings := templates.ScanSettingsData{
|
|
AutoScanEnabled: autoScan,
|
|
ScanPollIntervalSeconds: interval,
|
|
}
|
|
var buf bytes.Buffer
|
|
_ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// HTMX endpoint for saving a single tunable setting. Returns a small HTML
|
|
// status snippet rendered into the row's status span.
|
|
g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
key := c.FormValue("key")
|
|
value := c.FormValue("value")
|
|
|
|
if cfg.SystemSettingsHandler == nil {
|
|
return c.HTML(http.StatusServiceUnavailable, `<span style="color: var(--status-danger);">settings unavailable</span>`)
|
|
}
|
|
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, err.Error()))
|
|
}
|
|
|
|
color := "var(--status-success)"
|
|
msg := "Saved"
|
|
if resp.ReloadRequired {
|
|
color = "var(--status-warning)"
|
|
msg = "Saved — restart required"
|
|
}
|
|
return c.HTML(http.StatusOK, fmt.Sprintf(`<span style="color: %s;">%s</span>`, color, msg))
|
|
})
|
|
}
|