Files
john-okeefe 8e4412544d fix(devices): move device creation from CheckRegistration to ApproveDevice
Previously device creation happened in CheckRegistrationStatus (polling
endpoint), which was racy. Now the admin's ApproveDevice handler
creates the device record and stores auth token + device ID on the
registration entry. CheckRegistrationStatus just returns the pre-created
credentials.

Also adds approved/authToken/deviceID/syncEndpoints fields to
PendingRegistration struct.
2026-06-02 19:45:14 -04:00

708 lines
23 KiB
Go

package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"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
}
var pendingRegistrations = make(map[string]*PendingRegistration)
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(),
}
pendingRegistrations[registrationID] = registration
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"})
}
registration, exists := pendingRegistrations[req.RegistrationID]
if !exists {
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
}
if time.Now().After(registration.ExpiresAt) {
delete(pendingRegistrations, req.RegistrationID)
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
}
if registration.Approved {
delete(pendingRegistrations, req.RegistrationID)
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "approved",
AuthToken: registration.AuthToken,
DeviceID: registration.DeviceID,
SyncEndpoints: registration.SyncEndpoints,
})
}
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")
registration, exists := pendingRegistrations[registrationID]
if !exists {
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
}
if time.Now().After(registration.ExpiresAt) {
delete(pendingRegistrations, registrationID)
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
}
if registration.Approved {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device already approved",
"device_name": registration.DeviceName,
"device_type": registration.DeviceType,
"approved": true,
})
}
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}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequencyMinutes: syncFreq,
DeviceMetadata: []byte("{}"),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create 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)
}
registration.UserID = userUUID
registration.Approved = true
registration.AuthToken = authToken
registration.DeviceID = device.ID.Bytes
registration.SyncEndpoints = syncEndpoints
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")
_, exists := pendingRegistrations[registrationID]
if !exists {
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
}
delete(pendingRegistrations, registrationID)
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{}{}
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,
})
}
}
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{}{}
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{}),
})
}
}
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
}