Move three more hardcoded values behind the settings registry. All apply immediately on the next request (no restart needed). device_auth.go: - DeviceAuthMiddleware reads per-route device rate limits (sync / progress / metadata per minute) from the registry on each authenticated request via a rateLimitConfig() helper, falling back to the Default* constants when no registry is wired. - The X-RateLimit-Limit response header previously hardcoded "60" for every request type; it now reflects the actual configured limit for the request type via rateLimitForRequestType(). opds.go: - Default (50) and maximum (200) OPDS page sizes come from the registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline literals, so catalog pagination can be tuned without a redeploy. conversion_service.go: - The 24h kepub cache lifetime is read from the registry via a cacheTTL() helper (was a bare 24 * time.Hour literal in the constructor). The field default is retained for tests that construct the service directly. - conversion_service_test.go updated to assert both the field default and the cacheTTL() accessor return 24h.
965 lines
30 KiB
Go
965 lines
30 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/opds"
|
|
"bookhoard/internal/services"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type OPDSHandler struct {
|
|
db *database.Queries
|
|
libraryService *services.LibraryService
|
|
conversionService interface {
|
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
|
}
|
|
settings *database.SettingsRegistry
|
|
}
|
|
|
|
// SetSettings wires the tunable settings registry (OPDS page size).
|
|
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
|
|
|
// opdsDefaultPageSize returns the configured default page size (50 if unset).
|
|
func (h *OPDSHandler) opdsDefaultPageSize() int {
|
|
if h.settings != nil {
|
|
return h.settings.OpdsDefaultPageSize()
|
|
}
|
|
return 50
|
|
}
|
|
|
|
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
|
|
func (h *OPDSHandler) opdsMaxPageSize() int {
|
|
if h.settings != nil {
|
|
return h.settings.OpdsMaxPageSize()
|
|
}
|
|
return 200
|
|
}
|
|
|
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
|
}) *OPDSHandler {
|
|
return &OPDSHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
conversionService: conversionService,
|
|
}
|
|
}
|
|
|
|
// Helper function to get base URL from system config with request-derived fallback
|
|
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
|
var dbBaseURL string
|
|
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
|
dbBaseURL = config.Value
|
|
}
|
|
baseURL := deriveBaseURL(c, dbBaseURL)
|
|
opdsBaseURL := baseURL + "/opds"
|
|
return baseURL, opdsBaseURL, nil
|
|
}
|
|
|
|
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
|
token := c.QueryParam("token")
|
|
if token == "" {
|
|
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
|
}
|
|
return token
|
|
}
|
|
|
|
func appendToken(url, token string) string {
|
|
if token == "" {
|
|
return url
|
|
}
|
|
if strings.Contains(url, "?") {
|
|
return url + "&token=" + token
|
|
}
|
|
return url + "?token=" + token
|
|
}
|
|
|
|
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
|
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
|
|
|
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
|
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
|
// itemsPerPage, startIndex) to a feed based on the current page position.
|
|
// catalogBase is the device catalog URL without query parameters. The token
|
|
// (device auth) is appended to every generated link.
|
|
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
|
totalPages := 0
|
|
if totalItems > 0 {
|
|
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
|
}
|
|
startIdx := (pageNum - 1) * perPageNum
|
|
|
|
pagedURL := func(page int) string {
|
|
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
|
}
|
|
|
|
// self reflects the current page; start/first point to the first page
|
|
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
|
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
|
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
|
if totalPages > 0 {
|
|
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
|
}
|
|
if pageNum > 1 {
|
|
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
|
}
|
|
if pageNum < totalPages {
|
|
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
|
}
|
|
|
|
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
|
}
|
|
|
|
// resolveMimeType returns the mime type for a media item, preferring the stored
|
|
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
|
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
|
if mime.Valid && mime.String != "" {
|
|
return mime.String
|
|
}
|
|
if formatMime.Valid && formatMime.String != "" {
|
|
return formatMime.String
|
|
}
|
|
return "application/epub+zip"
|
|
}
|
|
|
|
// isComicArchive reports whether a format group represents a comic/manga
|
|
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
|
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
|
func isComicArchive(formatGroup string) bool {
|
|
return strings.EqualFold(formatGroup, "comic_archive")
|
|
}
|
|
|
|
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
|
// file path's extension, defaulting to "epub" when it cannot be determined.
|
|
func formatLabelFromPath(path string) string {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".epub":
|
|
return "epub"
|
|
case ".pdf":
|
|
return "pdf"
|
|
case ".cbz":
|
|
return "cbz"
|
|
case ".cbr":
|
|
return "cbr"
|
|
case ".cb7":
|
|
return "cb7"
|
|
case ".cbt":
|
|
return "cbt"
|
|
case ".mobi":
|
|
return "mobi"
|
|
case ".azw", ".azw3":
|
|
return "azw3"
|
|
case ".txt":
|
|
return "txt"
|
|
case "":
|
|
return "epub"
|
|
default:
|
|
return strings.TrimPrefix(ext, ".")
|
|
}
|
|
}
|
|
|
|
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
|
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
|
|
page := c.QueryParam("page")
|
|
perPage := c.QueryParam("per_page")
|
|
includeFormat := c.QueryParam("include_format")
|
|
|
|
// Parse pagination parameters
|
|
pageNum := 1
|
|
if page != "" {
|
|
if num, err := strconv.Atoi(page); err == nil && num > 0 {
|
|
pageNum = num
|
|
}
|
|
}
|
|
|
|
perPageNum := h.opdsDefaultPageSize()
|
|
maxPerPage := h.opdsMaxPageSize()
|
|
if perPage != "" {
|
|
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
|
perPageNum = num
|
|
}
|
|
}
|
|
|
|
// Get base URLs
|
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
|
}
|
|
|
|
// Parse device ID
|
|
deviceUUID, err := uuid.Parse(deviceID)
|
|
if err != nil {
|
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
|
}
|
|
|
|
// Verify device exists
|
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
|
}
|
|
|
|
// Get user's visible libraries
|
|
userID := device.UserID.Bytes
|
|
userUUID := uuid.UUID(userID)
|
|
|
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
|
}
|
|
|
|
// Get media items
|
|
var allItems []database.ListMediaItemsByLibraryRow
|
|
for _, lib := range libraries {
|
|
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: lib.ID.Bytes, Valid: true})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allItems = append(allItems, items...)
|
|
}
|
|
|
|
// Filter by format if specified
|
|
if includeFormat != "" {
|
|
filteredItems := []database.ListMediaItemsByLibraryRow{}
|
|
for _, item := range allItems {
|
|
if strings.EqualFold(item.FormatGroup, includeFormat) {
|
|
filteredItems = append(filteredItems, item)
|
|
}
|
|
}
|
|
allItems = filteredItems
|
|
}
|
|
|
|
// Pagination
|
|
totalItems := len(allItems)
|
|
startIdx := (pageNum - 1) * perPageNum
|
|
endIdx := startIdx + perPageNum
|
|
|
|
if startIdx >= totalItems {
|
|
allItems = []database.ListMediaItemsByLibraryRow{}
|
|
} else if endIdx > totalItems {
|
|
allItems = allItems[startIdx:]
|
|
} else {
|
|
allItems = allItems[startIdx:endIdx]
|
|
}
|
|
|
|
// Create OPDS feed
|
|
feed := opds.NewFeed(
|
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
|
"Bookhoard Library",
|
|
)
|
|
|
|
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
|
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
|
token := h.getAuthToken(c)
|
|
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
|
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
|
|
|
// OpenSearch: the search link points to an OpenSearch description document
|
|
// (served by the same /search endpoint when no query is supplied) so that
|
|
// OPDS clients like KOReader can discover how to formulate search requests.
|
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
|
|
|
// Add entries
|
|
for _, item := range allItems {
|
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
|
title := item.Title
|
|
author := ""
|
|
if item.Author.Valid {
|
|
author = item.Author.String
|
|
}
|
|
|
|
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
|
|
|
entry := opds.NewEntry(
|
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
|
title,
|
|
author,
|
|
updated,
|
|
)
|
|
|
|
// Add description
|
|
if item.Description.Valid {
|
|
entry.SetSummary(item.Description.String)
|
|
}
|
|
|
|
// Add acquisition link using the item's real mime type
|
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
|
|
|
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
|
// archives are served as-is in their native format.
|
|
if !isComicArchive(item.FormatGroup) {
|
|
if device.DeviceType == "kobo" {
|
|
kepubURL := downloadURL + "&format=kepub"
|
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
|
}
|
|
|
|
pdfURL := downloadURL + "&format=pdf"
|
|
entry.AddAlternateLink(pdfURL, "application/pdf")
|
|
}
|
|
|
|
// Add canonical identifier
|
|
entry.SetIdentifier(bookUUID)
|
|
|
|
// Add SHA-256 metadata
|
|
if item.FileSha256.Valid {
|
|
entry.AddMetadata("bookhoard:sha256", item.FileSha256.String)
|
|
}
|
|
|
|
// Add collections as categories
|
|
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
|
if err == nil {
|
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
|
for _, col := range collections {
|
|
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
|
entry.AddCategory(collectionScheme, col.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
feed.AddEntry(entry)
|
|
}
|
|
|
|
// Generate XML
|
|
xmlString, err := feed.GenerateXMLString()
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
|
//
|
|
// When no "q" query parameter is supplied it returns an OpenSearch description
|
|
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
|
// KOReader can discover the search URL template (which contains the
|
|
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
|
// acquisition feed of matching books.
|
|
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
query := c.QueryParam("q")
|
|
|
|
// Get base URLs
|
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
|
}
|
|
|
|
// Parse device ID
|
|
deviceUUID, err := uuid.Parse(deviceID)
|
|
if err != nil {
|
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
|
}
|
|
|
|
// Verify device exists
|
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
|
}
|
|
|
|
// Get user's visible libraries
|
|
userID := device.UserID.Bytes
|
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
|
}
|
|
|
|
token := h.getAuthToken(c)
|
|
|
|
// No query: serve the OpenSearch description document so clients can learn
|
|
// the search template (contains the {searchTerms} placeholder).
|
|
if query == "" {
|
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
|
desc := opds.NewSearchDescription(
|
|
"Bookhoard",
|
|
"Search the Bookhoard library",
|
|
searchURL,
|
|
)
|
|
xmlString, err := desc.GenerateXMLString()
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
|
}
|
|
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// Search media items
|
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
|
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
|
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
|
})
|
|
if err != nil {
|
|
allItems = []database.SearchMediaItemsRow{}
|
|
}
|
|
|
|
// Create OPDS feed
|
|
feed := opds.NewFeed(
|
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
|
fmt.Sprintf("Search: %s", query),
|
|
)
|
|
|
|
// Add feed links
|
|
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
|
feed.AddLink(catalogURL, catalogMediaType, "start")
|
|
|
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
|
feed.AddLink(searchURL, catalogMediaType, "self")
|
|
|
|
// OpenSearch paging metadata (search results are a single page)
|
|
feed.SetPagination(len(allItems), len(allItems), 1)
|
|
|
|
// Add entries (same as catalog)
|
|
userUUID := uuid.UUID(userID)
|
|
for _, item := range allItems {
|
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
|
title := item.Title
|
|
author := ""
|
|
if item.Author.Valid {
|
|
author = item.Author.String
|
|
}
|
|
|
|
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
|
|
|
entry := opds.NewEntry(
|
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
|
title,
|
|
author,
|
|
updated,
|
|
)
|
|
|
|
if item.Description.Valid {
|
|
entry.SetSummary(item.Description.String)
|
|
}
|
|
|
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
|
|
|
// Only offer kepub conversion for ebooks; comic archives are served
|
|
// as-is in their native format.
|
|
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
|
kepubURL := downloadURL + "&format=kepub"
|
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
|
}
|
|
|
|
entry.SetIdentifier(bookUUID)
|
|
|
|
if item.FileSha256.Valid {
|
|
entry.AddMetadata("bookhoard:sha256", item.FileSha256.String)
|
|
}
|
|
|
|
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
|
if err == nil {
|
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
|
for _, col := range collections {
|
|
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
|
entry.AddCategory(collectionScheme, col.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
feed.AddEntry(entry)
|
|
}
|
|
|
|
xmlString, err := feed.GenerateXMLString()
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// DownloadBook downloads a book with optional format conversion
|
|
func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
bookID := c.Param("bookId")
|
|
format := c.QueryParam("format") // epub, kepub, pdf, cbz
|
|
|
|
// Default to epub
|
|
if format == "" {
|
|
format = "epub"
|
|
}
|
|
|
|
// Parse IDs (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// Verify device exists
|
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
// Get user's visible libraries
|
|
userID := device.UserID.Bytes
|
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
|
}
|
|
|
|
// Check if book is in visible library
|
|
visible := false
|
|
for _, lib := range libraries {
|
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
|
}
|
|
|
|
// Get format information
|
|
filePath := ""
|
|
var fileSha256 string
|
|
mimeType := ""
|
|
|
|
if format == "kepub" {
|
|
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true},
|
|
FormatType: "kepub",
|
|
})
|
|
|
|
if err == nil && kepubFormat.FilePath.Valid {
|
|
filePath = kepubFormat.FilePath.String
|
|
if kepubFormat.FileSha256.Valid {
|
|
fileSha256 = kepubFormat.FileSha256.String
|
|
}
|
|
if kepubFormat.MimeType.Valid {
|
|
mimeType = kepubFormat.MimeType.String
|
|
}
|
|
} else {
|
|
if h.conversionService != nil {
|
|
resolvedPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
converted, err := h.conversionService.ConvertEPUBToKEPUB(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}, resolvedPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("KEPUB conversion failed: %v", err)})
|
|
}
|
|
filePath = converted.Path
|
|
fileSha256 = converted.SHA256
|
|
mimeType = "application/vnd.kobo+xml+zip"
|
|
} else {
|
|
filePath, err = h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
if mediaItem.MimeType.Valid {
|
|
mimeType = mediaItem.MimeType.String
|
|
}
|
|
}
|
|
}
|
|
} else if format == "pdf" {
|
|
// Check for PDF format
|
|
pdfFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true},
|
|
FormatType: "pdf",
|
|
})
|
|
if err == nil && pdfFormat.FilePath.Valid {
|
|
filePath = pdfFormat.FilePath.String
|
|
if pdfFormat.FileSha256.Valid {
|
|
fileSha256 = pdfFormat.FileSha256.String
|
|
}
|
|
if pdfFormat.MimeType.Valid {
|
|
mimeType = pdfFormat.MimeType.String
|
|
}
|
|
} else {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "pdf format not available"})
|
|
}
|
|
} else {
|
|
// Default: EPUB
|
|
var err error
|
|
filePath, err = h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
if mediaItem.MimeType.Valid {
|
|
mimeType = mediaItem.MimeType.String
|
|
}
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open file"})
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
|
}
|
|
|
|
// Set headers
|
|
filename := filepath.Base(filePath)
|
|
c.Response().Header().Set("Content-Type", mimeType)
|
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
c.Response().Header().Set("X-Bookhoard-UUID", bookUUID.String())
|
|
|
|
if format == "kepub" && fileSha256 != "" {
|
|
c.Response().Header().Set("X-Bookhoard-KEPUB-SHA256", fileSha256)
|
|
} else if fileSha256 != "" {
|
|
c.Response().Header().Set("X-Bookhoard-SHA256", fileSha256)
|
|
}
|
|
|
|
// Stream file
|
|
http.ServeContent(c.Response(), c.Request(), filename, fileInfo.ModTime(), file)
|
|
return nil
|
|
}
|
|
|
|
// GetCoverImage serves a book's cover image
|
|
func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
bookID := c.Param("bookId")
|
|
|
|
// Parse IDs (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// Verify device exists
|
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
// Get user's visible libraries
|
|
userID := device.UserID.Bytes
|
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
|
}
|
|
|
|
// Check if book is in visible library
|
|
visible := false
|
|
for _, lib := range libraries {
|
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
|
}
|
|
|
|
// Check if cover exists
|
|
if !mediaItem.CoverImagePath.Valid || mediaItem.CoverImagePath.String == "" {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
coverPath := mediaItem.CoverImagePath.String
|
|
|
|
// Resolve relative path using library service
|
|
fullPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverPath)
|
|
if err != nil {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open cover"})
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
|
}
|
|
|
|
// Determine content type
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
contentType := "image/jpeg"
|
|
if ext == ".png" {
|
|
contentType = "image/png"
|
|
} else if ext == ".webp" {
|
|
contentType = "image/webp"
|
|
}
|
|
|
|
// Set cache headers
|
|
c.Response().Header().Set("Content-Type", contentType)
|
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
|
|
|
|
// Stream image
|
|
http.ServeContent(c.Response(), c.Request(), "", fileInfo.ModTime(), file)
|
|
return nil
|
|
}
|
|
|
|
// GetDeviceNavigation returns the OPDS navigation feed for a device
|
|
func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
|
|
// Get base URLs
|
|
_, opdsBaseURL, err := h.getBaseURLs(c)
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
|
}
|
|
|
|
// Parse device ID (middleware guarantees valid UUID)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
|
|
// Verify device exists
|
|
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
|
}
|
|
|
|
// Create OPDS navigation feed
|
|
feed := opds.NewFeed(
|
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
|
"Bookhoard Library Navigation",
|
|
)
|
|
|
|
// Add feed links
|
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
|
|
|
// Add navigation entry for catalog
|
|
catalogEntry := opds.NewEntry(
|
|
fmt.Sprintf("urn:uuid:%s-catalog", deviceID),
|
|
"All Books",
|
|
"",
|
|
feed.Updated,
|
|
)
|
|
catalogEntry.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "subsection")
|
|
catalogEntry.SetSummary("Browse all books in the library")
|
|
feed.AddEntry(catalogEntry)
|
|
|
|
// Generate XML
|
|
xmlString, err := feed.GenerateXMLString()
|
|
if err != nil {
|
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=navigation")
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// ListFormats lists available formats for a book
|
|
func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
bookID := c.Param("bookId")
|
|
|
|
// Parse IDs (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// Verify device exists
|
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
// Get user's visible libraries
|
|
userID := device.UserID.Bytes
|
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
|
}
|
|
|
|
// Check if book is in visible library
|
|
visible := false
|
|
for _, lib := range libraries {
|
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
|
}
|
|
|
|
// Get all formats
|
|
formats, err := h.db.GetMediaItemFormats(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get formats"})
|
|
}
|
|
|
|
type FormatInfo struct {
|
|
FormatType string `json:"format_type"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
FileSha256 string `json:"file_sha256,omitempty"`
|
|
FileSize int64 `json:"file_size_bytes,omitempty"`
|
|
MimeType string `json:"mime_type,omitempty"`
|
|
Available bool `json:"available"`
|
|
}
|
|
|
|
formatList := []FormatInfo{}
|
|
|
|
// Add the primary/native format (always available if media item exists)
|
|
fileSize := int64(0)
|
|
if mediaItem.FileSize.Valid {
|
|
fileSize = mediaItem.FileSize.Int64
|
|
}
|
|
|
|
formatList = append(formatList, FormatInfo{
|
|
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
|
FilePath: mediaItem.FilePath,
|
|
FileSha256: func() string {
|
|
if mediaItem.FileSha256.Valid {
|
|
return mediaItem.FileSha256.String
|
|
} else {
|
|
return ""
|
|
}
|
|
}(),
|
|
FileSize: fileSize,
|
|
MimeType: func() string {
|
|
if mediaItem.MimeType.Valid {
|
|
return mediaItem.MimeType.String
|
|
} else {
|
|
return "application/epub+zip"
|
|
}
|
|
}(),
|
|
Available: true,
|
|
})
|
|
|
|
// Add other formats
|
|
for _, format := range formats {
|
|
fileSize := int64(0)
|
|
if format.FileSizeBytes.Valid {
|
|
fileSize = format.FileSizeBytes.Int64
|
|
}
|
|
|
|
formatList = append(formatList, FormatInfo{
|
|
FormatType: format.FormatType,
|
|
FilePath: func() string {
|
|
if format.FilePath.Valid {
|
|
return format.FilePath.String
|
|
} else {
|
|
return ""
|
|
}
|
|
}(),
|
|
FileSha256: func() string {
|
|
if format.FileSha256.Valid {
|
|
return format.FileSha256.String
|
|
} else {
|
|
return ""
|
|
}
|
|
}(),
|
|
FileSize: fileSize,
|
|
MimeType: func() string {
|
|
if format.MimeType.Valid {
|
|
return format.MimeType.String
|
|
} else {
|
|
return ""
|
|
}
|
|
}(),
|
|
Available: format.FilePath.Valid,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"media_item_id": bookUUID.String(),
|
|
"formats": formatList,
|
|
})
|
|
}
|
|
|
|
// RegisterOPDS registers a device for OPDS access
|
|
func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
|
|
// Parse device ID
|
|
deviceUUID, err := uuid.Parse(deviceID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
// Verify device exists
|
|
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
|
}
|
|
|
|
// Get base URLs
|
|
_, opdsBaseURL, err := h.getBaseURLs(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get config"})
|
|
}
|
|
|
|
// Generate OPDS token
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
tokenBytes = []byte(deviceID + "-" + uuid.New().String())
|
|
}
|
|
token := hex.EncodeToString(tokenBytes)[:64]
|
|
|
|
// Create OPDS token record (valid for 1 year)
|
|
expiresAt := time.Now().AddDate(1, 0, 0)
|
|
_, err = h.db.CreateOpdsToken(c.Request().Context(), database.CreateOpdsTokenParams{
|
|
DeviceID: pgtype.UUID{Bytes: deviceUUID, Valid: true},
|
|
Token: token,
|
|
TokenType: pgtype.Text{String: "device", Valid: true},
|
|
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
|
}
|
|
|
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"opds_token": map[string]interface{}{
|
|
"token": token,
|
|
"token_type": "device",
|
|
"expires_at": expiresAt.Format(time.RFC3339),
|
|
},
|
|
"opds_catalog_url": catalogURL,
|
|
"refresh_interval": 3600,
|
|
})
|
|
}
|