Replace direct error equality checks (err == pgx.ErrNoRows, err != http.ErrServerClosed) with the idiomatic errors.Is() function throughout handlers, services, middleware, and app startup. This correctly handles wrapped error chains. Also replace a raw type assertion (*HTTPError) with errors.AsType[*HTTPError]() in the error handler middleware for consistency. Additionally, rename shadowed variables for clarity: - sidecar.go: config -> sidecarConfig, systemConfig (shadowed package-level vars) - media_scanner.go: uuid -> uuidString (shadowed the uuid package import)
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"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
|
|
}
|
|
|
|
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
|
return &SidecarHandler{db: db, cfg: cfg}
|
|
}
|
|
|
|
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
|
|
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", 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)
|
|
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.Value + "/opds",
|
|
APIBaseURL: baseURL.Value + "/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
|
|
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", 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)
|
|
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.Value + "/opds",
|
|
APIBaseURL: baseURL.Value + "/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 {
|
|
_, 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),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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, 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
|
|
}
|