package handlers import ( "bookmann/internal/config" "bookmann/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/v4" "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"` } 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 } 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.UserID == (uuid.UUID{}) { return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ Status: "pending", Message: "awaiting user approval", ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()), }) } authToken, err := generateDeviceToken() if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"}) } userUUID := registration.UserID 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"}) } delete(pendingRegistrations, req.RegistrationID) 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) } return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ Status: "approved", AuthToken: authToken, DeviceID: device.ID.Bytes, SyncEndpoints: syncEndpoints, }) } 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: (*time.Time)(&device.LastSync.Time), LastSeen: (*time.Time)(&device.LastSeen.Time), SyncEnabled: syncEnabled, AutoSync: autoSync, SyncFrequency: syncFreq, CreatedAt: device.CreatedAt.Time, DeviceMetadata: device.DeviceMetadata, }) } return c.JSON(http.StatusOK, DeviceListResponse{ Devices: deviceList, Total: len(deviceList), }) } 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: (*time.Time)(&device.LastSync.Time), LastSeen: (*time.Time)(&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 } return c.JSON(http.StatusOK, map[string]interface{}{ "device_updated": true, "device": DeviceInfo{ ID: updatedDevice.ID.Bytes, DeviceName: updatedDevice.DeviceName, DeviceType: updatedDevice.DeviceType, LastSync: (*time.Time)(&updatedDevice.LastSync.Time), LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time), SyncEnabled: syncEnabled, AutoSync: autoSync, SyncFrequency: syncFreq, 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) 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"}) } registration.UserID = userUUID return c.JSON(http.StatusOK, map[string]interface{}{ "message": "device approved successfully", "device_name": registration.DeviceName, "device_type": registration.DeviceType, "registration_id": registrationID, }) } 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) 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, "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 }