Files
bookhoard/internal/handlers/devices.go
T
John O'Keefe 1cd8557b58 fix(devices): re-approving a known device rotates its token instead of 500
App reinstalls that preserve data (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 ApproveDevice's blind INSERT failed with a unique
violation and returned 500 'failed to create device' (reproduced via
curl: second approve with the same identifier = instant 500; the ~98s
in the original report was app-side retry/polling, not server wait).

ApproveDevice is now idempotent: look the device up by identifier
first; a row owned by the approving user gets its auth token rotated
via UpdateDeviceAuthToken (row id unchanged, so synced highlights/
bookmarks/progress anchored to it stay valid; fresh install = fresh
credentials, old token invalidated); a row owned by another user gets
409; unknown identifiers INSERT as before, with the 23505 race falling
through to the rotate path. DB failures are logged (they were silent).

Also guard the in-memory pendingRegistrations map with a mutex —
register/approve/reject/status/list all touch it from HTTP goroutines,
and a racing write is a Go runtime fatal, not an error. The approver's
credential publication and the status poller's approved-branch snapshot
now run under the lock so the token can never be read half-written.

Regression test: TestApproveDeviceReapprovalRotatesToken — register →
approve → re-register same identifier → approve (must be 200) → token
rotated, exactly one devices row, row carries the new token.
2026-09-17 23:09:02 -04:00

810 lines
26 KiB
Go

package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"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"`
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"`
}
type PendingRegistration struct {
RegistrationID string
DeviceName string
DeviceType string
DeviceIdentifier 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
)
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)
registration := &PendingRegistration{
RegistrationID: registrationID,
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
DeviceIdentifier: req.DeviceIdentifier,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}
pendingMu.Lock()
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,
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)
}
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "pending",
Message: "awaiting user approval",
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
})
}
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 {
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
}
deviceList = append(deviceList, 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,
AuthToken: device.AuthToken,
})
}
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 {
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
}
deviceList = append(deviceList, 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,
AuthToken: device.AuthToken,
})
}
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"})
}
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
}
return c.JSON(http.StatusOK, 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,
})
}
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"})
}
syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid
autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid
syncFreq := int32(0)
if updatedDevice.SyncFrequencyMinutes.Valid {
syncFreq = updatedDevice.SyncFrequencyMinutes.Int32 // Fixed: Use actual updated value
}
return c.JSON(http.StatusOK, map[string]interface{}{
"device_updated": true,
"device": DeviceInfo{
ID: updatedDevice.ID.Bytes,
DeviceName: updatedDevice.DeviceName,
DeviceType: updatedDevice.DeviceType,
LastSync: &updatedDevice.LastSync.Time,
LastSeen: &updatedDevice.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq, // Now correctly returns the updated value
CreatedAt: updatedDevice.CreatedAt.Time,
DeviceMetadata: updatedDevice.DeviceMetadata,
},
})
}
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"})
}
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
syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid
autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid
syncFreq := int32(0)
if updatedDevice.SyncFrequencyMinutes.Valid {
syncFreq = updatedDevice.SyncFrequencyMinutes.Int32
}
// 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["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL)
syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL)
syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL)
}
// Handle HTMX requests - return HTML with page reload script
if c.Request().Header.Get("HX-Request") == "true" {
html := `<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();`
return c.HTML(http.StatusOK, html)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Token regenerated successfully",
"auth_token": newToken,
"device": DeviceInfo{
ID: updatedDevice.ID.Bytes,
DeviceName: updatedDevice.DeviceName,
DeviceType: updatedDevice.DeviceType,
LastSync: &updatedDevice.LastSync.Time,
LastSeen: &updatedDevice.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
CreatedAt: updatedDevice.CreatedAt.Time,
DeviceMetadata: updatedDevice.DeviceMetadata,
},
"sync_urls": syncURLs,
})
}
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()
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
// 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, nil
}
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
database.UpdateDeviceAuthTokenParams{
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
AuthToken: authToken,
})
return updated, true, err
}
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
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).
var ok bool
device, ok, err = rotateExisting(existing)
if err != nil {
slog.Error("device re-approval failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to approve device"})
}
if !ok {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
case errors.Is(err, pgx.ErrNoRows):
device, err = h.db.CreateDevice(c.Request().Context(), 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(
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
var ok bool
device, ok, err = rotateExisting(winner)
if err == nil && !ok {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
}
}
}
if err != nil {
slog.Error("device creation failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to create device"})
}
default:
slog.Error("device lookup failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to look up device"})
}
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", 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)
}
// 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,
})
}
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
registrationID := c.Param("registration_id")
pendingMu.Lock()
_, exists := pendingRegistrations[registrationID]
if !exists {
pendingMu.Unlock()
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
}
delete(pendingRegistrations, registrationID)
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]string{
"message": "device registration rejected",
})
}
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations {
if reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
"device_type": reg.DeviceType,
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": false,
})
}
}
pendingMu.Unlock()
return registrations, nil
}
func (h *DeviceHandler) ListPendingRegistrations(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"})
}
registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
"device_type": reg.DeviceType,
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": reg.UserID != (uuid.UUID{}),
})
}
}
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]interface{}{
"registrations": registrations,
"pending_registrations": registrations, // Fixed: Add for test compatibility and API clarity
"total": len(registrations),
})
}
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
}
func ValidateDeviceToken(hashedToken string, plainToken string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedToken), []byte(plainToken))
return err == nil
}