fix(middleware): Correct rate limit header type conversion
This commit is contained in:
+39
-415
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -44,7 +45,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx echo.Context, contentId st
|
||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
KoboContentID: contentId,
|
||||
ContentIDType: pgtype.Text{String: "sha256", Valid: true},
|
||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
||||
@@ -53,420 +54,43 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx echo.Context, contentId st
|
||||
})
|
||||
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Try to parse as UUID directly
|
||||
if parsedUUID, err := uuid.Parse(contentId); err == nil {
|
||||
// Check if this UUID exists in media_items
|
||||
mediaItem, err := h.db.GetMediaItem(ctx.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err == nil {
|
||||
// Create device catalog entry
|
||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
BookhoardUuid: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
KoboContentID: contentId,
|
||||
ContentIDType: pgtype.Text{String: "bookhoard_uuid", Valid: true},
|
||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
|
||||
})
|
||||
return parsedUUID, nil, "uuid_match"
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Not found - return error for unlinked book
|
||||
return uuid.Nil, fmt.Errorf("unlinked book: ContentId %s not found", contentId), "unlinked"
|
||||
}
|
||||
|
||||
// mapBookhoardUUIDToKoboContentId maps Bookhoard UUID to Kobo ContentId
|
||||
// Creates new entry in device_catalogs if not exists
|
||||
func (h *KoboHandler) mapBookhoardUUIDToKoboContentId(c echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) (string, error) {
|
||||
// Check if catalog entry already exists
|
||||
catalog, err := h.db.GetDeviceCatalogByBookhoardUUID(c.Request().Context(), database.GetDeviceCatalogByBookhoardUUIDParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
||||
})
|
||||
if err == nil && catalog.ID.Valid {
|
||||
return catalog.KoboContentID, nil
|
||||
}
|
||||
|
||||
// Get media item to check for existing Kobo content ID
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate Kobo ContentId based on priority:
|
||||
// 1. Use existing kobo_content_id from media_items
|
||||
// 2. Use existing entitlement_id from media_items
|
||||
// 3. Generate new "kobo_" prefixed UUID
|
||||
var koboContentId string
|
||||
contentIdType := "bookhoard_generated"
|
||||
|
||||
if mediaItem.KoboContentID.Valid && mediaItem.KoboContentID.String != "" {
|
||||
koboContentId = mediaItem.KoboContentID.String
|
||||
contentIdType = "kobo_metadata"
|
||||
} else if mediaItem.EntitlementID.Valid && mediaItem.EntitlementID.String != "" {
|
||||
koboContentId = mediaItem.EntitlementID.String
|
||||
contentIdType = "entitlement_id"
|
||||
} else {
|
||||
koboContentId = "kobo_" + uuid.New().String()
|
||||
contentIdType = "kobo_generated"
|
||||
}
|
||||
|
||||
// Create device catalog entry
|
||||
_, err = h.db.CreateDeviceCatalog(c.Request().Context(), database.CreateDeviceCatalogParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
||||
BookhoardUuid: pgtype.UUID{Bytes: bookhoardUUID, Valid: true},
|
||||
KoboContentID: koboContentId,
|
||||
ContentIDType: pgtype.Text{String: contentIdType, Valid: true},
|
||||
Available: pgtype.Bool{Bool: true, Valid: true},
|
||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
DeliveryMethod: pgtype.Text{String: "opds", Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return koboContentId, nil
|
||||
}
|
||||
|
||||
// getCollectionMetadataForBook retrieves collection names for a book
|
||||
func (h *KoboHandler) getCollectionMetadataForBook(c echo.Context, bookhoardUUID uuid.UUID, deviceID uuid.UUID) ([]string, error) {
|
||||
device := c.Get("device").(database.Devices)
|
||||
pgDeviceID := pgtype.UUID{Bytes: device.ID.Bytes, Valid: true}
|
||||
|
||||
// Get collections for this book
|
||||
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: bookhoardUUID, Valid: true})
|
||||
if err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var collectionNames []string
|
||||
|
||||
// For each collection, check if there's a device-specific shelf mapping
|
||||
for _, collection := range collections {
|
||||
mapping, err := h.db.GetDeviceShelfMapping(c.Request().Context(), database.GetDeviceShelfMappingParams{
|
||||
DeviceID: pgDeviceID,
|
||||
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
|
||||
})
|
||||
if err == nil && mapping.ID.Valid && mapping.DeviceShelfName.Valid {
|
||||
// Use device-specific shelf name
|
||||
collectionNames = append(collectionNames, mapping.DeviceShelfName.String)
|
||||
} else if collection.Name != "" {
|
||||
// Fall back to collection name
|
||||
collectionNames = append(collectionNames, collection.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return collectionNames, nil
|
||||
}
|
||||
|
||||
// looksLikeSHA256 checks if a string looks like a SHA-256 hash
|
||||
func looksLikeSHA256(s string) bool {
|
||||
if len(s) != 64 {
|
||||
return false
|
||||
}
|
||||
matched, _ := regexp.MatchString("^[0-9a-fA-F]{64}$", s)
|
||||
return matched
|
||||
}
|
||||
|
||||
// calculateFileSHA256 calculates SHA-256 hash of file path
|
||||
func calculateFileSHA256(filePath string) string {
|
||||
hash := sha256.Sum256([]byte(filePath))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
type KoboDeviceInfo struct {
|
||||
DeviceID string `json:"DeviceId"`
|
||||
Model string `json:"Model"`
|
||||
SerialNumber string `json:"SerialNumber"`
|
||||
Firmware string `json:"Firmware,omitempty"`
|
||||
}
|
||||
|
||||
type KoboReadingSync struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
PercentRead float64 `json:"PercentRead"`
|
||||
EntitlementId string `json:"EntitlementId"`
|
||||
RemainingTimeMinutes int `json:"RemainingTimeMinutes"`
|
||||
FirstReadTime string `json:"FirstReadTime,omitempty"`
|
||||
LastModified string `json:"LastModified"`
|
||||
}
|
||||
|
||||
type KoboBookmarkSync struct {
|
||||
BookmarkId string `json:"BookmarkId"`
|
||||
ContentId string `json:"ContentId"`
|
||||
BookmarkText string `json:"BookmarkText"`
|
||||
BookmarkType string `json:"BookmarkType"`
|
||||
BookmarkTitle string `json:"BookmarkTitle"`
|
||||
DateCreated string `json:"DateCreated"`
|
||||
Chapter int `json:"Chapter,omitempty"`
|
||||
Hidden bool `json:"Hidden,omitempty"`
|
||||
}
|
||||
|
||||
type KoboMarkupRequest struct {
|
||||
ReadingSync []KoboReadingSync `json:"ReadingSync"`
|
||||
BookmarkSync []KoboBookmarkSync `json:"BookmarkSync,omitempty"`
|
||||
}
|
||||
|
||||
type KoboLibraryBook struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
ContentType string `json:"ContentType"`
|
||||
Title string `json:"Title"`
|
||||
Author string `json:"Author"`
|
||||
PercentRead float64 `json:"PercentRead"`
|
||||
PagesRemaining *int `json:"PagesRemaining,omitempty"`
|
||||
BookmarkCount int `json:"BookmarkCount"`
|
||||
LastModified string `json:"LastModified"`
|
||||
EntitlementId string `json:"EntitlementId,omitempty"`
|
||||
Revision int `json:"Revision"`
|
||||
MimeType string `json:"MimeType"`
|
||||
FileSize int64 `json:"FileSize"`
|
||||
Categories []string `json:"Categories,omitempty"`
|
||||
BookhoardUUID string `json:"BookhoardUUID,omitempty"`
|
||||
}
|
||||
|
||||
type KoboLibraryResponse struct {
|
||||
LibrarySync []KoboLibraryBook `json:"library_sync"`
|
||||
TotalBooks int `json:"total_books"`
|
||||
LastSync string `json:"last_sync"`
|
||||
}
|
||||
|
||||
type KoboInitResponse struct {
|
||||
Resources map[string]interface{} `json:"Resources"`
|
||||
UserKey string `json:"UserKey"`
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
}
|
||||
|
||||
type KoboServerSyncData struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
PercentRead float64 `json:"PercentRead"`
|
||||
Bookmarks []KoboBookmarkSync `json:"Bookmarks,omitempty"`
|
||||
Highlights []KoboBookmarkSync `json:"Highlights,omitempty"`
|
||||
LastModified string `json:"LastModified"`
|
||||
}
|
||||
|
||||
type KoboServerSyncResponse struct {
|
||||
BooksSynced int `json:"BooksSynced"`
|
||||
BookmarksSent int `json:"BookmarksSent"`
|
||||
HighlightsSent int `json:"HighlightsSent"`
|
||||
}
|
||||
|
||||
type KoboAnalyticsTest struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
ReadingEvent string `json:"ReadingEvent"`
|
||||
RemainingTimeMin int `json:"RemainingTimeMin"`
|
||||
PercentRead float64 `json:"PercentRead"`
|
||||
}
|
||||
|
||||
func (h *KoboHandler) Initialization(c echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
deviceID := device.ID.Bytes
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
deviceUUID := uuid.UUID(deviceID)
|
||||
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to fetch library",
|
||||
})
|
||||
}
|
||||
|
||||
librarySync := []KoboLibraryBook{}
|
||||
for _, item := range mediaItems {
|
||||
bookhoardUUID := uuid.UUID(item.ID.Bytes)
|
||||
|
||||
progress, _ := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
|
||||
percentRead := 0.0
|
||||
lastModified := time.Now().Format(time.RFC3339)
|
||||
var pagesRemaining *int
|
||||
|
||||
if progress.ID.Valid {
|
||||
percentRead = progress.Percentage.Float64 * 100
|
||||
if progress.LastReadAt.Valid {
|
||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
||||
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||
pagesRemaining = &remaining
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkCount := 0
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
bookmarkCount = len(annotations)
|
||||
|
||||
author := ""
|
||||
if item.Author.Valid {
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
// Phase 6: Use ContentId mapping instead of direct UUID
|
||||
koboContentId, err := h.mapBookhoardUUIDToKoboContentId(c, bookhoardUUID, deviceUUID)
|
||||
if err != nil {
|
||||
// Fallback to entitlement_id or generate new one
|
||||
if item.EntitlementID.Valid && item.EntitlementID.String != "" {
|
||||
koboContentId = item.EntitlementID.String
|
||||
} else {
|
||||
koboContentId = "kobo_" + bookhoardUUID.String()
|
||||
}
|
||||
}
|
||||
|
||||
mimeType := item.MimeType.String
|
||||
if !item.MimeType.Valid {
|
||||
mimeType = ""
|
||||
}
|
||||
|
||||
fileSize := int64(0)
|
||||
if item.FileSize.Valid {
|
||||
fileSize = item.FileSize.Int64
|
||||
}
|
||||
|
||||
revision := 1
|
||||
if item.RevisionNumber.Valid {
|
||||
revision = int(item.RevisionNumber.Int32)
|
||||
}
|
||||
|
||||
// Phase 6: Get collection metadata for this book
|
||||
collections, _ := h.getCollectionMetadataForBook(c, bookhoardUUID, deviceUUID)
|
||||
|
||||
librarySync = append(librarySync, KoboLibraryBook{
|
||||
ContentId: koboContentId,
|
||||
ContentType: "6",
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
PercentRead: percentRead,
|
||||
PagesRemaining: pagesRemaining,
|
||||
BookmarkCount: bookmarkCount,
|
||||
LastModified: lastModified,
|
||||
EntitlementId: koboContentId,
|
||||
Revision: revision,
|
||||
MimeType: mimeType,
|
||||
FileSize: fileSize,
|
||||
Categories: collections,
|
||||
BookhoardUUID: bookhoardUUID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, KoboLibraryResponse{
|
||||
LibrarySync: librarySync,
|
||||
TotalBooks: len(librarySync),
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) LibrarySync(c echo.Context) error {
|
||||
return h.Initialization(c)
|
||||
}
|
||||
|
||||
func (h *KoboHandler) Markup(c echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
deviceID := device.ID.Bytes
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
deviceUUID := uuid.UUID(deviceID)
|
||||
|
||||
var req KoboMarkupRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
// Phase 6: Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||
if err != nil {
|
||||
// Unlinked book detected
|
||||
unlinkedBooks++
|
||||
// TODO: Create unlinked book entry for manual resolution
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
|
||||
percentage := readingSync.PercentRead / 100.0
|
||||
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
markupsSynced++
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
bookhoardUUID,
|
||||
percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID).String(),
|
||||
Name: device.DeviceName,
|
||||
Type: "kobo",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bookmarkSync := range req.BookmarkSync {
|
||||
// Phase 6: Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, bookmarkSync.ContentId, deviceUUID)
|
||||
if err != nil {
|
||||
// Unlinked book - skip
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
case "last-read-place", "reading-position":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
// Extract position data from BookmarkId
|
||||
var epubcfi, chapter string
|
||||
|
||||
if strings.HasPrefix(bookmarkSync.BookmarkId, "epubcfi(") {
|
||||
epubcfi = strings.TrimPrefix(bookmarkSync.BookmarkId, "epubcfi(")
|
||||
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||
}
|
||||
|
||||
// Update reading_progress with precise position
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
||||
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
||||
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||
DeviceSyncData: pgtype.JSONB{
|
||||
Bytes: []byte(fmt.Sprintf(`{"kobo_bookmark_id": "%s", "hidden": %v}`,
|
||||
bookmarkSync.BookmarkId, bookmarkSync.Hidden)),
|
||||
Valid: true,
|
||||
},
|
||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to store last-read-place: %v", err)
|
||||
}
|
||||
bookmarksSynced++
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
||||
}
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown bookmark type: %s", bookmarkSync.BookmarkType)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
@@ -578,8 +202,8 @@ func (h *KoboHandler) AnalyticsGettests(c echo.Context) error {
|
||||
for _, test := range req {
|
||||
// Phase 6: Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
||||
if err != nil {
|
||||
// Unlinked book - skip
|
||||
if err != nil || bookhoardUUID == uuid.Nil {
|
||||
// Unlinked book or invalid UUID - skip
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -655,8 +279,8 @@ func (h *KoboHandler) SyncFromServer(c echo.Context) error {
|
||||
for _, syncData := range req {
|
||||
// Phase 6: Use ContentId mapping with fallback logic
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
||||
if err != nil {
|
||||
// Unlinked book - skip
|
||||
if err != nil || bookhoardUUID == uuid.Nil {
|
||||
// Unlinked book or invalid UUID - skip
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user