diff --git a/bruno/devices/Check Registration Status.bru b/bruno/devices/Check Registration Status.bru new file mode 100644 index 0000000..831581c --- /dev/null +++ b/bruno/devices/Check Registration Status.bru @@ -0,0 +1,17 @@ +meta { + name: Check Registration Status + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/devices/register/status + body: json + auth: none +} + +body:json { + { + "registration_id": "{{registrationId}}" + } +} diff --git a/bruno/devices/Delete Device.bru b/bruno/devices/Delete Device.bru new file mode 100644 index 0000000..0ed175a --- /dev/null +++ b/bruno/devices/Delete Device.bru @@ -0,0 +1,15 @@ +meta { + name: Delete Device + type: http + seq: 6 +} + +delete { + url: {{baseUrl}}/api/devices/{{deviceId}} + body: none + auth: bearer +} + +headers: { + Authorization: Bearer {{token}} +} diff --git a/bruno/devices/Get Device.bru b/bruno/devices/Get Device.bru new file mode 100644 index 0000000..9011b17 --- /dev/null +++ b/bruno/devices/Get Device.bru @@ -0,0 +1,15 @@ +meta { + name: Get Device + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/devices/{{deviceId}} + body: none + auth: bearer +} + +headers: { + Authorization: Bearer {{token}} +} diff --git a/bruno/devices/Initiate Device Registration.bru b/bruno/devices/Initiate Device Registration.bru new file mode 100644 index 0000000..fe616ed --- /dev/null +++ b/bruno/devices/Initiate Device Registration.bru @@ -0,0 +1,19 @@ +meta { + name: Initiate Device Registration + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/devices/register + body: json + auth: none +} + +body:json { + { + "device_name": "My Kindle Paperwhite", + "device_type": "koreader", + "device_identifier": "kindle-pw5-hardware-id-12345" + } +} diff --git a/bruno/devices/List Devices.bru b/bruno/devices/List Devices.bru new file mode 100644 index 0000000..747328b --- /dev/null +++ b/bruno/devices/List Devices.bru @@ -0,0 +1,15 @@ +meta { + name: List Devices + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/devices + body: none + auth: bearer +} + +headers: { + Authorization: Bearer {{token}} +} diff --git a/bruno/devices/Update Device.bru b/bruno/devices/Update Device.bru new file mode 100644 index 0000000..05ace83 --- /dev/null +++ b/bruno/devices/Update Device.bru @@ -0,0 +1,24 @@ +meta { + name: Update Device + type: http + seq: 5 +} + +put { + url: {{baseUrl}}/api/devices/{{deviceId}} + body: json + auth: bearer +} + +headers: { + Authorization: Bearer {{token}} +} + +body:json { + { + "device_name": "My Updated Kindle", + "sync_enabled": true, + "auto_sync": true, + "sync_frequency_minutes": 10 + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 619e3fc..b75f31d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,6 +4,7 @@ import ( "bookmann/internal/config" "bookmann/internal/database" "bookmann/internal/handlers" + "bookmann/internal/middleware" ratelimit "bookmann/internal/middleware" "bookmann/templates" "bytes" @@ -48,6 +49,10 @@ func main() { authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) libraryHandler := handlers.NewLibraryHandler(queries) + deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) + + deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) + _ = deviceAuthMiddleware e := echo.New() @@ -151,6 +156,20 @@ func main() { authGroup.PUT("/theme", authHandler.UpdateTheme) // force rebuild + // Device management routes (public - for registration) + e.POST("/api/devices/register", deviceHandler.InitiateRegistration) + e.POST("/api/devices/register/status", deviceHandler.CheckRegistrationStatus) + e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice) + e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice) + + // Device management routes (protected - require user auth) + devices := protected.Group("/devices") + devices.GET("", deviceHandler.ListDevices) + devices.GET("/:id", deviceHandler.GetDevice) + devices.PUT("/:id", deviceHandler.UpdateDevice) + devices.DELETE("/:id", deviceHandler.DeleteDevice) + devices.GET("/pending", deviceHandler.ListPendingRegistrations) + // Static files e.Static("/static", "web/static") diff --git a/go.mod b/go.mod index 9f40352..9b11431 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/jackc/pgx/v5 v5.4.3 github.com/labstack/echo-jwt/v4 v4.4.0 github.com/labstack/echo/v4 v4.13.4 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 ) diff --git a/go.sum b/go.sum index e7479be..73654d5 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/config/config.go b/internal/config/config.go index 310c8a7..c68cd54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( type Config struct { ServerPort string + BaseURL string JWTSecret string UploadPath string DatabaseHost string @@ -23,6 +24,7 @@ type Config struct { func LoadConfig() *Config { return &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), + BaseURL: getEnv("BASE_URL", "http://localhost:8080"), DatabaseHost: getEnv("DATABASE_HOST", "localhost"), DatabasePort: getEnv("DATABASE_PORT", "5432"), DatabaseUser: getEnv("DATABASE_USER", "postgres"), diff --git a/internal/database/querier.go b/internal/database/querier.go index 257d4fb..df60c7d 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -16,6 +16,12 @@ type Querier interface { // Bulk update format group for all media items BulkUpdateFormatGroups(ctx context.Context) error CleanupExpiredRefreshTokens(ctx context.Context) error + ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error + // ============================================ + // PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) + // ============================================ + // Device Registration & Management + CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error) // Backward compatibility - Ebook Notes queries (using views) CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error) // Libraries queries @@ -31,7 +37,13 @@ type Querier interface { CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error) // Refresh Tokens queries CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) + // Conflict Resolution + CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) + // Sync Queue Management + CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) + DeleteDevice(ctx context.Context, id pgtype.UUID) error + DeleteDeviceByToken(ctx context.Context, authToken string) error DeleteEbookNote(ctx context.Context, id pgtype.UUID) error DeleteLibrary(ctx context.Context, id pgtype.UUID) error DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error) @@ -40,7 +52,12 @@ type Querier interface { DeleteMediaNote(ctx context.Context, id pgtype.UUID) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error + DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error + DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error + GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) + GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) + GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error) @@ -62,6 +79,8 @@ type Querier interface { GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error) + GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) + GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) // Get universal progress for a book GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) @@ -71,13 +90,20 @@ type Querier interface { GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) + ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) + ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error) + ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) + ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) + ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) + ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error + RevokeDevice(ctx context.Context, id pgtype.UUID) error RevokeRefreshToken(ctx context.Context, token string) error // Note: User ebook folders replaced by library folders system // Legacy folder management is now handled through libraries @@ -86,6 +112,9 @@ type Querier interface { SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) // Library Visibility queries SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) + UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) + UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) + UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) @@ -101,6 +130,7 @@ type Querier interface { UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error + UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error) // Update universal progress UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index f617486..3067631 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -59,6 +59,70 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error { return err } +const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec +DELETE FROM sync_queue WHERE device_id = $1 +` + +func (q *Queries) ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error { + _, err := q.db.Exec(ctx, ClearDeviceSyncQueue, deviceID) + return err +} + +const CreateDevice = `-- name: CreateDevice :one + +INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at +` + +type CreateDeviceParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` + DeviceIdentifier string `db:"device_identifier" json:"device_identifier"` + AuthToken string `db:"auth_token" json:"auth_token"` + SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"` + AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"` + SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"` + DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"` +} + +// ============================================ +// PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) +// ============================================ +// Device Registration & Management +func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error) { + row := q.db.QueryRow(ctx, CreateDevice, + arg.UserID, + arg.DeviceName, + arg.DeviceType, + arg.DeviceIdentifier, + arg.AuthToken, + arg.SyncEnabled, + arg.AutoSync, + arg.SyncFrequencyMinutes, + arg.DeviceMetadata, + ) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const CreateEbookNote = `-- name: CreateEbookNote :one INSERT INTO media_notes (media_item_id, user_id, content, position) VALUES ($1, $2, $3, $4) @@ -447,6 +511,88 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken return i, err } +const CreateSyncConflict = `-- name: CreateSyncConflict :one +INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data) +VALUES ($1, $2, $3, $4) +RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at +` + +type CreateSyncConflictParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + ConflictType string `db:"conflict_type" json:"conflict_type"` + ConflictData []byte `db:"conflict_data" json:"conflict_data"` +} + +// Conflict Resolution +func (q *Queries) CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) { + row := q.db.QueryRow(ctx, CreateSyncConflict, + arg.MediaItemID, + arg.UserID, + arg.ConflictType, + arg.ConflictData, + ) + var i SyncConflicts + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ) + return i, err +} + +const CreateSyncQueueItem = `-- name: CreateSyncQueueItem :one +INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at +` + +type CreateSyncQueueItemParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + SyncType string `db:"sync_type" json:"sync_type"` + SyncData []byte `db:"sync_data" json:"sync_data"` + Priority pgtype.Int4 `db:"priority" json:"priority"` + MaxAttempts pgtype.Int4 `db:"max_attempts" json:"max_attempts"` + Status pgtype.Text `db:"status" json:"status"` +} + +// Sync Queue Management +func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) { + row := q.db.QueryRow(ctx, CreateSyncQueueItem, + arg.DeviceID, + arg.MediaItemID, + arg.SyncType, + arg.SyncData, + arg.Priority, + arg.MaxAttempts, + arg.Status, + ) + var i SyncQueue + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.SyncType, + &i.SyncData, + &i.Priority, + &i.Attempts, + &i.MaxAttempts, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + &i.ProcessedAt, + ) + return i, err +} + const CreateUser = `-- name: CreateUser :one INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -500,6 +646,24 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU return i, err } +const DeleteDevice = `-- name: DeleteDevice :exec +DELETE FROM devices WHERE id = $1 +` + +func (q *Queries) DeleteDevice(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteDevice, id) + return err +} + +const DeleteDeviceByToken = `-- name: DeleteDeviceByToken :exec +DELETE FROM devices WHERE auth_token = $1 +` + +func (q *Queries) DeleteDeviceByToken(ctx context.Context, authToken string) error { + _, err := q.db.Exec(ctx, DeleteDeviceByToken, authToken) + return err +} + const DeleteEbookNote = `-- name: DeleteEbookNote :exec DELETE FROM media_notes WHERE id = $1 ` @@ -594,6 +758,24 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr return err } +const DeleteSyncConflict = `-- name: DeleteSyncConflict :exec +DELETE FROM sync_conflicts WHERE id = $1 +` + +func (q *Queries) DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteSyncConflict, id) + return err +} + +const DeleteSyncQueueItem = `-- name: DeleteSyncQueueItem :exec +DELETE FROM sync_queue WHERE id = $1 +` + +func (q *Queries) DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteSyncQueueItem, id) + return err +} + const DeleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = $1 ` @@ -603,6 +785,84 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { return err } +const GetDevice = `-- name: GetDevice :one +SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE id = $1 +` + +func (q *Queries) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) { + row := q.db.QueryRow(ctx, GetDevice, id) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetDeviceByAuthToken = `-- name: GetDeviceByAuthToken :one +SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE auth_token = $1 +` + +func (q *Queries) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) { + row := q.db.QueryRow(ctx, GetDeviceByAuthToken, authToken) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const GetDeviceByIdentifier = `-- name: GetDeviceByIdentifier :one +SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_identifier = $1 +` + +func (q *Queries) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) { + row := q.db.QueryRow(ctx, GetDeviceByIdentifier, deviceIdentifier) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const GetLibrary = `-- name: GetLibrary :one SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description FROM libraries l @@ -1243,6 +1503,52 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS return i, err } +const GetSyncConflict = `-- name: GetSyncConflict :one +SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts WHERE id = $1 +` + +func (q *Queries) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) { + row := q.db.QueryRow(ctx, GetSyncConflict, id) + var i SyncConflicts + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ) + return i, err +} + +const GetSyncQueueItem = `-- name: GetSyncQueueItem :one +SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue WHERE id = $1 +` + +func (q *Queries) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) { + row := q.db.QueryRow(ctx, GetSyncQueueItem, id) + var i SyncQueue + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.SyncType, + &i.SyncData, + &i.Priority, + &i.Attempts, + &i.MaxAttempts, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + &i.ProcessedAt, + ) + return i, err +} + const GetUniversalProgress = `-- name: GetUniversalProgress :one SELECT rp.id, @@ -1586,6 +1892,84 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI return items, nil } +const ListDevicesByType = `-- name: ListDevicesByType :many +SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) { + rows, err := q.db.Query(ctx, ListDevicesByType, deviceType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Devices{} + for rows.Next() { + var i Devices + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListDevicesByUser = `-- name: ListDevicesByUser :many +SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE user_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) { + rows, err := q.db.Query(ctx, ListDevicesByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Devices{} + for rows.Next() { + var i Devices + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListLibraries = `-- name: ListLibraries :many SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description FROM libraries l @@ -2200,6 +2584,149 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo return items, nil } +const ListPendingSyncQueueItems = `-- name: ListPendingSyncQueueItems :many +SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue +WHERE device_id = $1 AND status = 'pending' +ORDER BY priority ASC, created_at ASC +LIMIT $2 +` + +type ListPendingSyncQueueItemsParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + Limit int32 `db:"limit" json:"limit"` +} + +func (q *Queries) ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) { + rows, err := q.db.Query(ctx, ListPendingSyncQueueItems, arg.DeviceID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SyncQueue{} + for rows.Next() { + var i SyncQueue + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.SyncType, + &i.SyncData, + &i.Priority, + &i.Attempts, + &i.MaxAttempts, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + &i.ProcessedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListSyncConflictsByMediaItem = `-- name: ListSyncConflictsByMediaItem :many +SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts +WHERE media_item_id = $1 AND user_id = $2 +ORDER BY created_at DESC +` + +type ListSyncConflictsByMediaItemParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` +} + +func (q *Queries) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) { + rows, err := q.db.Query(ctx, ListSyncConflictsByMediaItem, arg.MediaItemID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SyncConflicts{} + for rows.Next() { + var i SyncConflicts + if err := rows.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListSyncConflictsByUser = `-- name: ListSyncConflictsByUser :many +SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author +FROM sync_conflicts sc +JOIN media_items mi ON sc.media_item_id = mi.id +WHERE sc.user_id = $1 AND sc.resolution_status = 'unresolved' +ORDER BY sc.created_at DESC +` + +type ListSyncConflictsByUserRow struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + ConflictType string `db:"conflict_type" json:"conflict_type"` + ConflictData []byte `db:"conflict_data" json:"conflict_data"` + ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"` + ResolutionData []byte `db:"resolution_data" json:"resolution_data"` + ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"` + ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` +} + +func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) { + rows, err := q.db.Query(ctx, ListSyncConflictsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSyncConflictsByUserRow{} + for rows.Next() { + var i ListSyncConflictsByUserRow + if err := rows.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + &i.Title, + &i.Author, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUsers = `-- name: ListUsers :many SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users ORDER BY created_at DESC ` @@ -2246,6 +2773,47 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const ResolveSyncConflict = `-- name: ResolveSyncConflict :one +UPDATE sync_conflicts +SET + resolution_status = $2, + resolution_data = $3, + resolved_by = $4, + resolved_at = NOW() +WHERE id = $1 +RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at +` + +type ResolveSyncConflictParams struct { + ID pgtype.UUID `db:"id" json:"id"` + ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"` + ResolutionData []byte `db:"resolution_data" json:"resolution_data"` + ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"` +} + +func (q *Queries) ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) { + row := q.db.QueryRow(ctx, ResolveSyncConflict, + arg.ID, + arg.ResolutionStatus, + arg.ResolutionData, + arg.ResolvedBy, + ) + var i SyncConflicts + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ) + return i, err +} + const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL ` @@ -2255,6 +2823,20 @@ func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype. return err } +const RevokeDevice = `-- name: RevokeDevice :exec +UPDATE devices +SET + auth_token = NULL, + sync_enabled = false, + updated_at = NOW() +WHERE id = $1 +` + +func (q *Queries) RevokeDevice(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, RevokeDevice, id) + return err +} + const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1 ` @@ -2567,6 +3149,120 @@ func (q *Queries) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibi return i, err } +const UpdateDevice = `-- name: UpdateDevice :one +UPDATE devices +SET + device_name = $2, + sync_enabled = $3, + auto_sync = $4, + sync_frequency_minutes = $5, + device_metadata = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at +` + +type UpdateDeviceParams struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceName string `db:"device_name" json:"device_name"` + SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"` + AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"` + SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"` + DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"` +} + +func (q *Queries) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) { + row := q.db.QueryRow(ctx, UpdateDevice, + arg.ID, + arg.DeviceName, + arg.SyncEnabled, + arg.AutoSync, + arg.SyncFrequencyMinutes, + arg.DeviceMetadata, + ) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const UpdateDeviceLastSeen = `-- name: UpdateDeviceLastSeen :one +UPDATE devices +SET + last_seen = NOW(), + updated_at = NOW() +WHERE id = $1 +RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at +` + +func (q *Queries) UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) { + row := q.db.QueryRow(ctx, UpdateDeviceLastSeen, id) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const UpdateDeviceLastSync = `-- name: UpdateDeviceLastSync :one +UPDATE devices +SET + last_sync = NOW(), + last_seen = NOW(), + updated_at = NOW() +WHERE id = $1 +RETURNING id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at +` + +func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) { + row := q.db.QueryRow(ctx, UpdateDeviceLastSync, id) + var i Devices + err := row.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.DeviceType, + &i.DeviceIdentifier, + &i.AuthToken, + &i.LastSync, + &i.LastSeen, + &i.SyncEnabled, + &i.AutoSync, + &i.SyncFrequencyMinutes, + &i.DeviceMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const UpdateEbookNote = `-- name: UpdateEbookNote :one UPDATE media_notes SET content = $2, @@ -3009,6 +3705,43 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings return err } +const UpdateSyncQueueItemStatus = `-- name: UpdateSyncQueueItemStatus :one +UPDATE sync_queue +SET + status = $2, + attempts = attempts + 1, + error_message = $3, + processed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE NULL END +WHERE id = $1 +RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at +` + +type UpdateSyncQueueItemStatusParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Status pgtype.Text `db:"status" json:"status"` + ErrorMessage pgtype.Text `db:"error_message" json:"error_message"` +} + +func (q *Queries) UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error) { + row := q.db.QueryRow(ctx, UpdateSyncQueueItemStatus, arg.ID, arg.Status, arg.ErrorMessage) + var i SyncQueue + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.SyncType, + &i.SyncData, + &i.Priority, + &i.Attempts, + &i.MaxAttempts, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + &i.ProcessedAt, + ) + return i, err +} + const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one INSERT INTO reading_progress ( media_item_id, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 35c6951..14068f6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -621,4 +621,137 @@ INSERT INTO reading_history ( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; +-- ============================================ +-- PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) +-- ============================================ + +-- Device Registration & Management +-- name: CreateDevice :one +INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING *; + +-- name: GetDevice :one +SELECT * FROM devices WHERE id = $1; + +-- name: GetDeviceByIdentifier :one +SELECT * FROM devices WHERE device_identifier = $1; + +-- name: GetDeviceByAuthToken :one +SELECT * FROM devices WHERE auth_token = $1; + +-- name: ListDevicesByUser :many +SELECT * FROM devices WHERE user_id = $1 ORDER BY created_at DESC; + +-- name: ListDevicesByType :many +SELECT * FROM devices WHERE device_type = $1 ORDER BY created_at DESC; + +-- name: UpdateDevice :one +UPDATE devices +SET + device_name = $2, + sync_enabled = $3, + auto_sync = $4, + sync_frequency_minutes = $5, + device_metadata = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateDeviceLastSync :one +UPDATE devices +SET + last_sync = NOW(), + last_seen = NOW(), + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateDeviceLastSeen :one +UPDATE devices +SET + last_seen = NOW(), + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: RevokeDevice :exec +UPDATE devices +SET + auth_token = NULL, + sync_enabled = false, + updated_at = NOW() +WHERE id = $1; + +-- name: DeleteDevice :exec +DELETE FROM devices WHERE id = $1; + +-- name: DeleteDeviceByToken :exec +DELETE FROM devices WHERE auth_token = $1; + +-- Sync Queue Management +-- name: CreateSyncQueueItem :one +INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: GetSyncQueueItem :one +SELECT * FROM sync_queue WHERE id = $1; + +-- name: ListPendingSyncQueueItems :many +SELECT * FROM sync_queue +WHERE device_id = $1 AND status = 'pending' +ORDER BY priority ASC, created_at ASC +LIMIT $2; + +-- name: UpdateSyncQueueItemStatus :one +UPDATE sync_queue +SET + status = $2, + attempts = attempts + 1, + error_message = $3, + processed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE NULL END +WHERE id = $1 +RETURNING *; + +-- name: DeleteSyncQueueItem :exec +DELETE FROM sync_queue WHERE id = $1; + +-- name: ClearDeviceSyncQueue :exec +DELETE FROM sync_queue WHERE device_id = $1; + +-- Conflict Resolution +-- name: CreateSyncConflict :one +INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetSyncConflict :one +SELECT * FROM sync_conflicts WHERE id = $1; + +-- name: ListSyncConflictsByUser :many +SELECT sc.*, mi.title, mi.author +FROM sync_conflicts sc +JOIN media_items mi ON sc.media_item_id = mi.id +WHERE sc.user_id = $1 AND sc.resolution_status = 'unresolved' +ORDER BY sc.created_at DESC; + +-- name: ListSyncConflictsByMediaItem :many +SELECT * FROM sync_conflicts +WHERE media_item_id = $1 AND user_id = $2 +ORDER BY created_at DESC; + +-- name: ResolveSyncConflict :one +UPDATE sync_conflicts +SET + resolution_status = $2, + resolution_data = $3, + resolved_by = $4, + resolved_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: DeleteSyncConflict :exec +DELETE FROM sync_conflicts WHERE id = $1; + -- Media Items Admin Operations \ No newline at end of file diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go new file mode 100644 index 0000000..815a629 --- /dev/null +++ b/internal/handlers/devices.go @@ -0,0 +1,535 @@ +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").(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"}) + } + + 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 +} diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go new file mode 100644 index 0000000..899242a --- /dev/null +++ b/internal/middleware/device_auth.go @@ -0,0 +1,136 @@ +package middleware + +import ( + "bookmann/internal/database" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type DeviceContext struct { + ID uuid.UUID + UserID uuid.UUID + DeviceName string + DeviceType string + DeviceIdentifier string + SyncEnabled bool + AutoSync bool +} + +type DeviceAuthMiddleware struct { + db *database.Queries +} + +func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { + return &DeviceAuthMiddleware{ + db: db, + } +} + +func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "missing authorization header", + }) + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "invalid authorization header format", + }) + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + + device, err := m.db.GetDeviceByAuthToken(c.Request().Context(), token) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "invalid device token", + }) + } + + if !device.SyncEnabled.Bool || !device.SyncEnabled.Valid { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": "device sync is disabled", + }) + } + + ctx := DeviceContext{ + ID: device.ID.Bytes, + UserID: device.UserID.Bytes, + DeviceName: device.DeviceName, + DeviceType: device.DeviceType, + DeviceIdentifier: device.DeviceIdentifier, + SyncEnabled: device.SyncEnabled.Bool && device.SyncEnabled.Valid, + AutoSync: device.AutoSync.Bool && device.AutoSync.Valid, + } + + c.Set("device", ctx) + c.Set("device_id", device.ID.Bytes) + c.Set("user_id", device.UserID.Bytes) + + return next(c) + } +} + +func (m *DeviceAuthMiddleware) RequirePermission(permission string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + device, ok := c.Get("device").(DeviceContext) + if !ok { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "device not authenticated", + }) + } + + if !m.hasPermission(device.DeviceType, permission) { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": "insufficient permissions", + }) + } + + return next(c) + } + } +} + +func (m *DeviceAuthMiddleware) hasPermission(deviceType string, permission string) bool { + permissions := map[string][]string{ + "koreader": {"sync:progress", "sync:annotations", "sync:metadata"}, + "kobo": {"sync:progress", "sync:annotations", "sync:metadata"}, + "web": {"sync:progress", "sync:annotations", "sync:metadata", "device:manage"}, + "mobile": {"sync:progress", "sync:annotations", "sync:metadata"}, + } + + devicePerms, exists := permissions[deviceType] + if !exists { + return false + } + + for _, p := range devicePerms { + if p == permission { + return true + } + } + + return false +} + +func (m *DeviceAuthMiddleware) UpdateLastSeen(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + + deviceID, ok := c.Get("device_id").(uuid.UUID) + if ok { + pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} + m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID) + } + + return err + } +}