A pending device registration used to be announced to EVERY account's Devices page, and whoever clicked Approve first bound the device — and its freshly minted sync token — to their account. Worse, the pending card displayed the registration UUID, which is also the bearer credential on the unauthenticated /api/devices/register/status poll channel: any family user who could see the list already held everything needed to take the device. On a family server that is a wrong-account-binding bug (accidental or deliberate); this commit removes the class of problem rather than patching the race. The new model is the Netflix/YouTube TV device flow: the registering device displays a 6-digit pairing code, and the only person who can read it — whoever holds the device — types it into THEIR account on the Devices page. Possession of the device screen becomes the credential; nothing is broadcast; the reject-on-someone-else's-behalf hole dies with the list. Server changes: - POST /api/devices/register now mints pairing_code (crypto/rand, rejection-sampled for uniformity, collision-checked against live registrations) and retires prior unclaimed registrations with the same device_identifier, so re-registering leaves exactly one live attempt. - POST /api/devices/claim (JWT) binds the registration behind a code to the caller's account, reusing the rotate-or-create approval logic factored out of ApproveDevice (shared bindRegistrationToUser helper; the Android self-approval route keeps working unchanged). - POST /api/devices/claim/lookup (JWT) returns the confirm-card data without binding. Lookup and claim share a per-account budget (12 attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code space over a 5-minute TTL makes guessing infeasible without it. - POST /api/devices/register/cancel lets the registering device end its own attempt (registration UUID bearer, idempotent, never touches a claimed registration). - ListPendingRegistrations / GetPendingRegistrationsData are deprecated-by-emptiness: unclaimed registrations return to nobody. - RejectDevice and its route are deleted — the broadcast list was its only consumer, and there is nothing left for anyone to reject. Additive-only for pinned surfaces: the register response gains pairing_code (Android DTO untouched), the four KOReader sync surfaces and approve keep byte-identical shapes. contract_pins.md changelog records the widening and the reject removal. Docs, schema registry, and bruno mirrors move together per the drift gate; bruno_gen.go now keeps hand-authored bruno/podcasts/ (API docs never existed for them, so the next -update run would have swept them — latent landmine, defused). Unit tests: code shape, live-collision avoidance, typed-code normalization, and the resolve scopes (live/expired/claimed/miss).
1025 lines
35 KiB
Go
1025 lines
35 KiB
Go
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 := `<div class="text-green-500">Token regenerated! Reloading ... </div>`
|
|
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, `<div class="text-green-500">Paired! Reloading …</div>`)
|
|
}
|
|
|
|
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
|
|
}
|