Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
297 lines
10 KiB
Go
297 lines
10 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
type UserStore struct{}
|
|
|
|
func NewUserStore() *UserStore { return &UserStore{} }
|
|
|
|
const userCols = `id, username, email, password_hash, display_name, avatar_url,
|
|
is_active, settings, created_at, updated_at, last_login_at,
|
|
auth_source, external_id, handle`
|
|
|
|
const userListCols = `id, username, email, display_name, avatar_url, is_active,
|
|
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
|
|
|
|
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
|
|
u.ID = store.NewID()
|
|
now := time.Now().UTC()
|
|
u.CreatedAt = now
|
|
u.UpdatedAt = now
|
|
if u.AuthSource == "" {
|
|
u.AuthSource = "builtin"
|
|
}
|
|
_, err := DB.ExecContext(ctx, `
|
|
INSERT INTO users (id, username, email, password_hash, display_name, is_active,
|
|
settings, created_at, updated_at, auth_source, external_id, handle)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
|
|
ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt),
|
|
u.AuthSource, u.ExternalID, u.Handle,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE id = ?", userCols), id)
|
|
}
|
|
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?)", userCols), username)
|
|
}
|
|
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER(?)", userCols), email)
|
|
}
|
|
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", userCols), login, login)
|
|
}
|
|
func (s *UserStore) GetByHandle(ctx context.Context, handle string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(handle) = LOWER(?)", userCols), handle)
|
|
}
|
|
func (s *UserStore) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) {
|
|
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE auth_source = ? AND external_id = ?", userCols), authSource, externalID)
|
|
}
|
|
|
|
func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
b := NewUpdate("users")
|
|
for k, v := range fields {
|
|
if k == "settings" {
|
|
b.SetJSON(k, v)
|
|
} else {
|
|
b.Set(k, v)
|
|
}
|
|
}
|
|
if !b.HasSets() {
|
|
return nil
|
|
}
|
|
b.Where("id", id)
|
|
_, err := b.Exec(DB)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) Delete(ctx context.Context, id string) error {
|
|
_, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
|
|
b := NewSelect(userListCols, "users")
|
|
if opts.Sort == "" {
|
|
b.OrderBy("username", "ASC")
|
|
}
|
|
b.Paginate(opts)
|
|
|
|
var total int
|
|
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
|
|
|
|
q, args := b.Build()
|
|
rows, err := DB.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.User
|
|
for rows.Next() {
|
|
var u models.User
|
|
var dn, av sql.NullString
|
|
var extID, hdl sql.NullString
|
|
var sj []byte
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
|
|
&u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
|
|
&u.AuthSource, &extID, &hdl); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
u.DisplayName = NullableString(dn)
|
|
u.AvatarURL = NullableString(av)
|
|
if extID.Valid { u.ExternalID = &extID.String }
|
|
u.Handle = NullableString(hdl)
|
|
ScanJSON(sj, &u.Settings)
|
|
result = append(result, u)
|
|
}
|
|
return result, total, rows.Err()
|
|
}
|
|
|
|
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error {
|
|
_, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = datetime('now') WHERE id = ?", id)
|
|
return err
|
|
}
|
|
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
|
|
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = ? WHERE id = ?", active, id)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
|
rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = 1")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
// ── Refresh Tokens ──────────────────────────
|
|
|
|
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
|
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
|
|
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
|
|
return err
|
|
}
|
|
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
|
var userID string
|
|
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
|
|
return userID, err
|
|
}
|
|
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
|
|
return err
|
|
}
|
|
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
|
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID)
|
|
return err
|
|
}
|
|
func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
|
|
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
|
|
return err
|
|
}
|
|
|
|
// ── Internal ────────────────────────────────
|
|
|
|
func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.User, error) {
|
|
var u models.User
|
|
var dn, av, ph sql.NullString
|
|
var extID, hdl sql.NullString
|
|
var sj []byte
|
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
|
&u.ID, &u.Username, &u.Email, &ph, &dn, &av,
|
|
&u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
|
|
&u.AuthSource, &extID, &hdl,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.PasswordHash = NullableString(ph)
|
|
u.DisplayName = NullableString(dn)
|
|
u.AvatarURL = NullableString(av)
|
|
if extID.Valid { u.ExternalID = &extID.String }
|
|
u.Handle = NullableString(hdl)
|
|
ScanJSON(sj, &u.Settings)
|
|
return &u, nil
|
|
}
|
|
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
|
|
|
func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM users WHERE id = ?`, userID).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) {
|
|
q := `
|
|
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
|
|
FROM users
|
|
WHERE is_active = 1 AND id != ?`
|
|
args := []interface{}{excludeUserID}
|
|
|
|
if query != "" {
|
|
q += ` AND (LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(handle) LIKE ?)`
|
|
pattern := "%" + strings.ToLower(query) + "%"
|
|
args = append(args, pattern, pattern, pattern)
|
|
}
|
|
|
|
q += ` ORDER BY username LIMIT 20`
|
|
|
|
rows, err := DB.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []store.UserSearchResult
|
|
for rows.Next() {
|
|
var u store.UserSearchResult
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
|
|
continue
|
|
}
|
|
results = append(results, u)
|
|
}
|
|
if results == nil {
|
|
results = []store.UserSearchResult{}
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
|
|
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE users SET settings = json_patch(
|
|
CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object'
|
|
THEN '{}' ELSE settings END,
|
|
?), updated_at = datetime('now') WHERE id = ?
|
|
`, string(patch), userID)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) {
|
|
var vaultSet bool
|
|
var encUEK, salt, nonce []byte
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
|
FROM users WHERE id = ?
|
|
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
|
return vaultSet, encUEK, salt, nonce, err
|
|
}
|
|
|
|
func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`, encUEK, salt, nonce, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) CountAll(ctx context.Context) (int, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
|
|
|
|
func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = 0
|
|
WHERE id = ?
|
|
`, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
|
|
_, err := DB.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, vault_set = 1
|
|
WHERE id = ?
|
|
`, encUEK, salt, nonce, userID)
|
|
return err
|
|
}
|