The sidecar config's books map is keyed by the primary SHA-256 (UUID fallback). A device holding a converted format (KEPUB/PDF) whose hash lives only in media_item_formats could not resolve its file through the sidecar. After inserting the primary-keyed entry, also register the same entry under each per-format hash from media_item_formats (first write wins, so a primary hash is never shadowed). Devices now resolve converted files via the sidecar the same way the server's BookResolver does. Applied to both the GET and download sidecar builders.
631 lines
20 KiB
Go
631 lines
20 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,
|
|
}
|
|
|
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
|
// holding a converted format resolves via the sidecar the same way it
|
|
// would via BookResolver on the server.
|
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
|
if ferr == nil {
|
|
entry := books[key]
|
|
for _, f := range formats {
|
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
|
if _, exists := books[f.FileSha256.String]; !exists {
|
|
books[f.FileSha256.String] = entry
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
|
// holding a converted format resolves via the sidecar the same way it
|
|
// would via BookResolver on the server.
|
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
|
if ferr == nil {
|
|
entry := books[key]
|
|
for _, f := range formats {
|
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
|
if _, exists := books[f.FileSha256.String]; !exists {
|
|
books[f.FileSha256.String] = entry
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 ""
|
|
}
|