diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 688a6b1d..069bef6a 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "time" + + "github.com/owncord/server/db/dbgen" ) // ─── User Operations ────────────────────────────────────────────────────────── @@ -119,53 +121,34 @@ func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inv // GetUserByUsername returns the user with the given username (case-insensitive), // or nil if not found. func (d *DB) GetUserByUsername(username string) (*User, error) { - row := d.sqlDB.QueryRow( - `SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires - FROM users WHERE username = ? COLLATE NOCASE`, - username, - ) - return scanUser(row) -} - -// GetUserByID returns the user with the given ID, or nil if not found. -func (d *DB) GetUserByID(id int64) (*User, error) { - row := d.sqlDB.QueryRow( - `SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires - FROM users WHERE id = ?`, - id, - ) - return scanUser(row) -} - -// scanUser reads a User from a *sql.Row, returning nil (not an error) when the -// row is not found. -func scanUser(row *sql.Row) (*User, error) { - u := &User{} - var banned int - err := row.Scan( - &u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID, - &u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen, - &banned, &u.BanReason, &u.BanExpires, - ) + u, err := d.q.GetUserByUsername(dbCtx(), username) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { - return nil, fmt.Errorf("scanUser: %w", err) + return nil, fmt.Errorf("GetUserByUsername: %w", err) } - u.Banned = banned != 0 - return u, nil + return userFromGen(u), nil +} + +// GetUserByID returns the user with the given ID, or nil if not found. +func (d *DB) GetUserByID(id int64) (*User, error) { + u, err := d.q.GetUserByID(dbCtx(), id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetUserByID: %w", err) + } + return userFromGen(u), nil } // UpdateUserStatus sets the status column for the given user ID. func (d *DB) UpdateUserStatus(id int64, status string) error { - _, err := d.sqlDB.Exec( - `UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?`, - status, id, - ) - if err != nil { + if err := d.q.UpdateUserStatus(dbCtx(), dbgen.UpdateUserStatusParams{ + Status: status, + ID: id, + }); err != nil { return fmt.Errorf("UpdateUserStatus: %w", err) } return nil @@ -173,8 +156,10 @@ func (d *DB) UpdateUserStatus(id int64, status string) error { // UpdateUserTOTPSecret sets or clears the TOTP secret for a user. func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { - _, err := d.sqlDB.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, id) - if err != nil { + if err := d.q.UpdateUserTOTPSecret(dbCtx(), dbgen.UpdateUserTOTPSecretParams{ + TotpSecret: secret, + ID: id, + }); err != nil { return fmt.Errorf("UpdateUserTOTPSecret: %w", err) } return nil @@ -183,8 +168,7 @@ func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { // ResetAllUserStatuses sets all users to "offline". Called on server startup // to clear stale statuses from a previous run or crash. func (d *DB) ResetAllUserStatuses() error { - _, err := d.sqlDB.Exec(`UPDATE users SET status = 'offline' WHERE status != 'offline'`) - if err != nil { + if err := d.q.ResetAllUserStatuses(dbCtx()); err != nil { return fmt.Errorf("ResetAllUserStatuses: %w", err) } return nil @@ -198,11 +182,12 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { s := expires.UTC().Format("2006-01-02T15:04:05Z") expiresStr = &s } - _, err := d.sqlDB.Exec( - `UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`, - reason, expiresStr, id, - ) - if err != nil { + reasonCopy := reason + if err := d.q.BanUser(dbCtx(), dbgen.BanUserParams{ + BanReason: &reasonCopy, + BanExpires: expiresStr, + ID: id, + }); err != nil { return fmt.Errorf("BanUser: %w", err) } return nil @@ -210,11 +195,7 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { // UnbanUser removes the ban from a user. func (d *DB) UnbanUser(id int64) error { - _, err := d.sqlDB.Exec( - `UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`, - id, - ) - if err != nil { + if err := d.q.UnbanUser(dbCtx(), id); err != nil { return fmt.Errorf("UnbanUser: %w", err) } return nil @@ -233,21 +214,20 @@ const maxSessionsPerUser = 25 // the limit is reached. func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { // Evict oldest sessions if at or above the cap. - _, _ = d.sqlDB.Exec( - `DELETE FROM sessions WHERE id IN ( - SELECT id FROM sessions WHERE user_id = ? - ORDER BY created_at DESC - LIMIT -1 OFFSET ? - )`, - userID, maxSessionsPerUser-1, - ) + _ = d.q.EvictOldestSessions(dbCtx(), dbgen.EvictOldestSessionsParams{ + UserID: userID, + Offset: maxSessionsPerUser - 1, + }) expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z") - res, err := d.sqlDB.Exec( - `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) - VALUES (?, ?, ?, ?, ?)`, - userID, tokenHash, device, ip, expiresAt, - ) + deviceCopy, ipCopy := device, ip + res, err := d.q.InsertSession(dbCtx(), dbgen.InsertSessionParams{ + UserID: userID, + Token: tokenHash, + Device: &deviceCopy, + IpAddress: &ipCopy, + ExpiresAt: expiresAt, + }) if err != nil { return 0, fmt.Errorf("CreateSession: %w", err) } @@ -257,23 +237,15 @@ func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, e // GetSessionByTokenHash retrieves a session by its hashed token, or nil if // not found. func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) { - row := d.sqlDB.QueryRow( - `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at - FROM sessions WHERE token = ?`, - tokenHash, - ) - s := &Session{} - err := row.Scan( - &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, - &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, - ) + s, err := d.q.GetSessionByTokenHash(dbCtx(), tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetSessionByTokenHash: %w", err) } - return s, nil + sess := sessionFromGen(s) + return &sess, nil } // SessionWithBanStatus combines session data with user ban fields @@ -288,36 +260,33 @@ type SessionWithBanStatus struct { // GetSessionWithBanStatus returns the session joined with the user's ban // status in a single query. Returns nil, nil when not found. func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) { - row := d.sqlDB.QueryRow( - `SELECT s.id, s.user_id, s.token, s.device, s.ip_address, - s.created_at, s.last_used, s.expires_at, - u.banned, u.ban_reason, u.ban_expires - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.token = ?`, - tokenHash, - ) - r := &SessionWithBanStatus{} - var banned int - err := row.Scan( - &r.ID, &r.UserID, &r.TokenHash, &r.Device, &r.IP, - &r.CreatedAt, &r.LastUsed, &r.ExpiresAt, - &banned, &r.BanReason, &r.BanExpires, - ) + row, err := d.q.GetSessionWithBanStatus(dbCtx(), tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetSessionWithBanStatus: %w", err) } - r.Banned = banned != 0 - return r, nil + return &SessionWithBanStatus{ + Session: Session{ + ID: row.ID, + UserID: row.UserID, + TokenHash: row.Token, + Device: derefString(row.Device), + IP: derefString(row.IpAddress), + CreatedAt: row.CreatedAt, + LastUsed: row.LastUsed, + ExpiresAt: row.ExpiresAt, + }, + Banned: row.Banned != 0, + BanReason: row.BanReason, + BanExpires: row.BanExpires, + }, nil } // DeleteSession removes the session with the given token hash. func (d *DB) DeleteSession(tokenHash string) error { - _, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE token = ?`, tokenHash) - if err != nil { + if err := d.q.DeleteSessionByToken(dbCtx(), tokenHash); err != nil { return fmt.Errorf("DeleteSession: %w", err) } return nil @@ -327,10 +296,10 @@ func (d *DB) DeleteSession(tokenHash string) error { // with keepSessionID. Used after password change or 2FA state change to // invalidate all other sessions (BUG-108). func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { - result, err := d.sqlDB.Exec( - `DELETE FROM sessions WHERE user_id = ? AND id != ?`, - userID, keepSessionID, - ) + result, err := d.q.DeleteOtherSessions(dbCtx(), dbgen.DeleteOtherSessionsParams{ + UserID: userID, + ID: keepSessionID, + }) if err != nil { return 0, fmt.Errorf("DeleteOtherSessions: %w", err) } @@ -341,10 +310,7 @@ func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { // DeleteExpiredSessions removes all sessions whose expires_at is in the past. // Compares using strftime to handle both ISO-8601 and SQLite datetime formats. func (d *DB) DeleteExpiredSessions() error { - _, err := d.sqlDB.Exec( - `DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now')`, - ) - if err != nil { + if err := d.q.DeleteExpiredSessions(dbCtx()); err != nil { return fmt.Errorf("DeleteExpiredSessions: %w", err) } return nil @@ -352,11 +318,7 @@ func (d *DB) DeleteExpiredSessions() error { // TouchSession updates last_used for the session with the given token hash. func (d *DB) TouchSession(tokenHash string) error { - _, err := d.sqlDB.Exec( - `UPDATE sessions SET last_used = datetime('now') WHERE token = ?`, - tokenHash, - ) - if err != nil { + if err := d.q.TouchSession(dbCtx(), tokenHash); err != nil { return fmt.Errorf("TouchSession: %w", err) } return nil @@ -471,32 +433,19 @@ type MemberSummary struct { // ListMembers returns non-banned users as lightweight summaries. // M-12: Limited to 1000 rows to prevent unbounded result sets on large servers. func (d *DB) ListMembers() ([]MemberSummary, error) { - rows, err := d.sqlDB.Query( - `SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) - FROM users u - JOIN roles r ON u.role_id = r.id - WHERE u.banned = 0 - ORDER BY u.username ASC - LIMIT 1000`, - ) + rows, err := d.q.ListMembers(dbCtx()) if err != nil { return nil, fmt.Errorf("ListMembers: %w", err) } - defer rows.Close() //nolint:errcheck - - var members []MemberSummary - for rows.Next() { - var m MemberSummary - if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.Role); err != nil { - return nil, fmt.Errorf("ListMembers scan: %w", err) - } - members = append(members, m) - } - if rows.Err() != nil { - return nil, fmt.Errorf("ListMembers rows: %w", rows.Err()) - } - if members == nil { - members = []MemberSummary{} + members := make([]MemberSummary, 0, len(rows)) + for _, r := range rows { + members = append(members, MemberSummary{ + ID: r.ID, + Username: r.Username, + Avatar: r.Avatar, + Status: r.Status, + Role: r.Lower, + }) } return members, nil } diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index b23eee93..295a84ad 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -38,7 +38,7 @@ type Querier interface { DeleteLockout(ctx context.Context, key string) error DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (sql.Result, error) - DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error + DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error) DeleteSessionByToken(ctx context.Context, token string) error DisablePlugin(ctx context.Context, id int64) error EditMessageContent(ctx context.Context, arg EditMessageContentParams) error diff --git a/Server/db/dbgen/sessions.sql.go b/Server/db/dbgen/sessions.sql.go index f6077702..bee1d3fc 100644 --- a/Server/db/dbgen/sessions.sql.go +++ b/Server/db/dbgen/sessions.sql.go @@ -32,7 +32,7 @@ func (q *Queries) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessio return q.db.ExecContext(ctx, deleteOtherSessions, arg.UserID, arg.ID) } -const deleteSessionByID = `-- name: DeleteSessionByID :exec +const deleteSessionByID = `-- name: DeleteSessionByID :execresult DELETE FROM sessions WHERE id = ? AND user_id = ? ` @@ -41,9 +41,8 @@ type DeleteSessionByIDParams struct { UserID int64 `json:"userId"` } -func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error { - _, err := q.db.ExecContext(ctx, deleteSessionByID, arg.ID, arg.UserID) - return err +func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error) { + return q.db.ExecContext(ctx, deleteSessionByID, arg.ID, arg.UserID) } const deleteSessionByToken = `-- name: DeleteSessionByToken :exec diff --git a/Server/db/mappers.go b/Server/db/mappers.go new file mode 100644 index 00000000..b12c7668 --- /dev/null +++ b/Server/db/mappers.go @@ -0,0 +1,51 @@ +package db + +import "github.com/owncord/server/db/dbgen" + +// This file holds the conversions from sqlc-generated row/model types +// (db/dbgen) to the domain model types this package exposes. sqlc emits int64 +// for integer columns and *string for nullable text; the domain models use +// narrower Go types (int, bool, non-pointer string) for ergonomics, so each +// delegating read maps through one of these helpers. See docs/plans/sqlc-adoption.md. + +// derefString returns the pointed-to string, or "" when the pointer is nil. +// Used for columns the schema allows to be NULL but the domain model exposes +// as a plain string (device, ip_address). +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// userFromGen maps a generated user row to the domain User model. +func userFromGen(u dbgen.User) *User { + return &User{ + ID: u.ID, + Username: u.Username, + PasswordHash: u.Password, + Avatar: u.Avatar, + RoleID: u.RoleID, + TOTPSecret: u.TotpSecret, + Status: u.Status, + CreatedAt: u.CreatedAt, + LastSeen: u.LastSeen, + Banned: u.Banned != 0, + BanReason: u.BanReason, + BanExpires: u.BanExpires, + } +} + +// sessionFromGen maps a generated session row to the domain Session model. +func sessionFromGen(s dbgen.Session) Session { + return Session{ + ID: s.ID, + UserID: s.UserID, + TokenHash: s.Token, + Device: derefString(s.Device), + IP: derefString(s.IpAddress), + CreatedAt: s.CreatedAt, + LastUsed: s.LastUsed, + ExpiresAt: s.ExpiresAt, + } +} diff --git a/Server/db/profile_queries.go b/Server/db/profile_queries.go index c690b8bd..027719e9 100644 --- a/Server/db/profile_queries.go +++ b/Server/db/profile_queries.go @@ -2,16 +2,19 @@ package db import ( "fmt" + + "github.com/owncord/server/db/dbgen" ) // UpdateUserProfile updates the username and avatar for the given user. // Returns ErrNotFound if the user does not exist. Returns an error wrapping // a UNIQUE constraint violation if the username is already taken. func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) error { - result, err := d.sqlDB.Exec( - `UPDATE users SET username = ?, avatar = ? WHERE id = ?`, - username, avatar, userID, - ) + result, err := d.q.UpdateUserProfile(dbCtx(), dbgen.UpdateUserProfileParams{ + Username: username, + Avatar: avatar, + ID: userID, + }) if err != nil { return fmt.Errorf("UpdateUserProfile: %w", err) } @@ -27,11 +30,10 @@ func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) er // UpdateUserPassword sets a new password hash for the given user. func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { - _, err := d.sqlDB.Exec( - `UPDATE users SET password = ? WHERE id = ?`, - newPasswordHash, userID, - ) - if err != nil { + if err := d.q.UpdateUserPassword(dbCtx(), dbgen.UpdateUserPasswordParams{ + Password: newPasswordHash, + ID: userID, + }); err != nil { return fmt.Errorf("UpdateUserPassword: %w", err) } return nil @@ -40,34 +42,13 @@ func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { // ListUserSessions returns all sessions for the given user in a single query. // Results are ordered by created_at descending (newest first). func (d *DB) ListUserSessions(userID int64) ([]Session, error) { - rows, err := d.sqlDB.Query( - `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at - FROM sessions - WHERE user_id = ? - ORDER BY created_at DESC`, - userID, - ) + rows, err := d.q.ListUserSessions(dbCtx(), userID) if err != nil { return nil, fmt.Errorf("ListUserSessions: %w", err) } - defer rows.Close() //nolint:errcheck - - var sessions []Session - for rows.Next() { - var s Session - if err := rows.Scan( - &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, - &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, - ); err != nil { - return nil, fmt.Errorf("ListUserSessions scan: %w", err) - } - sessions = append(sessions, s) - } - if rows.Err() != nil { - return nil, fmt.Errorf("ListUserSessions rows: %w", rows.Err()) - } - if sessions == nil { - sessions = []Session{} + sessions := make([]Session, 0, len(rows)) + for _, s := range rows { + sessions = append(sessions, sessionFromGen(s)) } return sessions, nil } @@ -76,10 +57,10 @@ func (d *DB) ListUserSessions(userID int64) ([]Session, error) { // the specified user. Returns ErrNotFound if the session does not exist or // does not belong to the user. func (d *DB) DeleteSessionByID(sessionID, userID int64) error { - result, err := d.sqlDB.Exec( - `DELETE FROM sessions WHERE id = ? AND user_id = ?`, - sessionID, userID, - ) + result, err := d.q.DeleteSessionByID(dbCtx(), dbgen.DeleteSessionByIDParams{ + ID: sessionID, + UserID: userID, + }) if err != nil { return fmt.Errorf("DeleteSessionByID: %w", err) } diff --git a/Server/db/queries/sqlite/sessions.sql b/Server/db/queries/sqlite/sessions.sql index 75201c2d..71ccdc9d 100644 --- a/Server/db/queries/sqlite/sessions.sql +++ b/Server/db/queries/sqlite/sessions.sql @@ -24,7 +24,7 @@ WHERE s.token = ?; -- name: DeleteSessionByToken :exec DELETE FROM sessions WHERE token = ?; --- name: DeleteSessionByID :exec +-- name: DeleteSessionByID :execresult DELETE FROM sessions WHERE id = ? AND user_id = ?; -- name: DeleteOtherSessions :execresult