package handlers import ( "bookhoard/internal/config" "bookhoard/internal/database" "bookhoard/internal/middleware" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" "github.com/skip2/go-qrcode" "golang.org/x/crypto/bcrypt" ) type DeviceHandler struct { db *database.Queries jwtKey []byte cfg *config.Config } func NewDeviceHandler(db *database.Queries, jwtSecret string, cfg *config.Config) *DeviceHandler { return &DeviceHandler{ db: db, jwtKey: []byte(jwtSecret), cfg: cfg, } } type DeviceRegistrationRequest struct { DeviceName string `json:"device_name" validate:"required,min=1,max=100"` DeviceType string `json:"device_type" validate:"required,oneof=koreader kobo web mobile"` DeviceIdentifier string `json:"device_identifier" validate:"required,min=1,max=255"` } type DeviceRegistrationResponse struct { DeviceID uuid.UUID `json:"device_id"` RegistrationID string `json:"registration_id"` AuthURL string `json:"auth_url"` QRCode string `json:"qr_code"` PairingCode string `json:"pairing_code"` ExpiresIn int `json:"expires_in"` PollInterval int `json:"poll_interval"` SetupInstructions map[string]string `json:"setup_instructions"` } type DeviceAuthStatusRequest struct { RegistrationID string `json:"registration_id" validate:"required"` } type DeviceAuthStatusResponse struct { Status string `json:"status"` AuthToken string `json:"auth_token,omitempty"` DeviceID uuid.UUID `json:"device_id,omitempty"` SyncEndpoints map[string]string `json:"sync_endpoints,omitempty"` Message string `json:"message,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` } type DeviceListResponse struct { Devices []DeviceInfo `json:"devices"` Total int `json:"total"` } type DeviceInfo struct { ID uuid.UUID `json:"id"` DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` LastSync *time.Time `json:"last_sync"` LastSeen *time.Time `json:"last_seen"` SyncEnabled bool `json:"sync_enabled"` AutoSync bool `json:"auto_sync"` SyncFrequency int32 `json:"sync_frequency_minutes"` CreatedAt time.Time `json:"created_at"` DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"` AuthToken string `json:"auth_token"` } type DeviceUpdateRequest struct { DeviceName string `json:"device_name,omitempty" validate:"omitempty,min=1,max=100"` SyncEnabled *bool `json:"sync_enabled,omitempty"` AutoSync *bool `json:"auto_sync,omitempty"` SyncFrequencyMinutes *int32 `json:"sync_frequency_minutes,omitempty" validate:"omitempty,min=1,max=1440"` DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"` } type DeviceApprovalRequest struct { RegistrationID string `json:"registration_id" validate:"required"` Approve bool `json:"approve"` } // PairingCodeRequest carries the 6-digit code shown on a registering // device's screen — the claim credential for the pairing flow. type PairingCodeRequest struct { PairingCode string `json:"pairing_code"` } // PairingLookupResponse describes a live registration without binding it — // the confirm card's data ("Pair 'My Kindle'?"). type PairingLookupResponse struct { DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` DeviceIdentifier string `json:"device_identifier"` ExpiresIn int `json:"expires_in"` } type CancelRegistrationRequest struct { RegistrationID string `json:"registration_id" validate:"required"` } type PendingRegistration struct { RegistrationID string DeviceName string DeviceType string DeviceIdentifier string PairingCode string UserID uuid.UUID ExpiresAt time.Time CreatedAt time.Time Approved bool AuthToken string DeviceID [16]byte SyncEndpoints map[string]string } // pendingRegistrations holds in-flight (unapproved) device registrations. // HTTP handlers touch it from multiple goroutines — every access must hold // pendingMu (Go maps are not safe for concurrent use; a racing write is a // runtime fatal, not an error). var ( pendingRegistrations = make(map[string]*PendingRegistration) pendingMu sync.Mutex ) // pairingRateLimiter throttles claim-code lookups per account: the code is // the only secret guarding a registration, so guessing must not be free. // The per-registration 5-minute TTL bounds each code's exposure; this // bounds the guessing rate against the whole live-code space. var pairingRateLimiter = middleware.NewDeviceRateLimiter() const ( // pairingCodeDigits: six, the Netflix/YouTube TV pairing shape — // nothing to type on e-ink hardware, and a million-code space that a // rate-limited guesser cannot walk inside the 5-minute TTL. pairingCodeDigits = 6 ) // pairingRateLimitConfig caps lookup/claim attempts per account per // minute (lookups and claims share the budget — a pairing is two calls: // lookup, then confirm). var pairingRateLimitConfig = middleware.DeviceRateLimitConfig{ ClaimAttemptsPerMinute: 12, } // generatePairingCode returns a uniformly random pairingCodeDigits-digit // code, unique among the currently live registrations. The code is a claim // credential — never log it. func generatePairingCode() (string, error) { space := 1 for i := 0; i < pairingCodeDigits; i++ { space *= 10 } // Rejection-sample: n%space over the full 24-bit range would bias low // codes (16777216 is not a multiple of 10^6), so values at or above // the largest whole multiple of the code space are discarded. max := ((1 << 24) / space) * space for attempt := 0; attempt < 10; attempt++ { b := make([]byte, 3) if _, err := rand.Read(b); err != nil { return "", err } n := int(b[0])<<16 | int(b[1])<<8 | int(b[2]) if n >= max { continue } code := fmt.Sprintf("%0*d", pairingCodeDigits, n%space) if !pairingCodeInUse(code) { return code, nil } } return "", errors.New("pairing code space exhausted") } // pairingCodeInUse reports whether any live registration already holds the // code. Callers race on mint→insert; the next mint's collision check // covers the gap well enough for a family-scale server. func pairingCodeInUse(code string) bool { pendingMu.Lock() defer pendingMu.Unlock() for _, reg := range pendingRegistrations { if reg.PairingCode == code { return true } } return false } // normalizePairingCode reduces human-typed input to its digits — the // device shows "583 912", users type "583 912", "583912", "583-912". func normalizePairingCode(raw string) string { var b strings.Builder for _, r := range raw { if r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } // errPairingInvalid marks a wrong code (generic by design — no hint // whether the code exists), errPairingExpired a live-registration TTL // miss, errPairingClaimed a code whose registration was already bound. var ( errPairingInvalid = errors.New("invalid pairing code") errPairingExpired = errors.New("pairing code expired") errPairingClaimed = errors.New("pairing code already used") ) // registrationByPairingCode resolves a normalized code to its live, // unclaimed registration, reaping expired entries on sight. func registrationByPairingCode(code string) (*PendingRegistration, error) { pendingMu.Lock() defer pendingMu.Unlock() for _, reg := range pendingRegistrations { if reg.PairingCode != code { continue } if time.Now().After(reg.ExpiresAt) { delete(pendingRegistrations, reg.RegistrationID) return nil, errPairingExpired } if reg.Approved { return nil, errPairingClaimed } return reg, nil } return nil, errPairingInvalid } // claimAllowed applies the per-account pairing attempt budget. userID // keys the window; IP is folded in so a token shared across browsers // still bounds per-origin abuse. func claimAllowed(userID string, ip string) bool { return pairingRateLimiter.CheckRateLimit(userID+"|"+ip, "claim", pairingRateLimitConfig) } func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error { req := DeviceRegistrationRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } registrationID := uuid.New().String() expiresAt := time.Now().Add(5 * time.Minute) pairingCode, err := generatePairingCode() if err != nil { slog.Error("pairing code generation failed", "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing code"}) } registration := &PendingRegistration{ RegistrationID: registrationID, DeviceName: req.DeviceName, DeviceType: req.DeviceType, DeviceIdentifier: req.DeviceIdentifier, PairingCode: pairingCode, ExpiresAt: expiresAt, CreatedAt: time.Now(), } pendingMu.Lock() // One live registration per device: a re-register (cancel-then-retry, // double-tap) retires the previous unclaimed attempt so exactly one // code is ever claimable for this identifier. for id, existing := range pendingRegistrations { if existing.DeviceIdentifier == req.DeviceIdentifier && !existing.Approved { delete(pendingRegistrations, id) } } pendingRegistrations[registrationID] = registration pendingMu.Unlock() authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID) qrCode, err := qrcode.Encode(authURL, qrcode.Medium, 256) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate QR code"}) } qrCodeBase64 := base64.StdEncoding.EncodeToString(qrCode) setupInstructions := map[string]string{} switch req.DeviceType { case "koreader": setupInstructions["koreader"] = fmt.Sprintf("Calibre URL: %s/api/sync/koreader", h.cfg.BaseURL) case "kobo": setupInstructions["kobo"] = fmt.Sprintf("Sync URL: %s/api/sync/kobo", h.cfg.BaseURL) } response := DeviceRegistrationResponse{ RegistrationID: registrationID, AuthURL: authURL, QRCode: "data:image/png;base64," + qrCodeBase64, PairingCode: pairingCode, ExpiresIn: 300, PollInterval: 3, SetupInstructions: setupInstructions, } return c.JSON(http.StatusCreated, response) } func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error { req := DeviceAuthStatusRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } pendingMu.Lock() registration, exists := pendingRegistrations[req.RegistrationID] if !exists { pendingMu.Unlock() return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"}) } if time.Now().After(registration.ExpiresAt) { delete(pendingRegistrations, req.RegistrationID) pendingMu.Unlock() return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) } if registration.Approved { delete(pendingRegistrations, req.RegistrationID) // Snapshot under the lock: the approver wrote these fields, and // the row is gone from the map — no other reader/writer remains. resp := DeviceAuthStatusResponse{ Status: "approved", AuthToken: registration.AuthToken, DeviceID: registration.DeviceID, SyncEndpoints: registration.SyncEndpoints, } pendingMu.Unlock() return c.JSON(http.StatusOK, resp) } pendingMu.Unlock() return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ Status: "pending", Message: "awaiting user approval", ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()), }) } // koreaderSyncEndpoints is the KOReader endpoint map shared by the // surfaces that publish it (KOReader authenticates by header, so its // URLs never embed the token). func koreaderSyncEndpoints(baseURL string) map[string]string { return map[string]string{ "progress": fmt.Sprintf("%s/api/sync/koreader/progress", baseURL), "metadata": fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL), "bookmarks": fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL), } } // deviceToInfo is the single Devices->DeviceInfo mapper for every // device surface. AuthToken rides only on surfaces that deliberately // expose it (the owner's device lists). func deviceToInfo(device database.Devices, includeAuthToken bool) DeviceInfo { syncEnabled := device.SyncEnabled.Bool && device.SyncEnabled.Valid autoSync := device.AutoSync.Bool && device.AutoSync.Valid syncFreq := int32(0) if device.SyncFrequencyMinutes.Valid { syncFreq = device.SyncFrequencyMinutes.Int32 } info := DeviceInfo{ ID: device.ID.Bytes, DeviceName: device.DeviceName, DeviceType: device.DeviceType, LastSync: &device.LastSync.Time, LastSeen: &device.LastSeen.Time, SyncEnabled: syncEnabled, AutoSync: autoSync, SyncFrequency: syncFreq, CreatedAt: device.CreatedAt.Time, DeviceMetadata: device.DeviceMetadata, } if includeAuthToken { info.AuthToken = device.AuthToken } return info } func (h *DeviceHandler) ListDevices(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"}) } pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true} devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgUserID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list devices"}) } deviceList := make([]DeviceInfo, 0, len(devices)) for _, device := range devices { deviceList = append(deviceList, deviceToInfo(device, true)) } return c.JSON(http.StatusOK, DeviceListResponse{ Devices: deviceList, Total: len(deviceList), }) } func (h *DeviceHandler) GetDevicesData(c *echo.Context) ([]DeviceInfo, error) { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return nil, err } pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true} devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgUserID) if err != nil { return nil, err } deviceList := make([]DeviceInfo, 0, len(devices)) for _, device := range devices { deviceList = append(deviceList, deviceToInfo(device, true)) } return deviceList, nil } func (h *DeviceHandler) GetDevice(c *echo.Context) error { userID := c.Get("user_id") if userID == nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) } userUUID, err := uuid.Parse(userID.(string)) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"}) } deviceID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) } if device.UserID.Bytes != userUUID { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } return c.JSON(http.StatusOK, deviceToInfo(device, false)) } func (h *DeviceHandler) UpdateDevice(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"}) } deviceID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) } if device.UserID.Bytes != userUUID { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } req := DeviceUpdateRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } updateParams := database.UpdateDeviceParams{ ID: pgDeviceID, } if req.DeviceName != "" { updateParams.DeviceName = req.DeviceName } else { updateParams.DeviceName = device.DeviceName } if req.SyncEnabled != nil { updateParams.SyncEnabled = pgtype.Bool{Bool: *req.SyncEnabled, Valid: true} } else { updateParams.SyncEnabled = device.SyncEnabled } if req.AutoSync != nil { updateParams.AutoSync = pgtype.Bool{Bool: *req.AutoSync, Valid: true} } else { updateParams.AutoSync = device.AutoSync } if req.SyncFrequencyMinutes != nil { updateParams.SyncFrequencyMinutes = pgtype.Int4{Int32: *req.SyncFrequencyMinutes, Valid: true} } else { updateParams.SyncFrequencyMinutes = device.SyncFrequencyMinutes } if req.DeviceMetadata != nil { updateParams.DeviceMetadata = req.DeviceMetadata } else { updateParams.DeviceMetadata = device.DeviceMetadata } updatedDevice, err := h.db.UpdateDevice(c.Request().Context(), updateParams) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update device"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "device_updated": true, "device": deviceToInfo(updatedDevice, false), }) } func (h *DeviceHandler) DeleteDevice(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"}) } deviceID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) } if device.UserID.Bytes != userUUID { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } // Detach reading history first: the history FK to devices is NO // ACTION on purpose (rows are real captured stats); NULLing the // device association keeps the stats while freeing the device. if err := h.db.DetachReadingHistoryFromDevice(c.Request().Context(), pgDeviceID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to detach device history"}) } if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"}) } return c.NoContent(http.StatusNoContent) } func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error { // Verify JWT authentication userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user ID"}) } // Parse device ID from URL parameter deviceID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} // Verify device exists and belongs to user device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) } if device.UserID.Bytes != userUUID { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } // Generate new auth token newToken, err := generateDeviceToken() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) } // Update device with new token updatedDevice, err := h.db.UpdateDeviceAuthToken(c.Request().Context(), database.UpdateDeviceAuthTokenParams{ ID: pgDeviceID, AuthToken: newToken, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update token"}) } // Return new token with device info // Build sync URLs with new token syncURLs := map[string]string{} baseURL := h.cfg.BaseURL switch updatedDevice.DeviceType { case "kobo": syncURLs["sync_url"] = fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, newToken) syncURLs["markup"] = fmt.Sprintf("%s/api/sync/kobo/%s/markup", baseURL, newToken) syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken) syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken) case "koreader": syncURLs = koreaderSyncEndpoints(baseURL) } // Handle HTMX requests - return HTML with page reload script if c.Request().Header.Get("HX-Request") == "true" { // CSP: no inline scripts in partials; HX-Refresh makes htmx // reload the page natively (the old script tag was also unclosed). c.Response().Header().Set("HX-Refresh", "true") html := `