Changeset 0.24.0 (#156)

This commit is contained in:
2026-03-07 11:27:24 +00:00
parent 2dc4514a57
commit a63728a481
23 changed files with 1850 additions and 385 deletions

View File

@@ -14,82 +14,49 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, 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, role, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
now.Format(timeFmt), now.Format(timeFmt),
INSERT INTO users (id, username, email, password_hash, display_name, role, is_active,
settings, created_at, updated_at, auth_source, external_id, handle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, 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.getBy(ctx, "id", id)
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) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER(?)`, username).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
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) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(email) = LOWER(?)`, email).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
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) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`, login, login).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
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 {
@@ -115,16 +82,12 @@ func (s *UserStore) Delete(ctx context.Context, id string) error {
}
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
b := NewSelect(
"id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at",
"users",
)
b := NewSelect(userListCols, "users")
if opts.Sort == "" {
b.OrderBy("username", "ASC")
}
b.Paginate(opts)
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
@@ -138,16 +101,19 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var result []models.User
for rows.Next() {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt))
if err != nil {
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.Role, &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(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
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()
@@ -157,7 +123,6 @@ 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
@@ -166,57 +131,48 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
// ── 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))
_, 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)
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)
_, 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)
_, 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')")
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
return err
}
// ── Internal ────────────────────────────────
func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) {
func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE %s = ?`, col), val).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
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.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.AuthSource, &extID, &hdl,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
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
}