The browser's Pair device button submits an application/x-www-form- urlencoded <form> (htmx), but PairingCodeRequest/CancelRegistration- Request carried only json: tags. Echo v5 routes form posts to the 'form:' binder, which maps by the form: struct tag exclusively — the code arrived empty, normalized to "", and every browser claim failed with the generic 'invalid pairing code' 400 even though the separate lookup step (which reads c.FormValue directly, no struct) had just resolved the same code. The API clients (plugin, Android, tests) post JSON and were never affected. Both request structs now carry dual form:/json: tags, matching the auth.go convention. Adds TestPairingClaimAcceptsBrowserFormEncoding to pin the form path: form-encoded lookup resolves, form-encoded claim binds, device poll delivers credentials. Found by the user clicking through the real Add Device modal; the integration suite missed it because every prior claim test posted application/json.
1027 lines
35 KiB
Go
1027 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 {
|
|
// form tag mirrors json: htmx posts urlencoded, the API posts JSON —
|
|
// echo v5's form binder maps by `form:` tag only (auth.go convention).
|
|
PairingCode string `form:"pairing_code" 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 `form:"registration_id" 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
|
|
}
|