feat: add Kobo device sync support and fix device route protection
- Add Kobo sync handler with markup, bookmark, analytics, and initialization endpoints - Add Kobo integration tests and Bruno API test collection - Move device approve/reject routes from public to protected routes - Enhance test infrastructure with DATABASE_URL support and helper functions - Fix device GetDevice handler nil pointer handling - Clean up test reports and session files
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
wsync "bookmann/internal/sync"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
}
|
||||
|
||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||
return &KoboHandler{db: db, connManager: connManager}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
librarySync = append(librarySync, KoboLibraryBook{
|
||||
ContentId: uuid.UUID(item.ID.Bytes).String(),
|
||||
ContentType: "6",
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
PercentRead: percentRead,
|
||||
PagesRemaining: pagesRemaining,
|
||||
BookmarkCount: bookmarkCount,
|
||||
LastModified: lastModified,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
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
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
mediaUUID, err := uuid.Parse(readingSync.ContentId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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(
|
||||
mediaUUID,
|
||||
percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID).String(),
|
||||
Name: device.DeviceName,
|
||||
Type: "kobo",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bookmarkSync := range req.BookmarkSync {
|
||||
mediaUUID, err := uuid.Parse(bookmarkSync.ContentId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to update device timestamp",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, KoboSyncStatus{
|
||||
Status: "Success",
|
||||
MarkupsSynced: markupsSynced,
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) Bookmark(c echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
var req struct {
|
||||
BookmarkSync []KoboBookmarkSync `json:"BookmarkSync"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
bookmarksSynced := 0
|
||||
|
||||
for _, bookmarkSync := range req.BookmarkSync {
|
||||
mediaUUID, err := uuid.Parse(bookmarkSync.ContentId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, 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},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to update device timestamp",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, KoboSyncStatus{
|
||||
Status: "Success",
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
MarkupsSynced: 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) AnalyticsGettests(c echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
var req []KoboAnalyticsTest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range req {
|
||||
mediaUUID, err := uuid.Parse(test.ContentId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||
percentage := test.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 {
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
mediaUUID,
|
||||
percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID).String(),
|
||||
Name: device.DeviceName,
|
||||
Type: "kobo",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to update device timestamp",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"Status": "Success",
|
||||
})
|
||||
}
|
||||
|
||||
func parseKoboDeviceHeader(c echo.Context) (KoboDeviceInfo, error) {
|
||||
deviceHeader := c.Request().Header.Get("x-kobo-device")
|
||||
if deviceHeader == "" {
|
||||
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
|
||||
}
|
||||
|
||||
var device KoboDeviceInfo
|
||||
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
|
||||
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
Reference in New Issue
Block a user