Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.
system_settings.go:
- GET /api/system/settings returns every known setting with full
metadata (value, type, min, max, requires_restart, category, group,
description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
up the compiled Default for the key, runs type-aware validation
(int range, bool parse, non-empty string, timezone via
time.LoadLocation), upserts via UpsertSystemSetting, reloads the
registry, and reports whether a restart is needed for the change to
take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
now reload the registry after writing and prefer the registry when
reading, so the cache stays consistent regardless of entry point.
sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
UpdateSystemConfiguration (PUT /api/system/config) calls
settings.Reload() after the write so the new value is visible
immediately. base_url handling is unchanged.
system.go:
- Register GET/PUT /api/system/settings under the existing admin
/api/system group.
601 lines
19 KiB
Go
601 lines
19 KiB
Go
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, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
|
|
}
|
|
|
|
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(`
|
|
<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>
|
|
<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-6" style="color: var(--text-primary)">System Defaults</h3>
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
|
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
|
<option value="UTC"%s>UTC (UTC+0)</option>
|
|
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
|
|
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
|
|
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
|
|
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
|
|
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
|
|
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
|
|
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
|
|
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
|
|
<option value="Europe/London"%s>British (UTC+0/+1)</option>
|
|
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
|
|
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
|
|
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
|
|
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
|
|
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
|
|
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
|
|
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
|
|
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
|
|
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
|
|
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
|
|
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
|
|
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
|
|
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
|
|
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
|
|
</select>
|
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</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,
|
|
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 ""
|
|
}
|