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 := `
Token regenerated! Reloading ...
` return c.HTML(http.StatusOK, html) } return c.JSON(http.StatusOK, map[string]interface{}{ "message": "Token regenerated successfully", "auth_token": newToken, "device": deviceToInfo(updatedDevice, false), "sync_urls": syncURLs, }) } // errIdentifierOwnedByOther marks the cross-account case: the device // identifier's existing row belongs to a different user, so neither // approval nor claim may touch it. var errIdentifierOwnedByOther = errors.New("device identifier registered to another user") // bindRegistrationToUser creates or rotates the devices row for a pending // registration and returns the row, its fresh token, and the sync // endpoints for the device type. Shared by ApproveDevice (Android // self-approval by registration UUID) and the claim-by-code endpoint; // callers publish the result under pendingMu themselves. func (h *DeviceHandler) bindRegistrationToUser(ctx context.Context, registration *PendingRegistration, pgUserID pgtype.UUID) (database.Devices, string, map[string]string, error) { authToken, err := generateDeviceToken() if err != nil { return database.Devices{}, "", nil, fmt.Errorf("generate token: %w", err) } // Re-approval: app reinstalls that preserve data (e.g. Android Studio // installDebug over an existing install) re-register with the SAME // device_identifier, but the devices row from the previous install // still exists — device_identifier is UNIQUE, so a blind INSERT fails // with a unique violation (the historical "approve returns 500 after // reinstall" bug). Look the device up first and rotate its token // instead; a fresh install MUST get fresh credentials, so the old // token is invalidated either way. rotateExisting := func(existing database.Devices) (database.Devices, bool, error) { // The identifier is globally unique; a row owned by another user // means cross-account identifier reuse — refuse it. if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes { return existing, false, errIdentifierOwnedByOther } updated, err := h.db.UpdateDeviceAuthToken(ctx, database.UpdateDeviceAuthTokenParams{ ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true}, AuthToken: authToken, }) return updated, true, err } existing, err := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier) var device database.Devices switch { case err == nil: // Known device: rotate the token, keep the row (id unchanged — // highlights/bookmarks/progress anchored to it stay valid). device, _, err = rotateExisting(existing) if err != nil { return database.Devices{}, "", nil, err } case errors.Is(err, pgx.ErrNoRows): device, err = h.db.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgUserID, DeviceName: registration.DeviceName, DeviceType: registration.DeviceType, DeviceIdentifier: registration.DeviceIdentifier, AuthToken: authToken, SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, DeviceMetadata: []byte("{}"), }) if err != nil { // Concurrent approve racing us onto the unique index: fall // through to the rotate path for the winner's row. var pgErr *pgconn.PgError if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" { if winner, lerr := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier); lerr == nil { device, _, err = rotateExisting(winner) } } } if err != nil { return database.Devices{}, "", nil, fmt.Errorf("create device: %w", err) } default: return database.Devices{}, "", nil, fmt.Errorf("look up device: %w", err) } syncEndpoints := map[string]string{} switch registration.DeviceType { case "koreader": syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL) case "kobo": syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL) syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL) } return device, authToken, syncEndpoints, nil } func (h *DeviceHandler) ApproveDevice(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"}) } registrationID := c.Param("registration_id") pendingMu.Lock() registration, exists := pendingRegistrations[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, registrationID) pendingMu.Unlock() return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) } if registration.Approved { pendingMu.Unlock() return c.JSON(http.StatusOK, map[string]interface{}{ "message": "device already approved", "device_name": registration.DeviceName, "device_type": registration.DeviceType, "approved": true, }) } pendingMu.Unlock() pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true} device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID) if err != nil { if errors.Is(err, errIdentifierOwnedByOther) { return c.JSON(http.StatusConflict, map[string]string{"error": "device identifier already registered to another user"}) } slog.Error("device approval bind failed", "registration_id", registrationID, "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to approve device"}) } // Publish the credentials under the map lock: the status poller // snapshots these fields only after Approved flips, so the token can // never be read half-written. pendingMu.Lock() registration.UserID = userUUID registration.Approved = true registration.AuthToken = authToken registration.DeviceID = device.ID.Bytes registration.SyncEndpoints = syncEndpoints pendingMu.Unlock() return c.JSON(http.StatusOK, map[string]interface{}{ "message": "device approved successfully", "device_name": registration.DeviceName, "device_type": registration.DeviceType, "registration_id": registrationID, "approved": true, }) } // errPairingRateLimited marks an exhausted per-account attempt budget. var errPairingRateLimited = errors.New("too many pairing attempts") // resolvePairingCode is the shared lookup core for the JSON API and the // SSR confirm-card partial: apply the per-account budget, normalize the // typed code, resolve to a live unclaimed registration. func resolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) { if !claimAllowed(userID, ip) { return nil, errPairingRateLimited } return registrationByPairingCode(normalizePairingCode(rawCode)) } // pairingHTTPError maps a resolvePairingCode error to status + message. // The invalid-code case is deliberately generic: it must not reveal // whether any registration is waiting. func pairingHTTPError(err error) (int, string) { switch { case errors.Is(err, errPairingRateLimited): return http.StatusTooManyRequests, "too many pairing attempts; try again in a minute" case errors.Is(err, errPairingExpired): return http.StatusGone, "pairing code expired — register the device again" case errors.Is(err, errPairingClaimed): return http.StatusConflict, "pairing code already used" default: return http.StatusBadRequest, "invalid pairing code" } } // ResolvePairingCode is the exported lookup core for the router's SSR // confirm-card partial (the router renders templ; handlers stay JSON). func ResolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) { return resolvePairingCode(userID, ip, rawCode) } // PairingErrorStatus maps a ResolvePairingCode error to the status key // consumed by templates.PairingLookupResult. func PairingErrorStatus(err error) string { switch { case errors.Is(err, errPairingRateLimited): return "rate_limited" case errors.Is(err, errPairingExpired): return "expired" case errors.Is(err, errPairingClaimed): return "claimed" default: return "invalid" } } // LookupPairingCode resolves a pairing code to its pending registration // WITHOUT binding it — the confirm card's data. Lookups and claims share // the per-account attempt budget. func (h *DeviceHandler) LookupPairingCode(c *echo.Context) error { userID := c.Get("user_id").(string) req := PairingCodeRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode) if err != nil { status, msg := pairingHTTPError(err) return c.JSON(status, map[string]string{"error": msg}) } return c.JSON(http.StatusOK, PairingLookupResponse{ DeviceName: registration.DeviceName, DeviceType: registration.DeviceType, DeviceIdentifier: registration.DeviceIdentifier, ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()), }) } // ClaimDevice binds the registration behind a pairing code to the // CALLER's account — the web-side replacement for the broadcast-list // approve. The device's own poll (registration UUID bearer) then delivers // the fresh token to the plugin, exactly as in the old approve flow. func (h *DeviceHandler) ClaimDevice(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"}) } if !claimAllowed(userID, c.RealIP()) { return c.JSON(http.StatusTooManyRequests, map[string]string{"error": "too many pairing attempts; try again in a minute"}) } req := PairingCodeRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode) if err != nil { status, msg := pairingHTTPError(err) return c.JSON(status, map[string]string{"error": msg}) } pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true} device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID) if err != nil { if errors.Is(err, errIdentifierOwnedByOther) { return c.JSON(http.StatusConflict, map[string]string{"error": "device identifier already registered to another user"}) } slog.Error("device claim bind failed", "error", err) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to pair device"}) } // Publish under the map lock: the device's status poller snapshots // these fields only after Approved flips, so the token can never be // read half-written. pendingMu.Lock() registration.UserID = userUUID registration.Approved = true registration.AuthToken = authToken registration.DeviceID = device.ID.Bytes registration.SyncEndpoints = syncEndpoints pendingMu.Unlock() // HTMX confirm button: htmx reloads the page natively (CSP — no // inline scripts in the partial). if c.Request().Header.Get("HX-Request") == "true" { c.Response().Header().Set("HX-Refresh", "true") return c.HTML(http.StatusOK, `
Paired! Reloading …
`) } return c.JSON(http.StatusOK, map[string]interface{}{ "message": "device paired successfully", "device_name": registration.DeviceName, "device_type": registration.DeviceType, "approved": true, }) } // CancelRegistration lets the REGISTERING device end its own pairing // attempt (dismissed waiting dialog). The registration UUID is the // bearer — same trust as the status poll — so a device can only ever // cancel its own registration, and a claimed one is left alone (its // poller is mid-delivery of the token). func (h *DeviceHandler) CancelRegistration(c *echo.Context) error { req := CancelRegistrationRequest{} if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } pendingMu.Lock() if reg, exists := pendingRegistrations[req.RegistrationID]; exists && !reg.Approved { delete(pendingRegistrations, req.RegistrationID) } pendingMu.Unlock() return c.JSON(http.StatusOK, map[string]string{"message": "registration cancelled"}) } // GetPendingRegistrationsData returns nothing, deliberately. Unclaimed // registrations are invisible since pairing codes took over the approval // flow: first-approve-wins on a broadcast list let any account claim (or // reject) a device it could not even name. Claiming happens by typing the // code shown on the registering device's screen. Kept as an always-empty // shape so API consumers (and the Android app's docs) keep compiling. func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) { return []map[string]interface{}{}, nil } // ListPendingRegistrations — see GetPendingRegistrationsData: the // broadcast is dead; unclaimed registrations belong to no one's list. func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error { registrations := []map[string]interface{}{} return c.JSON(http.StatusOK, map[string]interface{}{ "registrations": registrations, "pending_registrations": registrations, // test/API-shape compatibility "total": 0, }) } func generateDeviceToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } hashedToken, err := bcrypt.GenerateFromPassword(bytes, bcrypt.DefaultCost) if err != nil { return "", err } token := base64.StdEncoding.EncodeToString(hashedToken) return fmt.Sprintf("dev_%s", token), nil }