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:
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user