diff --git a/internal/database/models.go b/internal/database/models.go index 8be41eb..111113c 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -175,6 +175,15 @@ type ReadingProgress struct { LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` } +type RefreshTokens struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"` +} + type Users struct { ID pgtype.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` diff --git a/internal/database/querier.go b/internal/database/querier.go index 7472c06..7388bc8 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -13,6 +13,7 @@ import ( type Querier interface { // Library Folders queries AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) + CleanupExpiredRefreshTokens(ctx context.Context) error CreateEbook(ctx context.Context, arg CreateEbookParams) (MediaItems, error) // Backward compatibility - Ebook Highlights queries (using views) CreateEbookHighlight(ctx context.Context, arg CreateEbookHighlightParams) (MediaHighlights, error) @@ -29,6 +30,8 @@ type Querier interface { // Media Notes queries CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error) + // Refresh Tokens queries + CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) DeleteEbook(ctx context.Context, id pgtype.UUID) error DeleteEbookHighlight(ctx context.Context, id pgtype.UUID) error @@ -69,6 +72,7 @@ type Querier interface { GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) 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) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) @@ -82,6 +86,8 @@ type Querier interface { ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) + RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error + RevokeRefreshToken(ctx context.Context, token string) error // Library Visibility queries SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (MediaItems, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 64dd4c7..2ff1b7b 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -33,6 +33,15 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara return i, err } +const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec +DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days') +` + +func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens) + return err +} + const CreateEbook = `-- name: CreateEbook :one INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, added_by_admin_id) VALUES ((SELECT id FROM libraries WHERE library_type_id = (SELECT id FROM library_types WHERE name = 'ebooks') LIMIT 1), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) @@ -425,6 +434,33 @@ func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingPa return i, err } +const CreateRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token, expires_at) +VALUES ($1, $2, $3) +RETURNING id, user_id, token, expires_at, created_at, revoked_at +` + +type CreateRefreshTokenParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` +} + +// Refresh Tokens queries +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error) { + row := q.db.QueryRow(ctx, CreateRefreshToken, arg.UserID, arg.Token, arg.ExpiresAt) + var i RefreshTokens + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.RevokedAt, + ) + 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) @@ -1302,6 +1338,42 @@ func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgress return i, err } +const GetRefreshToken = `-- name: GetRefreshToken :one +SELECT rt.id, rt.user_id, rt.token, rt.expires_at, rt.created_at, rt.revoked_at, u.email, u.username, u.role +FROM refresh_tokens rt +JOIN users u ON rt.user_id = u.id +WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW() +` + +type GetRefreshTokenRow struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + Role string `db:"role" json:"role"` +} + +func (q *Queries) GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error) { + row := q.db.QueryRow(ctx, GetRefreshToken, token) + var i GetRefreshTokenRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.RevokedAt, + &i.Email, + &i.Username, + &i.Role, + ) + return i, err +} + const GetScanSettings = `-- name: GetScanSettings :one SELECT scan_frequency_minutes, auto_scan_enabled FROM users WHERE id = $1 ` @@ -1853,6 +1925,24 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec +UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL +` + +func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, RevokeAllUserRefreshTokens, userID) + return err +} + +const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, RevokeRefreshToken, token) + return err +} + const SetLibraryVisibility = `-- name: SetLibraryVisibility :one INSERT INTO library_visibility (user_id, library_id, is_visible) VALUES ($1, $2, $3)