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) } 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"}) } if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"}) } return c.NoContent(http.StatusNoContent) } func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error { // Verify JWT authentication userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user ID"}) } // Parse device ID from URL parameter deviceID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} // Verify device exists and belongs to user device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) } if device.UserID.Bytes != userUUID { return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) } // Generate new auth token newToken, err := generateDeviceToken() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) } // Update device with new token updatedDevice, err := h.db.UpdateDeviceAuthToken(c.Request().Context(), database.UpdateDeviceAuthTokenParams{ ID: pgDeviceID, AuthToken: newToken, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update token"}) } // Return new token with device info // Build sync URLs with new token syncURLs := map[string]string{} baseURL := h.cfg.BaseURL switch updatedDevice.DeviceType { case "kobo": syncURLs["sync_url"] = fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, newToken) syncURLs["markup"] = fmt.Sprintf("%s/api/sync/kobo/%s/markup", baseURL, newToken) syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken) syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken) case "koreader": syncURLs = koreaderSyncEndpoints(baseURL) } // Handle HTMX requests - return HTML with page reload script if c.Request().Header.Get("HX-Request") == "true" { // CSP: no inline scripts in partials; HX-Refresh makes htmx // reload the page natively (the old script tag was also unclosed). c.Response().Header().Set("HX-Refresh", "true") html := `
Token regenerated! Reloading ...
` return c.HTML(http.StatusOK, html) } return c.JSON(http.StatusOK, map[string]interface{}{ "message": "Token regenerated successfully", "auth_token": newToken, "device": deviceToInfo(updatedDevice, false), "sync_urls": syncURLs, }) } 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 = 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) } // 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 }