feat: backend service
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
)
|
||||
|
||||
// adminAccountsPrimaryKey is the constraint name surfaced on the
|
||||
// primary-key UNIQUE violation when a duplicate username is inserted.
|
||||
// Postgres synthesises the constraint name as `<table>_pkey` for
|
||||
// primary-key constraints, which matches the migration in
|
||||
// `backend/internal/postgres/migrations/00001_init.sql:199`.
|
||||
const adminAccountsPrimaryKey = "admin_accounts_pkey"
|
||||
|
||||
// Store is the Postgres-backed query surface for the admin package.
|
||||
// Queries are built through go-jet against the generated table
|
||||
// bindings under `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// adminColumnList is the canonical projection used by every read path.
|
||||
// The slice ordering matches the destination struct fields.
|
||||
func adminColumnList() postgres.ColumnList {
|
||||
return postgres.ColumnList{
|
||||
table.AdminAccounts.Username,
|
||||
table.AdminAccounts.PasswordHash,
|
||||
table.AdminAccounts.CreatedAt,
|
||||
table.AdminAccounts.LastUsedAt,
|
||||
table.AdminAccounts.DisabledAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the admin row and its bcrypt hash for username.
|
||||
// Returns ErrNotFound when no row matches.
|
||||
func (s *Store) Lookup(ctx context.Context, username string) (Admin, []byte, error) {
|
||||
stmt := postgres.SELECT(adminColumnList()).
|
||||
FROM(table.AdminAccounts).
|
||||
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.AdminAccounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Admin{}, nil, ErrNotFound
|
||||
}
|
||||
return Admin{}, nil, fmt.Errorf("admin store: lookup %q: %w", username, err)
|
||||
}
|
||||
admin, hash := modelToAdmin(row)
|
||||
return admin, hash, nil
|
||||
}
|
||||
|
||||
// ListAll returns every admin row paired with its bcrypt hash, ordered
|
||||
// by username ASC. Used by Cache.Warm and by the List handler (the
|
||||
// hashes are dropped before the handler sends a response, but Warm
|
||||
// needs them so Verify can match without a follow-up query).
|
||||
func (s *Store) ListAll(ctx context.Context) ([]Admin, [][]byte, error) {
|
||||
stmt := postgres.SELECT(adminColumnList()).
|
||||
FROM(table.AdminAccounts).
|
||||
ORDER_BY(table.AdminAccounts.Username.ASC())
|
||||
|
||||
var rows []model.AdminAccounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, nil, fmt.Errorf("admin store: list: %w", err)
|
||||
}
|
||||
admins := make([]Admin, 0, len(rows))
|
||||
hashes := make([][]byte, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
admin, hash := modelToAdmin(row)
|
||||
admins = append(admins, admin)
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
return admins, hashes, nil
|
||||
}
|
||||
|
||||
// Insert persists a fresh admin row. Returns ErrUsernameTaken when the
|
||||
// primary-key UNIQUE constraint is violated (concurrent or repeat
|
||||
// Create).
|
||||
func (s *Store) Insert(ctx context.Context, username string, passwordHash []byte) (Admin, error) {
|
||||
stmt := table.AdminAccounts.
|
||||
INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash).
|
||||
VALUES(username, passwordHash).
|
||||
RETURNING(adminColumnList())
|
||||
|
||||
var row model.AdminAccounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if isUniqueViolation(err, adminAccountsPrimaryKey) {
|
||||
return Admin{}, ErrUsernameTaken
|
||||
}
|
||||
return Admin{}, fmt.Errorf("admin store: insert %q: %w", username, err)
|
||||
}
|
||||
admin, _ := modelToAdmin(row)
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
// UpdatePasswordHash replaces the stored bcrypt hash for username.
|
||||
// Returns ErrNotFound when no row matches.
|
||||
func (s *Store) UpdatePasswordHash(ctx context.Context, username string, passwordHash []byte) (Admin, error) {
|
||||
stmt := table.AdminAccounts.
|
||||
UPDATE(table.AdminAccounts.PasswordHash).
|
||||
SET(passwordHash).
|
||||
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
|
||||
RETURNING(adminColumnList())
|
||||
|
||||
var row model.AdminAccounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Admin{}, ErrNotFound
|
||||
}
|
||||
return Admin{}, fmt.Errorf("admin store: update password for %q: %w", username, err)
|
||||
}
|
||||
admin, _ := modelToAdmin(row)
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
// SetDisabledAt patches `disabled_at` for username. Pass `&time` to
|
||||
// disable, `nil` to re-enable. Returns the refreshed Admin together
|
||||
// with its bcrypt hash so the cache stays consistent. Returns
|
||||
// ErrNotFound when no row matches.
|
||||
func (s *Store) SetDisabledAt(ctx context.Context, username string, disabledAt *time.Time) (Admin, []byte, error) {
|
||||
var disabledExpr postgres.Expression
|
||||
if disabledAt != nil {
|
||||
disabledExpr = postgres.TimestampzT(*disabledAt)
|
||||
} else {
|
||||
disabledExpr = postgres.TimestampzExp(postgres.NULL)
|
||||
}
|
||||
stmt := table.AdminAccounts.
|
||||
UPDATE(table.AdminAccounts.DisabledAt).
|
||||
SET(disabledExpr).
|
||||
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
|
||||
RETURNING(adminColumnList())
|
||||
|
||||
var row model.AdminAccounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Admin{}, nil, ErrNotFound
|
||||
}
|
||||
return Admin{}, nil, fmt.Errorf("admin store: set disabled_at for %q: %w", username, err)
|
||||
}
|
||||
admin, hash := modelToAdmin(row)
|
||||
return admin, hash, nil
|
||||
}
|
||||
|
||||
// TouchLastUsed bumps last_used_at on a successful Verify. The caller
|
||||
// runs the update fire-and-forget; errors are returned for logging
|
||||
// but never propagated to the request.
|
||||
func (s *Store) TouchLastUsed(ctx context.Context, username string, now time.Time) error {
|
||||
stmt := table.AdminAccounts.
|
||||
UPDATE(table.AdminAccounts.LastUsedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("admin store: touch last_used_at for %q: %w", username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BootstrapInsert inserts the seed admin row when no row with the
|
||||
// supplied username exists. The boolean reports whether the insert
|
||||
// happened (true) or was skipped because of an existing row (false).
|
||||
//
|
||||
// Idempotent across restarts: subsequent calls with the same username
|
||||
// return false without modifying the password hash. Operators rotate
|
||||
// the seed admin's password through `ResetPassword`, not by editing
|
||||
// env vars and restarting.
|
||||
func (s *Store) BootstrapInsert(ctx context.Context, username string, passwordHash []byte) (bool, error) {
|
||||
stmt := table.AdminAccounts.
|
||||
INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash).
|
||||
VALUES(username, passwordHash).
|
||||
ON_CONFLICT(table.AdminAccounts.Username).
|
||||
DO_NOTHING()
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin store: bootstrap insert %q: %w", username, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin store: bootstrap rows-affected: %w", err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// modelToAdmin projects a generated model row into the public Admin
|
||||
// struct plus the raw password hash. The conversion centralises the
|
||||
// pointer-copy of nullable timestamps so each method stays a one-liner.
|
||||
func modelToAdmin(row model.AdminAccounts) (Admin, []byte) {
|
||||
admin := Admin{
|
||||
Username: row.Username,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
if row.LastUsedAt != nil {
|
||||
t := *row.LastUsedAt
|
||||
admin.LastUsedAt = &t
|
||||
}
|
||||
if row.DisabledAt != nil {
|
||||
t := *row.DisabledAt
|
||||
admin.DisabledAt = &t
|
||||
}
|
||||
return admin, row.PasswordHash
|
||||
}
|
||||
Reference in New Issue
Block a user