Phase 2 Week 5: Device Registration & Management

Implement device registration and management system for universal sync.

Database Changes:
- Add device queries to queries.sql (CRUD operations, registration, auth)
- Add sync queue management queries
- Add conflict resolution queries
- Regenerate sqlc models with new device-related types

Device Handler (devices.go):
- InitiateRegistration: Start device registration with auth URL and QR code
- CheckRegistrationStatus: Poll for registration approval
- ListDevices: Get all devices for current user
- GetDevice: Get specific device details
- UpdateDevice: Update device settings (name, sync settings, frequency)
- DeleteDevice: Remove device from account
- ApproveDevice: User approves device registration via web
- RejectDevice: Reject pending device registration
- ListPendingRegistrations: Show all pending registrations
- generateDeviceToken: Generate secure Bearer token for devices

Device Authentication Middleware (device_auth.go):
- Authenticate: Validate device Bearer tokens
- RequirePermission: Check device permissions by type
- hasPermission: Define permissions per device type
- UpdateLastSeen: Auto-update device last_seen timestamp

Configuration:
- Add BaseURL field to Config for device setup URLs

API Endpoints:
POST /api/devices/register - Initiate device registration
POST /api/devices/register/status - Check registration status
GET /api/devices/approve/:id - Approve device (web UI)
POST /api/devices/reject/:id - Reject device
GET /api/devices - List user's devices
GET /api/devices/:id - Get device details
PUT /api/devices/:id - Update device settings
DELETE /api/devices/:id - Delete device
GET /api/devices/pending - List pending registrations

Bruno API Collection:
- Initiate Device Registration
- Check Registration Status
- List Devices
- Get Device
- Update Device
- Delete Device

Dependencies:
- github.com/skip2/go-qrcode for QR code generation

Device Types Supported:
- koreader: Calibre-compatible sync
- kobo: Kobo sync protocol
- web: Web interface
- mobile: Mobile apps

Device Permissions:
- sync:progress
- sync:annotations
- sync:metadata
- device:manage (web only)
This commit is contained in:
2026-01-30 16:45:10 -05:00
parent a2424bcf74
commit 23ad70158c
15 changed files with 1696 additions and 0 deletions
@@ -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}}"
}
}
+15
View File
@@ -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}}
}
+15
View File
@@ -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}}
}
@@ -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"
}
}
+15
View File
@@ -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}}
}
+24
View File
@@ -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
}
}
+19
View File
@@ -4,6 +4,7 @@ import (
"bookmann/internal/config" "bookmann/internal/config"
"bookmann/internal/database" "bookmann/internal/database"
"bookmann/internal/handlers" "bookmann/internal/handlers"
"bookmann/internal/middleware"
ratelimit "bookmann/internal/middleware" ratelimit "bookmann/internal/middleware"
"bookmann/templates" "bookmann/templates"
"bytes" "bytes"
@@ -48,6 +49,10 @@ func main() {
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
libraryHandler := handlers.NewLibraryHandler(queries) libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
_ = deviceAuthMiddleware
e := echo.New() e := echo.New()
@@ -151,6 +156,20 @@ func main() {
authGroup.PUT("/theme", authHandler.UpdateTheme) authGroup.PUT("/theme", authHandler.UpdateTheme)
// force rebuild // 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 // Static files
e.Static("/static", "web/static") e.Static("/static", "web/static")
+1
View File
@@ -12,6 +12,7 @@ require (
github.com/jackc/pgx/v5 v5.4.3 github.com/jackc/pgx/v5 v5.4.3
github.com/labstack/echo-jwt/v4 v4.4.0 github.com/labstack/echo-jwt/v4 v4.4.0
github.com/labstack/echo/v4 v4.13.4 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 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
) )
+2
View File
@@ -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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+2
View File
@@ -8,6 +8,7 @@ import (
type Config struct { type Config struct {
ServerPort string ServerPort string
BaseURL string
JWTSecret string JWTSecret string
UploadPath string UploadPath string
DatabaseHost string DatabaseHost string
@@ -23,6 +24,7 @@ type Config struct {
func LoadConfig() *Config { func LoadConfig() *Config {
return &Config{ return &Config{
ServerPort: getEnv("SERVER_PORT", "8080"), ServerPort: getEnv("SERVER_PORT", "8080"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
DatabaseHost: getEnv("DATABASE_HOST", "localhost"), DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
DatabasePort: getEnv("DATABASE_PORT", "5432"), DatabasePort: getEnv("DATABASE_PORT", "5432"),
DatabaseUser: getEnv("DATABASE_USER", "postgres"), DatabaseUser: getEnv("DATABASE_USER", "postgres"),
+30
View File
@@ -16,6 +16,12 @@ type Querier interface {
// Bulk update format group for all media items // Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
CleanupExpiredRefreshTokens(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) // Backward compatibility - Ebook Notes queries (using views)
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error) CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
// Libraries queries // Libraries queries
@@ -31,7 +37,13 @@ type Querier interface {
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error) CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
// Refresh Tokens queries // Refresh Tokens queries
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) 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) 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 DeleteEbookNote(ctx context.Context, id pgtype.UUID) error
DeleteLibrary(ctx context.Context, id pgtype.UUID) error DeleteLibrary(ctx context.Context, id pgtype.UUID) error
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error) DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
@@ -40,7 +52,12 @@ type Querier interface {
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) 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 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) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, 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) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error) GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, 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 // Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, 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) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, 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) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error)
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, 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) ListUsers(ctx context.Context) ([]ListUsersRow, error)
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error
RevokeRefreshToken(ctx context.Context, token string) error RevokeRefreshToken(ctx context.Context, token string) error
// Note: User ebook folders replaced by library folders system // Note: User ebook folders replaced by library folders system
// Legacy folder management is now handled through libraries // Legacy folder management is now handled through libraries
@@ -86,6 +112,9 @@ type Querier interface {
SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error)
// Library Visibility queries // Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) 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) UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error)
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
@@ -101,6 +130,7 @@ type Querier interface {
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error
UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error)
// Update universal progress // Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
+733
View File
@@ -59,6 +59,70 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
return err 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 const CreateEbookNote = `-- name: CreateEbookNote :one
INSERT INTO media_notes (media_item_id, user_id, content, position) INSERT INTO media_notes (media_item_id, user_id, content, position)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
@@ -447,6 +511,88 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
return i, err 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 const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
VALUES ($1, $2, $3, $4, $5, $6, $7) 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 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 const DeleteEbookNote = `-- name: DeleteEbookNote :exec
DELETE FROM media_notes WHERE id = $1 DELETE FROM media_notes WHERE id = $1
` `
@@ -594,6 +758,24 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
return err 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 const DeleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1 DELETE FROM users WHERE id = $1
` `
@@ -603,6 +785,84 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err 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 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 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 FROM libraries l
@@ -1243,6 +1503,52 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS
return i, err 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 const GetUniversalProgress = `-- name: GetUniversalProgress :one
SELECT SELECT
rp.id, rp.id,
@@ -1586,6 +1892,84 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI
return items, nil 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 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 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 FROM libraries l
@@ -2200,6 +2584,149 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
return items, nil 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 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 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 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 const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL 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 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 const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1 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 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 const UpdateEbookNote = `-- name: UpdateEbookNote :one
UPDATE media_notes SET UPDATE media_notes SET
content = $2, content = $2,
@@ -3009,6 +3705,43 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings
return err 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 const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
INSERT INTO reading_progress ( INSERT INTO reading_progress (
media_item_id, media_item_id,
+133
View File
@@ -621,4 +621,137 @@ INSERT INTO reading_history (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; 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 -- Media Items Admin Operations
+535
View File
@@ -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
}
+136
View File
@@ -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
}
}