Files
bot-pontos/backend/internal/adapters/postgres/web.go
2026-10-30 23:25:38 -03:00

163 lines
6.2 KiB
Go

package postgres
import (
"context"
"time"
"botpontos/internal/domain"
)
func accountCols(p string) string {
return p + "id, " + p + "username::text, " + cols(p, "password_hash", "role", "user_id", "failed_login_count",
"locked_until", "disabled_at", "last_login_at", "created_at")
}
type accountScan struct {
a domain.WebAccount
role string
}
func (s *accountScan) dest() []any {
return []any{&s.a.ID, &s.a.Username, &s.a.PasswordHash, &s.role, &s.a.UserID, &s.a.FailedLoginCount,
&s.a.LockedUntil, &s.a.DisabledAt, &s.a.LastLoginAt, &s.a.CreatedAt}
}
func (s *accountScan) result() domain.WebAccount {
a := s.a
a.Role = domain.Role(s.role)
return a
}
func readAccount(row scanner) (domain.WebAccount, error) {
var as accountScan
if err := row.Scan(as.dest()...); err != nil {
return domain.WebAccount{}, mapErr(err)
}
return as.result(), nil
}
func (s *Store) CreateWebAccount(ctx context.Context, username, passwordHash string, role domain.Role, userID *int64) (domain.WebAccount, error) {
return readAccount(s.pool.QueryRow(ctx, `
INSERT INTO web_accounts (username, password_hash, role, user_id)
VALUES ($1::text::citext, $2, $3::text::web_role, $4)
RETURNING `+accountCols(""), username, passwordHash, string(role), userID))
}
func (s *Store) GetWebAccount(ctx context.Context, id int64) (domain.WebAccount, error) {
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE id = $1`, id))
}
func (s *Store) GetWebAccountByUsername(ctx context.Context, username string) (domain.WebAccount, error) {
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE username = $1::text::citext`, username))
}
func (s *Store) ListWebAccounts(ctx context.Context) ([]domain.WebAccount, error) {
rows, err := s.pool.Query(ctx, `SELECT `+accountCols("")+` FROM web_accounts ORDER BY username`)
return collect(rows, err, readAccount)
}
// UpdateWebAccount altera papel, situação e/ou vínculo com o usuário do Telegram;
// nil mantém o valor atual. Em setUser, userID nil remove o vínculo.
func (s *Store) UpdateWebAccount(ctx context.Context, id int64, role *domain.Role, disabled *bool, setUser bool, userID *int64) error {
var roleText *string
if role != nil {
v := string(*role)
roleText = &v
}
return expectOne(s.pool.Exec(ctx, `
UPDATE web_accounts SET
role = COALESCE($2::text::web_role, role),
disabled_at = CASE
WHEN $3::boolean IS NULL THEN disabled_at
WHEN $3::boolean THEN COALESCE(disabled_at, now())
ELSE NULL
END,
user_id = CASE WHEN $4::boolean THEN $5::bigint ELSE user_id END
WHERE id = $1`, id, roleText, disabled, setUser, userID))
}
func (s *Store) SetWebAccountPassword(ctx context.Context, id int64, passwordHash string) error {
return expectOne(s.pool.Exec(ctx, `
UPDATE web_accounts SET password_hash = $2, failed_login_count = 0, locked_until = NULL
WHERE id = $1`, id, passwordHash))
}
// DeleteWebAccount apaga a conta; as sessões vão junto e a auditoria mantém o nome (migration 00003).
func (s *Store) DeleteWebAccount(ctx context.Context, id int64) error {
return expectOne(s.pool.Exec(ctx, `DELETE FROM web_accounts WHERE id = $1`, id))
}
func (s *Store) CountActiveOwners(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n)
return n, err
}
// RecordLoginFailure soma uma falha; ao atingir o limite, bloqueia até lockUntil e zera o contador.
func (s *Store) RecordLoginFailure(ctx context.Context, id int64, maxFailures int, lockUntil time.Time) (locked bool, err error) {
err = s.pool.QueryRow(ctx, `
UPDATE web_accounts SET
failed_login_count = CASE WHEN failed_login_count + 1 >= $2 THEN 0 ELSE failed_login_count + 1 END,
locked_until = CASE WHEN failed_login_count + 1 >= $2 THEN $3 ELSE locked_until END
WHERE id = $1
RETURNING locked_until IS NOT NULL AND locked_until > now()`, id, maxFailures, lockUntil).Scan(&locked)
return locked, mapErr(err)
}
func (s *Store) RecordLoginSuccess(ctx context.Context, id int64, at time.Time) error {
return expectOne(s.pool.Exec(ctx, `
UPDATE web_accounts SET failed_login_count = 0, locked_until = NULL, last_login_at = $2 WHERE id = $1`, id, at))
}
type NewSession struct {
AccountID int64
TokenHash []byte
CSRFTokenHash []byte
ExpiresAt time.Time
UserAgent *string
IP *string
}
func (s *Store) CreateSession(ctx context.Context, n NewSession) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO web_sessions (account_id, token_hash, csrf_token_hash, expires_at, last_seen_at, user_agent, ip)
VALUES ($1, $2, $3, $4, now(), $5, $6::text::inet)`,
n.AccountID, n.TokenHash, n.CSRFTokenHash, n.ExpiresAt, n.UserAgent, n.IP)
return mapErr(err)
}
func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (domain.WebSession, error) {
var ws domain.WebSession
var as accountScan
dest := append([]any{&ws.ID, &ws.AccountID, &ws.CSRFTokenHash, &ws.ExpiresAt, &ws.LastSeenAt, &ws.RevokedAt}, as.dest()...)
err := s.pool.QueryRow(ctx, `
SELECT s.id, s.account_id, s.csrf_token_hash, s.expires_at, s.last_seen_at, s.revoked_at, `+accountCols("a.")+`
FROM web_sessions s JOIN web_accounts a ON a.id = s.account_id
WHERE s.token_hash = $1`, tokenHash).Scan(dest...)
if err != nil {
return ws, mapErr(err)
}
ws.Account = as.result()
return ws, nil
}
func (s *Store) TouchSession(ctx context.Context, id int64, at time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET last_seen_at = $2 WHERE id = $1`, id, at)
return err
}
func (s *Store) RevokeSession(ctx context.Context, id int64) error {
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
return err
}
func (s *Store) RevokeAccountSessions(ctx context.Context, accountID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE account_id = $1 AND revoked_at IS NULL`, accountID)
return err
}
func (s *Store) DeleteSessionsEndedBefore(ctx context.Context, before time.Time) (int64, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM web_sessions WHERE COALESCE(revoked_at, expires_at) < $1`, before)
return tag.RowsAffected(), err
}