This commit is contained in:
Pavel Flegr 2026-04-08 17:53:30 +02:00
commit ef105e1cac
22 changed files with 3001 additions and 0 deletions

View file

@ -0,0 +1,16 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

11
internal/auth/tokens.go Normal file
View file

@ -0,0 +1,11 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
)
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

83
internal/config/config.go Normal file
View file

@ -0,0 +1,83 @@
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
type Config struct {
HTTPAddr string
DatabaseURL string
SessionKey string
AppEnv string
AdminEmail string
AdminPass string
}
func Load() Config {
if err := loadDotEnv(".env"); err != nil {
fmt.Fprintf(os.Stderr, "warning: load .env: %v\n", err)
}
return Config{
HTTPAddr: getenv("HTTP_ADDR", ":8080"),
DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5433/superbanka?sslmode=disable"),
SessionKey: getenv("SESSION_KEY", "dev-session-key-change-me"),
AppEnv: getenv("APP_ENV", "development"),
AdminEmail: getenv("ADMIN_EMAIL", "admin@superbanka.local"),
AdminPass: getenv("ADMIN_PASSWORD", "admin123456"),
}
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func loadDotEnv(path string) error {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("line %d: missing '='", lineNumber)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
return fmt.Errorf("line %d: empty key", lineNumber)
}
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("line %d: set %s: %w", lineNumber, key, err)
}
}
return scanner.Err()
}

72
internal/db/migrate.go Normal file
View file

@ -0,0 +1,72 @@
package db
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, dir string) error {
if _, err := pool.Exec(ctx, `
create table if not exists schema_migrations (
filename text primary key,
applied_at timestamptz not null default now()
)
`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var files []string
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
files = append(files, entry.Name())
}
sort.Strings(files)
for _, name := range files {
var exists bool
if err := pool.QueryRow(ctx, `select exists(select 1 from schema_migrations where filename = $1)`, name).Scan(&exists); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if exists {
continue
}
body, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
if _, err := tx.Exec(ctx, string(body)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("execute migration %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `insert into schema_migrations (filename) values ($1)`, name); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}

872
internal/db/store.go Normal file
View file

@ -0,0 +1,872 @@
package db
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"superbanka/internal/platform"
)
const signupBonusMinor int64 = 1000000
var ErrConflict = errors.New("conflict")
var ErrInvalidCredentials = errors.New("invalid credentials")
var ErrInsufficientFunds = errors.New("insufficient funds")
var ErrNotFound = errors.New("not found")
var ErrFrozen = errors.New("account frozen")
type Store struct {
pool *pgxpool.Pool
}
type User struct {
ID string
Email string
Username string
Status string
AccountID string
AccountNumber string
BalanceMinor int64
CreatedAt time.Time
}
type AdminUser struct {
ID string
Email string
Role string
Status string
CreatedAt time.Time
}
type TransactionView struct {
ID string
Type string
Description string
Direction string
SenderAccount string
RecipientAccount string
AmountMinor int64
CreatedAt time.Time
}
type UserListItem struct {
ID string
Email string
Username string
Status string
AccountNumber string
BalanceMinor int64
CreatedAt time.Time
}
type LeaderboardEntry struct {
Rank int
Username string
AccountNumber string
BalanceMinor int64
}
type AuditLog struct {
Action string
TargetType string
TargetID string
Metadata string
CreatedAt time.Time
AdminEmail string
}
type AdminDashboard struct {
UserCount int
AccountCount int
TransactionCount int
FrozenUserCount int
RecentTransactions []TransactionView
RecentAuditActivity []AuditLog
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) BootstrapAdmin(ctx context.Context, email, passwordHash string) error {
var exists bool
if err := s.pool.QueryRow(ctx, `select exists(select 1 from admin_users)`).Scan(&exists); err != nil {
return fmt.Errorf("check existing admins: %w", err)
}
if exists {
return nil
}
id, err := platform.NewID()
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
insert into admin_users (id, email, password_hash, role, status, created_at, updated_at)
values ($1, $2, $3, 'super_admin', 'active', now(), now())
`, id, strings.ToLower(strings.TrimSpace(email)), passwordHash)
if err != nil {
return fmt.Errorf("insert bootstrap admin: %w", err)
}
return nil
}
func (s *Store) CreateUser(ctx context.Context, email, username, passwordHash string) (*User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin create user: %w", err)
}
defer tx.Rollback(ctx)
userID, err := platform.NewID()
if err != nil {
return nil, err
}
accountID, err := platform.NewID()
if err != nil {
return nil, err
}
transactionID, err := platform.NewID()
if err != nil {
return nil, err
}
entryID, err := platform.NewID()
if err != nil {
return nil, err
}
reference, err := platform.NewID()
if err != nil {
return nil, err
}
accountNumber, err := s.generateUniqueAccountNumber(ctx, tx)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `
insert into users (id, email, username, password_hash, status, created_at, updated_at)
values ($1, $2, $3, $4, 'active', now(), now())
`, userID, strings.ToLower(strings.TrimSpace(email)), strings.ToLower(strings.TrimSpace(username)), passwordHash)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
return nil, fmt.Errorf("insert user: %w", err)
}
_, err = tx.Exec(ctx, `
insert into accounts (id, user_id, account_number, currency, status, created_at)
values ($1, $2, $3, 'CZK', 'open', now())
`, accountID, userID, accountNumber)
if err != nil {
return nil, fmt.Errorf("insert account: %w", err)
}
_, err = tx.Exec(ctx, `
insert into transactions (id, type, status, reference, description, created_at)
values ($1, 'signup_bonus', 'completed', $2, 'Signup bonus', now())
`, transactionID, reference)
if err != nil {
return nil, fmt.Errorf("insert signup transaction: %w", err)
}
_, err = tx.Exec(ctx, `
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
values ($1, $2, $3, 'credit', $4, now())
`, entryID, transactionID, accountID, signupBonusMinor)
if err != nil {
return nil, fmt.Errorf("insert signup ledger entry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit create user: %w", err)
}
return s.GetUserDashboard(ctx, userID)
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, string, error) {
var user User
var passwordHash string
err := s.pool.QueryRow(ctx, `
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at, u.password_hash
from users u
join accounts a on a.user_id = u.id
where u.email = $1
`, strings.ToLower(strings.TrimSpace(email))).Scan(
&user.ID,
&user.Email,
&user.Username,
&user.Status,
&user.AccountID,
&user.AccountNumber,
&user.CreatedAt,
&passwordHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrInvalidCredentials
}
if err != nil {
return nil, "", fmt.Errorf("get user by email: %w", err)
}
balance, err := s.BalanceForAccount(ctx, user.AccountID)
if err != nil {
return nil, "", err
}
user.BalanceMinor = balance
return &user, passwordHash, nil
}
func (s *Store) GetAdminByEmail(ctx context.Context, email string) (*AdminUser, string, error) {
var admin AdminUser
var passwordHash string
err := s.pool.QueryRow(ctx, `
select id, email, role, status, created_at, password_hash
from admin_users
where email = $1
`, strings.ToLower(strings.TrimSpace(email))).Scan(&admin.ID, &admin.Email, &admin.Role, &admin.Status, &admin.CreatedAt, &passwordHash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrInvalidCredentials
}
if err != nil {
return nil, "", fmt.Errorf("get admin by email: %w", err)
}
return &admin, passwordHash, nil
}
func (s *Store) CreateUserSession(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
id, err := platform.NewID()
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
insert into user_sessions (id, user_id, token_hash, expires_at, created_at)
values ($1, $2, $3, $4, now())
`, id, userID, tokenHash, expiresAt)
if err != nil {
return fmt.Errorf("create user session: %w", err)
}
return nil
}
func (s *Store) CreateAdminSession(ctx context.Context, adminID, tokenHash string, expiresAt time.Time) error {
id, err := platform.NewID()
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
insert into admin_sessions (id, admin_user_id, token_hash, expires_at, created_at)
values ($1, $2, $3, $4, now())
`, id, adminID, tokenHash, expiresAt)
if err != nil {
return fmt.Errorf("create admin session: %w", err)
}
return nil
}
func (s *Store) DeleteUserSession(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `delete from user_sessions where token_hash = $1`, tokenHash)
if err != nil {
return fmt.Errorf("delete user session: %w", err)
}
return nil
}
func (s *Store) DeleteAdminSession(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `delete from admin_sessions where token_hash = $1`, tokenHash)
if err != nil {
return fmt.Errorf("delete admin session: %w", err)
}
return nil
}
func (s *Store) UserFromSession(ctx context.Context, tokenHash string) (*User, error) {
var user User
err := s.pool.QueryRow(ctx, `
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at
from user_sessions us
join users u on u.id = us.user_id
join accounts a on a.user_id = u.id
where us.token_hash = $1 and us.expires_at > now()
`, tokenHash).Scan(&user.ID, &user.Email, &user.Username, &user.Status, &user.AccountID, &user.AccountNumber, &user.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("user from session: %w", err)
}
balance, err := s.BalanceForAccount(ctx, user.AccountID)
if err != nil {
return nil, err
}
user.BalanceMinor = balance
return &user, nil
}
func (s *Store) AdminFromSession(ctx context.Context, tokenHash string) (*AdminUser, error) {
var admin AdminUser
err := s.pool.QueryRow(ctx, `
select a.id, a.email, a.role, a.status, a.created_at
from admin_sessions s
join admin_users a on a.id = s.admin_user_id
where s.token_hash = $1 and s.expires_at > now()
`, tokenHash).Scan(&admin.ID, &admin.Email, &admin.Role, &admin.Status, &admin.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("admin from session: %w", err)
}
return &admin, nil
}
func (s *Store) GetUserDashboard(ctx context.Context, userID string) (*User, error) {
var user User
err := s.pool.QueryRow(ctx, `
select u.id, u.email, u.username, u.status, a.id, a.account_number, u.created_at
from users u
join accounts a on a.user_id = u.id
where u.id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Username, &user.Status, &user.AccountID, &user.AccountNumber, &user.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get user dashboard: %w", err)
}
balance, err := s.BalanceForAccount(ctx, user.AccountID)
if err != nil {
return nil, err
}
user.BalanceMinor = balance
return &user, nil
}
func (s *Store) BalanceForAccount(ctx context.Context, accountID string) (int64, error) {
var balance int64
err := s.pool.QueryRow(ctx, `
select coalesce(sum(case when entry_type = 'credit' then amount_minor else -amount_minor end), 0)
from ledger_entries
where account_id = $1
`, accountID).Scan(&balance)
if err != nil {
return 0, fmt.Errorf("balance for account: %w", err)
}
return balance, nil
}
func (s *Store) RecentTransactionsForAccount(ctx context.Context, accountID string, limit int) ([]TransactionView, error) {
rows, err := s.pool.Query(ctx, `
select
t.id,
t.type,
t.description,
le.entry_type,
coalesce(
(
select a2.account_number
from ledger_entries le2
join accounts a2 on a2.id = le2.account_id
where le2.transaction_id = t.id and le2.entry_type = 'debit'
limit 1
),
''
) as sender_account,
coalesce(
(
select a2.account_number
from ledger_entries le2
join accounts a2 on a2.id = le2.account_id
where le2.transaction_id = t.id and le2.entry_type = 'credit'
limit 1
),
''
) as recipient_account,
le.amount_minor,
t.created_at
from ledger_entries le
join transactions t on t.id = le.transaction_id
where le.account_id = $1
order by t.created_at desc
limit $2
`, accountID, limit)
if err != nil {
return nil, fmt.Errorf("recent transactions: %w", err)
}
defer rows.Close()
var items []TransactionView
for rows.Next() {
var item TransactionView
if err := rows.Scan(&item.ID, &item.Type, &item.Description, &item.Direction, &item.SenderAccount, &item.RecipientAccount, &item.AmountMinor, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan transaction: %w", err)
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAccountNumber, description string, amountMinor int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transfer: %w", err)
}
defer tx.Rollback(ctx)
type accountRecord struct {
AccountID string
UserID string
Status string
UserStatus string
AccountNumber string
}
var sender accountRecord
err = tx.QueryRow(ctx, `
select a.id, u.id, a.status, u.status, a.account_number
from accounts a
join users u on u.id = a.user_id
where u.id = $1
`, senderUserID).Scan(&sender.AccountID, &sender.UserID, &sender.Status, &sender.UserStatus, &sender.AccountNumber)
if err != nil {
return fmt.Errorf("load sender account: %w", err)
}
var receiver accountRecord
err = tx.QueryRow(ctx, `
select a.id, u.id, a.status, u.status, a.account_number
from accounts a
join users u on u.id = a.user_id
where a.account_number = $1
`, strings.TrimSpace(destinationAccountNumber)).Scan(&receiver.AccountID, &receiver.UserID, &receiver.Status, &receiver.UserStatus, &receiver.AccountNumber)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("load receiver account: %w", err)
}
if sender.AccountID == receiver.AccountID {
return fmt.Errorf("cannot transfer to the same account")
}
if sender.UserStatus != "active" || sender.Status != "open" || receiver.UserStatus != "active" || receiver.Status != "open" {
return ErrFrozen
}
ids := []string{sender.AccountID, receiver.AccountID}
sort.Strings(ids)
if _, err := tx.Exec(ctx, `select id from accounts where id = any($1) order by id for update`, ids); err != nil {
return fmt.Errorf("lock accounts: %w", err)
}
balance, err := balanceForAccountTx(ctx, tx, sender.AccountID)
if err != nil {
return err
}
if balance < amountMinor {
return ErrInsufficientFunds
}
transactionID, err := platform.NewID()
if err != nil {
return err
}
reference, err := platform.NewID()
if err != nil {
return err
}
debitID, err := platform.NewID()
if err != nil {
return err
}
creditID, err := platform.NewID()
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
values ($1, 'transfer', 'completed', $2, $3, $4, now())
`, transactionID, reference, senderUserID, strings.TrimSpace(description))
if err != nil {
return fmt.Errorf("insert transfer transaction: %w", err)
}
_, err = tx.Exec(ctx, `
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
values ($1, $2, $3, 'debit', $4, now()),
($5, $2, $6, 'credit', $4, now())
`, debitID, transactionID, sender.AccountID, amountMinor, creditID, receiver.AccountID)
if err != nil {
return fmt.Errorf("insert ledger entries: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit transfer: %w", err)
}
return nil
}
func (s *Store) AdminDashboard(ctx context.Context) (*AdminDashboard, error) {
var dashboard AdminDashboard
if err := s.pool.QueryRow(ctx, `select count(*) from users`).Scan(&dashboard.UserCount); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `select count(*) from accounts`).Scan(&dashboard.AccountCount); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `select count(*) from transactions`).Scan(&dashboard.TransactionCount); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `select count(*) from users where status = 'frozen'`).Scan(&dashboard.FrozenUserCount); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
select t.id, t.type, t.description, le.entry_type,
coalesce((select a.account_number from ledger_entries le2 join accounts a on a.id = le2.account_id where le2.transaction_id = t.id and le2.entry_type = 'debit' limit 1), ''),
coalesce((select a.account_number from ledger_entries le2 join accounts a on a.id = le2.account_id where le2.transaction_id = t.id and le2.entry_type = 'credit' limit 1), ''),
le.amount_minor,
t.created_at
from transactions t
join ledger_entries le on le.transaction_id = t.id
order by t.created_at desc
limit 10
`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var item TransactionView
if err := rows.Scan(&item.ID, &item.Type, &item.Description, &item.Direction, &item.SenderAccount, &item.RecipientAccount, &item.AmountMinor, &item.CreatedAt); err != nil {
return nil, err
}
dashboard.RecentTransactions = append(dashboard.RecentTransactions, item)
}
auditRows, err := s.pool.Query(ctx, `
select al.action, al.target_type, al.target_id, al.metadata::text, al.created_at, au.email
from audit_logs al
join admin_users au on au.id = al.actor_admin_id
order by al.created_at desc
limit 10
`)
if err != nil {
return nil, err
}
defer auditRows.Close()
for auditRows.Next() {
var item AuditLog
if err := auditRows.Scan(&item.Action, &item.TargetType, &item.TargetID, &item.Metadata, &item.CreatedAt, &item.AdminEmail); err != nil {
return nil, err
}
dashboard.RecentAuditActivity = append(dashboard.RecentAuditActivity, item)
}
return &dashboard, nil
}
func (s *Store) SearchUsers(ctx context.Context, search string) ([]UserListItem, error) {
search = strings.TrimSpace(search)
pattern := "%" + strings.ToLower(search) + "%"
rows, err := s.pool.Query(ctx, `
select u.id, u.email, u.username, u.status, a.account_number, u.created_at,
coalesce(sum(case when le.entry_type = 'credit' then le.amount_minor else -le.amount_minor end), 0) as balance_minor
from users u
join accounts a on a.user_id = u.id
left join ledger_entries le on le.account_id = a.id
where ($1 = '' or lower(u.email) like $2 or lower(u.username) like $2 or a.account_number like $2)
group by u.id, u.email, u.username, u.status, a.account_number, u.created_at
order by u.created_at desc
limit 100
`, search, pattern)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
var items []UserListItem
for rows.Next() {
var item UserListItem
if err := rows.Scan(&item.ID, &item.Email, &item.Username, &item.Status, &item.AccountNumber, &item.CreatedAt, &item.BalanceMinor); err != nil {
return nil, fmt.Errorf("scan user list item: %w", err)
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) Leaderboard(ctx context.Context, limit int) ([]LeaderboardEntry, error) {
rows, err := s.pool.Query(ctx, `
select
u.username,
a.account_number,
coalesce(sum(case when le.entry_type = 'credit' then le.amount_minor else -le.amount_minor end), 0) as balance_minor
from users u
join accounts a on a.user_id = u.id
left join ledger_entries le on le.account_id = a.id
where u.status = 'active' and a.status = 'open'
group by u.id, u.username, a.account_number
order by balance_minor desc, u.username asc
limit $1
`, limit)
if err != nil {
return nil, fmt.Errorf("leaderboard: %w", err)
}
defer rows.Close()
var entries []LeaderboardEntry
rank := 1
for rows.Next() {
var entry LeaderboardEntry
if err := rows.Scan(&entry.Username, &entry.AccountNumber, &entry.BalanceMinor); err != nil {
return nil, fmt.Errorf("scan leaderboard entry: %w", err)
}
entry.Rank = rank
rank++
entries = append(entries, entry)
}
return entries, rows.Err()
}
func (s *Store) GetUserDetail(ctx context.Context, userID string) (*User, []TransactionView, error) {
user, err := s.GetUserDashboard(ctx, userID)
if err != nil {
return nil, nil, err
}
transactions, err := s.RecentTransactionsForAccount(ctx, user.AccountID, 50)
if err != nil {
return nil, nil, err
}
return user, transactions, nil
}
func (s *Store) SetUserStatus(ctx context.Context, userID, status, adminID string) error {
result, err := s.pool.Exec(ctx, `update users set status = $2, updated_at = now() where id = $1`, userID, status)
if err != nil {
return fmt.Errorf("update user status: %w", err)
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
metadata, err := json.Marshal(map[string]string{"status": status})
if err != nil {
return fmt.Errorf("marshal user status audit log: %w", err)
}
return s.insertAuditLog(ctx, adminID, "user_status_changed", "user", userID, string(metadata))
}
func (s *Store) CreateAdjustment(ctx context.Context, adminID, accountID, direction string, amountMinor int64, reason string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin adjustment: %w", err)
}
defer tx.Rollback(ctx)
var userStatus string
err = tx.QueryRow(ctx, `
select u.status
from accounts a
join users u on u.id = a.user_id
where a.id = $1
for update
`, accountID).Scan(&userStatus)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("load account for adjustment: %w", err)
}
if userStatus != "active" {
return ErrFrozen
}
if direction == "debit" {
balance, err := balanceForAccountTx(ctx, tx, accountID)
if err != nil {
return err
}
if balance < amountMinor {
return ErrInsufficientFunds
}
}
transactionID, err := platform.NewID()
if err != nil {
return err
}
reference, err := platform.NewID()
if err != nil {
return err
}
entryID, err := platform.NewID()
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
insert into transactions (id, type, status, reference, initiated_by_admin_id, description, created_at)
values ($1, 'adjustment', 'completed', $2, $3, $4, now())
`, transactionID, reference, adminID, strings.TrimSpace(reason))
if err != nil {
return fmt.Errorf("insert adjustment transaction: %w", err)
}
_, err = tx.Exec(ctx, `
insert into ledger_entries (id, transaction_id, account_id, entry_type, amount_minor, created_at)
values ($1, $2, $3, $4, $5, now())
`, entryID, transactionID, accountID, direction, amountMinor)
if err != nil {
return fmt.Errorf("insert adjustment entry: %w", err)
}
metadataBytes, err := json.Marshal(map[string]any{
"direction": direction,
"amount_minor": amountMinor,
"reason": strings.TrimSpace(reason),
})
if err != nil {
return fmt.Errorf("marshal adjustment metadata: %w", err)
}
logID, err := platform.NewID()
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
insert into audit_logs (id, actor_admin_id, action, target_type, target_id, metadata, created_at)
values ($1, $2, 'balance_adjustment', 'account', $3, $4::jsonb, now())
`, logID, adminID, accountID, string(metadataBytes))
if err != nil {
return fmt.Errorf("insert adjustment audit log: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit adjustment: %w", err)
}
return nil
}
func (s *Store) AuditLogs(ctx context.Context) ([]AuditLog, error) {
rows, err := s.pool.Query(ctx, `
select al.action, al.target_type, al.target_id, al.metadata::text, al.created_at, au.email
from audit_logs al
join admin_users au on au.id = al.actor_admin_id
order by al.created_at desc
limit 100
`)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var item AuditLog
if err := rows.Scan(&item.Action, &item.TargetType, &item.TargetID, &item.Metadata, &item.CreatedAt, &item.AdminEmail); err != nil {
return nil, fmt.Errorf("scan audit log: %w", err)
}
logs = append(logs, item)
}
return logs, rows.Err()
}
func (s *Store) insertAuditLog(ctx context.Context, adminID, action, targetType, targetID, metadata string) error {
id, err := platform.NewID()
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
insert into audit_logs (id, actor_admin_id, action, target_type, target_id, metadata, created_at)
values ($1, $2, $3, $4, $5, $6::jsonb, now())
`, id, adminID, action, targetType, targetID, metadata)
if err != nil {
return fmt.Errorf("insert audit log: %w", err)
}
return nil
}
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
for i := 0; i < 20; i++ {
candidate, err := randomAccountNumber()
if err != nil {
return "", err
}
var exists bool
if err := tx.QueryRow(ctx, `select exists(select 1 from accounts where account_number = $1)`, candidate).Scan(&exists); err != nil {
return "", fmt.Errorf("check account number uniqueness: %w", err)
}
if !exists {
return candidate, nil
}
}
return "", fmt.Errorf("could not generate unique account number")
}
func randomAccountNumber() (string, error) {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("read random account bytes: %w", err)
}
n := binary.BigEndian.Uint64(buf[:]) % 10000000000
if n < 1000000000 {
n += 1000000000
}
return fmt.Sprintf("%010d", n), nil
}
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
var balance int64
err := tx.QueryRow(ctx, `
select coalesce(sum(case when entry_type = 'credit' then amount_minor else -amount_minor end), 0)
from ledger_entries
where account_id = $1
`, accountID).Scan(&balance)
if err != nil {
return 0, fmt.Errorf("balance for account tx: %w", err)
}
return balance, nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

703
internal/http/app.go Normal file
View file

@ -0,0 +1,703 @@
package http
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"superbanka/internal/auth"
"superbanka/internal/config"
"superbanka/internal/db"
"superbanka/internal/platform"
)
const (
userSessionCookie = "superbanka_user_session"
adminSessionCookie = "superbanka_admin_session"
csrfCookie = "superbanka_csrf"
)
type contextKey string
const (
ctxUserKey contextKey = "current-user"
ctxAdminKey contextKey = "current-admin"
ctxCSRFKey contextKey = "csrf-token"
flashCookie = "superbanka_flash"
)
type App struct {
cfg config.Config
dbPool *pgxpool.Pool
store *db.Store
renderer *Renderer
router http.Handler
}
type HomeData struct {
UserCount int
TransactionCount int
}
type DashboardData struct {
User *db.User
Transactions []db.TransactionView
}
type AdminDashboardData struct {
Admin *db.AdminUser
Dashboard *db.AdminDashboard
}
type UsersPageData struct {
Admin *db.AdminUser
Search string
Users []db.UserListItem
}
type UserDetailData struct {
Admin *db.AdminUser
User *db.User
Transactions []db.TransactionView
}
type LeaderboardData struct {
Entries []db.LeaderboardEntry
}
func NewApp(cfg config.Config) (*App, error) {
ctx := context.Background()
dbPool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open db pool: %w", err)
}
if err := dbPool.Ping(ctx); err != nil {
dbPool.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
if err := db.RunMigrations(ctx, dbPool, filepath.Join("migrations")); err != nil {
dbPool.Close()
return nil, fmt.Errorf("run migrations: %w", err)
}
store := db.NewStore(dbPool)
renderer, err := NewRenderer()
if err != nil {
dbPool.Close()
return nil, err
}
adminHash, err := auth.HashPassword(cfg.AdminPass)
if err != nil {
dbPool.Close()
return nil, fmt.Errorf("hash bootstrap admin password: %w", err)
}
if err := store.BootstrapAdmin(ctx, cfg.AdminEmail, adminHash); err != nil {
dbPool.Close()
return nil, err
}
app := &App{
cfg: cfg,
dbPool: dbPool,
store: store,
renderer: renderer,
}
app.router = app.routes()
return app, nil
}
func (a *App) Router() http.Handler {
return a.router
}
func (a *App) Close() {
a.dbPool.Close()
}
func (a *App) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RedirectSlashes)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(a.withCSRF)
r.Use(a.withCurrentSessions)
r.Get("/healthz", a.healthz)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static")))))
r.Get("/", a.home)
r.Get("/leaderboard", a.leaderboard)
r.Get("/register", a.showRegister)
r.Post("/register", a.register)
r.Get("/login", a.showLogin)
r.Post("/login", a.login)
r.Post("/logout", a.logout)
r.Get("/admin/login", a.showAdminLogin)
r.Post("/admin/login", a.adminLogin)
r.Post("/admin/logout", a.adminLogout)
r.Group(func(protected chi.Router) {
protected.Use(a.requireUser)
protected.Get("/app", a.userDashboard)
protected.Get("/app/transfer", a.showTransfer)
protected.Post("/app/transfer", a.createTransfer)
protected.Get("/app/transactions", a.transactionsPage)
})
r.Route("/admin", func(admin chi.Router) {
admin.Use(a.requireAdmin)
admin.Get("/", a.adminDashboard)
admin.Get("/users", a.adminUsers)
admin.Get("/users/{userID}", a.adminUserDetail)
admin.Post("/users/{userID}/freeze", a.freezeUser)
admin.Post("/users/{userID}/unfreeze", a.unfreezeUser)
admin.Post("/accounts/{accountID}/adjust", a.adjustBalance)
admin.Get("/audit-logs", a.auditLogs)
})
return r
}
func (a *App) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func (a *App) home(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
admin, _ := a.currentAdmin(r)
dashboard, err := a.store.AdminDashboard(r.Context())
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{
Title: "Superbanka",
Page: "home",
User: user,
Admin: admin,
CSRFToken: a.csrfToken(r),
Flash: a.readFlash(w, r),
Data: HomeData{
UserCount: dashboard.UserCount,
TransactionCount: dashboard.TransactionCount,
},
})
}
func (a *App) leaderboard(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
admin, _ := a.currentAdmin(r)
entries, err := a.store.Leaderboard(r.Context(), 100)
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{
Title: "Leaderboard",
Page: "leaderboard",
User: user,
Admin: admin,
CSRFToken: a.csrfToken(r),
Flash: a.readFlash(w, r),
Data: LeaderboardData{Entries: entries},
})
}
func (a *App) showRegister(w http.ResponseWriter, r *http.Request) {
a.render(w, http.StatusOK, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
}
func (a *App) register(w http.ResponseWriter, r *http.Request) {
if !a.verifyCSRF(r) {
a.render(w, http.StatusBadRequest, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
return
}
if err := r.ParseForm(); err != nil {
a.serverError(w, err)
return
}
email := strings.TrimSpace(r.FormValue("email"))
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if email == "" || username == "" || len(password) < 8 {
a.render(w, http.StatusBadRequest, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Email, username, and a password with at least 8 characters are required."})
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
a.serverError(w, err)
return
}
user, err := a.store.CreateUser(r.Context(), email, username, passwordHash)
if err != nil {
if errors.Is(err, db.ErrConflict) {
a.render(w, http.StatusConflict, ViewData{Title: "Register", Page: "register", CSRFToken: a.csrfToken(r), Error: "Email or username is already taken."})
return
}
a.serverError(w, err)
return
}
if err := a.startUserSession(w, r, user.ID); err != nil {
a.serverError(w, err)
return
}
a.setFlash(w, "Account created. Your signup bonus of 10 000,00 CZK is ready.")
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) showLogin(w http.ResponseWriter, r *http.Request) {
a.render(w, http.StatusOK, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
}
func (a *App) login(w http.ResponseWriter, r *http.Request) {
if !a.verifyCSRF(r) {
a.render(w, http.StatusBadRequest, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
return
}
if err := r.ParseForm(); err != nil {
a.serverError(w, err)
return
}
user, hash, err := a.store.GetUserByEmail(r.Context(), r.FormValue("email"))
if err != nil || !auth.CheckPassword(hash, r.FormValue("password")) {
a.render(w, http.StatusUnauthorized, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "Invalid email or password."})
return
}
if user.Status != "active" {
a.render(w, http.StatusForbidden, ViewData{Title: "Login", Page: "login", CSRFToken: a.csrfToken(r), Error: "This user is frozen."})
return
}
if err := a.startUserSession(w, r, user.ID); err != nil {
a.serverError(w, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if !a.verifyCSRF(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if cookie, err := r.Cookie(userSessionCookie); err == nil {
_ = a.store.DeleteUserSession(r.Context(), auth.HashToken(cookie.Value))
}
a.clearCookie(w, userSessionCookie)
a.setFlash(w, "Signed out.")
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *App) userDashboard(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
transactions, err := a.store.RecentTransactionsForAccount(r.Context(), user.AccountID, 10)
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{
Title: "Dashboard",
Page: "dashboard",
User: user,
CSRFToken: a.csrfToken(r),
Flash: a.readFlash(w, r),
Data: DashboardData{User: user, Transactions: transactions},
})
}
func (a *App) showTransfer(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
a.render(w, http.StatusOK, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
}
func (a *App) createTransfer(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
if !a.verifyCSRF(r) {
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
return
}
if err := r.ParseForm(); err != nil {
a.serverError(w, err)
return
}
amountMinor, err := platform.ParseCZK(r.FormValue("amount"))
if err != nil || amountMinor <= 0 {
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Enter a valid positive CZK amount."})
return
}
err = a.store.CreateTransfer(r.Context(), user.ID, r.FormValue("account_number"), r.FormValue("description"), amountMinor)
if err != nil {
switch {
case errors.Is(err, db.ErrInsufficientFunds):
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Insufficient funds."})
case errors.Is(err, db.ErrFrozen):
a.render(w, http.StatusForbidden, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "This transfer is not allowed because one of the accounts is frozen."})
case errors.Is(err, db.ErrNotFound):
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: "Destination account number was not found."})
default:
a.render(w, http.StatusBadRequest, ViewData{Title: "Send money", Page: "transfer", User: user, CSRFToken: a.csrfToken(r), Error: err.Error()})
}
return
}
a.setFlash(w, "Transfer completed.")
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) transactionsPage(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
transactions, err := a.store.RecentTransactionsForAccount(r.Context(), user.AccountID, 100)
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{Title: "Transactions", Page: "transactions", User: user, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: DashboardData{User: user, Transactions: transactions}})
}
func (a *App) showAdminLogin(w http.ResponseWriter, r *http.Request) {
a.render(w, http.StatusOK, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r)})
}
func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) {
if !a.verifyCSRF(r) {
a.render(w, http.StatusBadRequest, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "Invalid form token."})
return
}
if err := r.ParseForm(); err != nil {
a.serverError(w, err)
return
}
admin, hash, err := a.store.GetAdminByEmail(r.Context(), r.FormValue("email"))
if err != nil || !auth.CheckPassword(hash, r.FormValue("password")) {
a.render(w, http.StatusUnauthorized, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "Invalid email or password."})
return
}
if admin.Status != "active" {
a.render(w, http.StatusForbidden, ViewData{Title: "Admin login", Page: "admin_login", CSRFToken: a.csrfToken(r), Error: "This admin account is inactive."})
return
}
if err := a.startAdminSession(w, r, admin.ID); err != nil {
a.serverError(w, err)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
if !a.verifyCSRF(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if cookie, err := r.Cookie(adminSessionCookie); err == nil {
_ = a.store.DeleteAdminSession(r.Context(), auth.HashToken(cookie.Value))
}
a.clearCookie(w, adminSessionCookie)
a.setFlash(w, "Admin signed out.")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
func (a *App) adminDashboard(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
dashboard, err := a.store.AdminDashboard(r.Context())
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{Title: "Admin dashboard", Page: "admin_dashboard", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: AdminDashboardData{Admin: admin, Dashboard: dashboard}})
}
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
search := r.URL.Query().Get("q")
users, err := a.store.SearchUsers(r.Context(), search)
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{Title: "Users", Page: "admin_users", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: UsersPageData{Admin: admin, Search: search, Users: users}})
}
func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
user, transactions, err := a.store.GetUserDetail(r.Context(), chi.URLParam(r, "userID"))
if err != nil {
if errors.Is(err, db.ErrNotFound) {
http.NotFound(w, r)
return
}
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{Title: "User detail", Page: "admin_user_detail", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: UserDetailData{Admin: admin, User: user, Transactions: transactions}})
}
func (a *App) freezeUser(w http.ResponseWriter, r *http.Request) {
a.changeUserStatus(w, r, "frozen")
}
func (a *App) unfreezeUser(w http.ResponseWriter, r *http.Request) {
a.changeUserStatus(w, r, "active")
}
func (a *App) changeUserStatus(w http.ResponseWriter, r *http.Request, status string) {
admin, _ := a.currentAdmin(r)
if !a.verifyCSRF(r) {
a.setFlash(w, "Invalid form token.")
http.Redirect(w, r, "/admin/users/"+chi.URLParam(r, "userID"), http.StatusSeeOther)
return
}
if err := a.store.SetUserStatus(r.Context(), chi.URLParam(r, "userID"), status, admin.ID); err != nil {
a.setFlash(w, err.Error())
} else {
a.setFlash(w, "User status updated.")
}
http.Redirect(w, r, "/admin/users/"+chi.URLParam(r, "userID"), http.StatusSeeOther)
}
func (a *App) adjustBalance(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
if !a.verifyCSRF(r) {
a.setFlash(w, "Invalid form token.")
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
a.serverError(w, err)
return
}
amountMinor, err := platform.ParseCZK(r.FormValue("amount"))
if err != nil || amountMinor <= 0 {
a.setFlash(w, "Invalid adjustment amount.")
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
return
}
if err := a.store.CreateAdjustment(r.Context(), admin.ID, chi.URLParam(r, "accountID"), r.FormValue("direction"), amountMinor, r.FormValue("reason")); err != nil {
a.setFlash(w, err.Error())
} else {
a.setFlash(w, "Balance adjusted.")
}
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
}
func (a *App) auditLogs(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
logs, err := a.store.AuditLogs(r.Context())
if err != nil {
a.serverError(w, err)
return
}
a.render(w, http.StatusOK, ViewData{Title: "Audit logs", Page: "admin_audit_logs", Admin: admin, CSRFToken: a.csrfToken(r), Flash: a.readFlash(w, r), Data: logs})
}
func (a *App) withCurrentSessions(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if cookie, err := r.Cookie(userSessionCookie); err == nil && cookie.Value != "" {
if user, err := a.store.UserFromSession(ctx, auth.HashToken(cookie.Value)); err == nil {
ctx = context.WithValue(ctx, ctxUserKey, user)
}
}
if cookie, err := r.Cookie(adminSessionCookie); err == nil && cookie.Value != "" {
if admin, err := a.store.AdminFromSession(ctx, auth.HashToken(cookie.Value)); err == nil {
ctx = context.WithValue(ctx, ctxAdminKey, admin)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *App) requireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := a.currentUser(r)
if user == nil {
a.setFlash(w, "Please sign in first.")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Status != "active" {
a.clearCookie(w, userSessionCookie)
a.setFlash(w, "Your user is frozen.")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (a *App) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admin, _ := a.currentAdmin(r)
if admin == nil {
a.setFlash(w, "Please sign in as admin.")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (a *App) startUserSession(w http.ResponseWriter, r *http.Request, userID string) error {
token, err := platform.NewToken()
if err != nil {
return err
}
expires := time.Now().Add(24 * time.Hour)
if err := a.store.CreateUserSession(r.Context(), userID, auth.HashToken(token), expires); err != nil {
return err
}
a.setCookie(w, userSessionCookie, token, expires)
return nil
}
func (a *App) startAdminSession(w http.ResponseWriter, r *http.Request, adminID string) error {
token, err := platform.NewToken()
if err != nil {
return err
}
expires := time.Now().Add(24 * time.Hour)
if err := a.store.CreateAdminSession(r.Context(), adminID, auth.HashToken(token), expires); err != nil {
return err
}
a.setCookie(w, adminSessionCookie, token, expires)
return nil
}
func (a *App) withCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := ""
if cookie, err := r.Cookie(csrfCookie); err == nil && cookie.Value != "" {
token = cookie.Value
} else {
var err error
token, err = platform.NewToken()
if err != nil {
a.serverError(w, err)
return
}
a.setCookie(w, csrfCookie, token, time.Now().Add(7*24*time.Hour))
}
ctx := context.WithValue(r.Context(), ctxCSRFKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *App) csrfToken(r *http.Request) string {
if token, ok := r.Context().Value(ctxCSRFKey).(string); ok && token != "" {
return token
}
cookie, err := r.Cookie(csrfCookie)
if err != nil {
return ""
}
return cookie.Value
}
func (a *App) verifyCSRF(r *http.Request) bool {
return a.csrfToken(r) != "" && a.csrfToken(r) == r.FormValue("csrf_token")
}
func (a *App) currentUser(r *http.Request) (*db.User, bool) {
user, ok := r.Context().Value(ctxUserKey).(*db.User)
return user, ok
}
func (a *App) currentAdmin(r *http.Request) (*db.AdminUser, bool) {
admin, ok := r.Context().Value(ctxAdminKey).(*db.AdminUser)
return admin, ok
}
func (a *App) setCookie(w http.ResponseWriter, name, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: a.cfg.AppEnv == "production",
})
}
func (a *App) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: a.cfg.AppEnv == "production",
})
}
func (a *App) setFlash(w http.ResponseWriter, message string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: url.QueryEscape(message),
Path: "/",
MaxAge: 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: a.cfg.AppEnv == "production",
})
}
func (a *App) readFlash(w http.ResponseWriter, r *http.Request) string {
cookie, err := r.Cookie(flashCookie)
if err != nil || cookie.Value == "" {
return ""
}
a.clearCookie(w, flashCookie)
message, err := url.QueryUnescape(cookie.Value)
if err != nil {
return cookie.Value
}
return message
}
func (a *App) render(w http.ResponseWriter, status int, data ViewData) {
a.renderer.HTML(w, status, data)
}
func (a *App) serverError(w http.ResponseWriter, err error) {
http.Error(w, fmt.Sprintf("server error: %v", err), http.StatusInternalServerError)
}

60
internal/http/render.go Normal file
View file

@ -0,0 +1,60 @@
package http
import (
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"superbanka/internal/platform"
)
type ViewData struct {
Title string
Page string
CSRFToken string
Flash string
Error string
User any
Admin any
Data any
}
type Renderer struct {
templates *template.Template
}
func NewRenderer() (*Renderer, error) {
funcs := template.FuncMap{
"money": platform.FormatCZK,
"dt": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("02. 01. 2006 15:04")
},
}
templates, err := template.New("all").Funcs(funcs).ParseGlob(filepath.Join("web", "templates", "layouts", "*.gohtml"))
if err != nil {
return nil, fmt.Errorf("parse layout templates: %w", err)
}
templates, err = templates.ParseGlob(filepath.Join("web", "templates", "pages", "*.gohtml"))
if err != nil {
return nil, fmt.Errorf("parse page templates: %w", err)
}
return &Renderer{templates: templates}, nil
}
func (r *Renderer) HTML(w http.ResponseWriter, status int, data ViewData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := r.templates.ExecuteTemplate(w, "layouts/base", data); err != nil {
http.Error(w, fmt.Sprintf("render template: %v", err), http.StatusInternalServerError)
}
}

25
internal/platform/id.go Normal file
View file

@ -0,0 +1,25 @@
package platform
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
func NewID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random id bytes: %w", err)
}
return hex.EncodeToString(buf), nil
}
func NewToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random token bytes: %w", err)
}
return hex.EncodeToString(buf), nil
}

View file

@ -0,0 +1,79 @@
package platform
import (
"fmt"
"strconv"
"strings"
)
func FormatCZK(amountMinor int64) string {
sign := ""
if amountMinor < 0 {
sign = "-"
amountMinor = -amountMinor
}
whole := amountMinor / 100
fraction := amountMinor % 100
return fmt.Sprintf("%s%s,%02d CZK", sign, formatThousands(whole), fraction)
}
func ParseCZK(input string) (int64, error) {
clean := strings.TrimSpace(strings.ReplaceAll(input, " ", ""))
clean = strings.ReplaceAll(clean, ",", ".")
if clean == "" {
return 0, fmt.Errorf("amount is required")
}
parts := strings.Split(clean, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("invalid amount")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid whole amount")
}
var fraction int64
if len(parts) == 2 {
frac := parts[1]
if len(frac) > 2 {
return 0, fmt.Errorf("too many decimal places")
}
if len(frac) == 1 {
frac += "0"
}
if len(frac) == 0 {
frac = "00"
}
fraction, err = strconv.ParseInt(frac, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid decimal amount")
}
}
if whole < 0 || fraction < 0 {
return 0, fmt.Errorf("amount must be positive")
}
return whole*100 + fraction, nil
}
func formatThousands(value int64) string {
plain := strconv.FormatInt(value, 10)
if len(plain) <= 3 {
return plain
}
var parts []string
for len(plain) > 3 {
parts = append([]string{plain[len(plain)-3:]}, parts...)
plain = plain[:len(plain)-3]
}
parts = append([]string{plain}, parts...)
return strings.Join(parts, " ")
}

View file

@ -0,0 +1,31 @@
package platform
import "testing"
func TestParseCZK(t *testing.T) {
tests := []struct {
input string
want int64
}{
{input: "100", want: 10000},
{input: "100.50", want: 10050},
{input: "100,5", want: 10050},
}
for _, tt := range tests {
got, err := ParseCZK(tt.input)
if err != nil {
t.Fatalf("ParseCZK(%q) returned error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("ParseCZK(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestFormatCZK(t *testing.T) {
got := FormatCZK(1234567)
if got != "12 345,67 CZK" {
t.Fatalf("FormatCZK() = %q", got)
}
}