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 := `
Token regenerated! Reloading ...