lottery
This commit is contained in:
parent
ef105e1cac
commit
b918b92a4d
6 changed files with 702 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -77,6 +78,31 @@ type LeaderboardEntry struct {
|
|||
BalanceMinor int64
|
||||
}
|
||||
|
||||
type LotteryRoundView struct {
|
||||
ID string
|
||||
PoolMinor int64
|
||||
DrawAt time.Time
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
UserTicketCount int
|
||||
UserContributionMinor int64
|
||||
}
|
||||
|
||||
type LotteryPayoutView struct {
|
||||
RoundID string
|
||||
WinnerUsername string
|
||||
WinnerAccount string
|
||||
PoolMinor int64
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type LotteryDashboard struct {
|
||||
ActiveRound *LotteryRoundView
|
||||
RecentPayouts []LotteryPayoutView
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
Action string
|
||||
TargetType string
|
||||
|
|
@ -95,6 +121,21 @@ type AdminDashboard struct {
|
|||
RecentAuditActivity []AuditLog
|
||||
}
|
||||
|
||||
type lotteryEntryRecord struct {
|
||||
UserID string
|
||||
AccountID string
|
||||
}
|
||||
|
||||
type lotterySettlementResult struct {
|
||||
RoundID string
|
||||
WinnerUsername string
|
||||
WinnerAccount string
|
||||
PoolMinor int64
|
||||
ParticipantCount int
|
||||
TicketCount int
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
|
@ -536,6 +577,210 @@ func (s *Store) CreateTransfer(ctx context.Context, senderUserID, destinationAcc
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLotteryDashboard(ctx context.Context, userID string) (*LotteryDashboard, error) {
|
||||
var dashboard LotteryDashboard
|
||||
|
||||
var active LotteryRoundView
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
select
|
||||
lr.id,
|
||||
lr.pool_minor,
|
||||
lr.draw_at,
|
||||
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0),
|
||||
coalesce((select sum(le.amount_minor) from lottery_entries le where le.round_id = lr.id and le.user_id = $1), 0)
|
||||
from lottery_rounds lr
|
||||
where lr.status = 'active'
|
||||
order by lr.created_at desc
|
||||
limit 1
|
||||
`, userID).Scan(
|
||||
&active.ID,
|
||||
&active.PoolMinor,
|
||||
&active.DrawAt,
|
||||
&active.ParticipantCount,
|
||||
&active.TicketCount,
|
||||
&active.UserTicketCount,
|
||||
&active.UserContributionMinor,
|
||||
)
|
||||
if err == nil {
|
||||
dashboard.ActiveRound = &active
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("load active lottery round: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
select
|
||||
lr.id,
|
||||
u.username,
|
||||
a.account_number,
|
||||
lr.pool_minor,
|
||||
coalesce((select count(distinct le.user_id) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
coalesce((select count(*) from lottery_entries le where le.round_id = lr.id), 0),
|
||||
lr.completed_at
|
||||
from lottery_rounds lr
|
||||
join users u on u.id = lr.winner_user_id
|
||||
join accounts a on a.id = lr.winner_account_id
|
||||
where lr.status = 'completed' and lr.winner_user_id is not null
|
||||
order by lr.completed_at desc
|
||||
limit 8
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lottery payouts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var item LotteryPayoutView
|
||||
if err := rows.Scan(
|
||||
&item.RoundID,
|
||||
&item.WinnerUsername,
|
||||
&item.WinnerAccount,
|
||||
&item.PoolMinor,
|
||||
&item.ParticipantCount,
|
||||
&item.TicketCount,
|
||||
&item.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lottery payout: %w", err)
|
||||
}
|
||||
dashboard.RecentPayouts = append(dashboard.RecentPayouts, item)
|
||||
}
|
||||
|
||||
return &dashboard, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateLotteryDeposit(ctx context.Context, userID string, amountMinor int64) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin lottery deposit: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type accountRecord struct {
|
||||
AccountID string
|
||||
UserStatus string
|
||||
Status string
|
||||
}
|
||||
|
||||
var account accountRecord
|
||||
err = tx.QueryRow(ctx, `
|
||||
select a.id, u.status, a.status
|
||||
from accounts a
|
||||
join users u on u.id = a.user_id
|
||||
where u.id = $1
|
||||
for update
|
||||
`, userID).Scan(&account.AccountID, &account.UserStatus, &account.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load lottery depositor account: %w", err)
|
||||
}
|
||||
if account.UserStatus != "active" || account.Status != "open" {
|
||||
return ErrFrozen
|
||||
}
|
||||
|
||||
balance, err := balanceForAccountTx(ctx, tx, account.AccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if balance < amountMinor {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
|
||||
roundID, drawAt, err := s.findOrCreateActiveLotteryRoundTx(ctx, tx, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledgerEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lotteryEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Lottery deposit for round closing at %s", drawAt.Format("15:04"))
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||
values ($1, 'lottery_deposit', 'completed', $2, $3, $4, now())
|
||||
`, transactionID, reference, userID, description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery deposit 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())
|
||||
`, ledgerEntryID, transactionID, account.AccountID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery deposit ledger entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into lottery_entries (id, round_id, user_id, account_id, amount_minor, created_at)
|
||||
values ($1, $2, $3, $4, $5, now())
|
||||
`, lotteryEntryID, roundID, userID, account.AccountID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lottery entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set pool_minor = pool_minor + $2
|
||||
where id = $1
|
||||
`, roundID, amountMinor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update lottery round pool: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit lottery deposit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SettleDueLotteryRound(ctx context.Context) (*LotteryPayoutView, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin lottery settlement: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
result, err := s.settleDueLotteryRoundTx(ctx, tx, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit lottery settlement: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &LotteryPayoutView{
|
||||
RoundID: result.RoundID,
|
||||
WinnerUsername: result.WinnerUsername,
|
||||
WinnerAccount: result.WinnerAccount,
|
||||
PoolMinor: result.PoolMinor,
|
||||
ParticipantCount: result.ParticipantCount,
|
||||
TicketCount: result.TicketCount,
|
||||
CompletedAt: result.CompletedAt,
|
||||
}, 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 {
|
||||
|
|
@ -821,6 +1066,171 @@ func (s *Store) insertAuditLog(ctx context.Context, adminID, action, targetType,
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) findOrCreateActiveLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (string, time.Time, error) {
|
||||
var roundID string
|
||||
var drawAt time.Time
|
||||
err := tx.QueryRow(ctx, `
|
||||
select id, draw_at
|
||||
from lottery_rounds
|
||||
where status = 'active'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`).Scan(&roundID, &drawAt)
|
||||
if err == nil {
|
||||
return roundID, drawAt, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", time.Time{}, fmt.Errorf("load active lottery round: %w", err)
|
||||
}
|
||||
|
||||
roundID, err = platform.NewID()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
drawAt = now.Add(time.Hour)
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into lottery_rounds (id, status, pool_minor, draw_at, created_at)
|
||||
values ($1, 'active', 0, $2, $3)
|
||||
`, roundID, drawAt, now)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("create lottery round: %w", err)
|
||||
}
|
||||
|
||||
return roundID, drawAt, nil
|
||||
}
|
||||
|
||||
func (s *Store) settleDueLotteryRoundTx(ctx context.Context, tx pgx.Tx, now time.Time) (*lotterySettlementResult, error) {
|
||||
var round struct {
|
||||
ID string
|
||||
PoolMinor int64
|
||||
DrawAt time.Time
|
||||
}
|
||||
|
||||
err := tx.QueryRow(ctx, `
|
||||
select id, pool_minor, draw_at
|
||||
from lottery_rounds
|
||||
where status = 'active'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update
|
||||
`).Scan(&round.ID, &round.PoolMinor, &round.DrawAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active lottery round for settlement: %w", err)
|
||||
}
|
||||
if round.DrawAt.After(now) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
select user_id, account_id
|
||||
from lottery_entries
|
||||
where round_id = $1
|
||||
order by created_at asc, id asc
|
||||
`, round.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load lottery entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []lotteryEntryRecord
|
||||
participants := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var entry lotteryEntryRecord
|
||||
if err := rows.Scan(&entry.UserID, &entry.AccountID); err != nil {
|
||||
return nil, fmt.Errorf("scan lottery entry: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
participants[entry.UserID] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entries) == 0 || round.PoolMinor == 0 {
|
||||
_, err := tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set status = 'completed', completed_at = $2
|
||||
where id = $1
|
||||
`, round.ID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete empty lottery round: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
winnerIndex, err := cryptoRandomIndex(len(entries))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
winner := entries[winnerIndex]
|
||||
|
||||
var winnerUsername, winnerAccount string
|
||||
err = tx.QueryRow(ctx, `
|
||||
select u.username, a.account_number
|
||||
from users u
|
||||
join accounts a on a.id = $1
|
||||
where u.id = $2
|
||||
`, winner.AccountID, winner.UserID).Scan(&winnerUsername, &winnerAccount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load lottery winner: %w", err)
|
||||
}
|
||||
|
||||
transactionID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reference, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledgerEntryID, err := platform.NewID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
insert into transactions (id, type, status, reference, initiated_by_user_id, description, created_at)
|
||||
values ($1, 'lottery_payout', 'completed', $2, $3, $4, $5)
|
||||
`, transactionID, reference, winner.UserID, fmt.Sprintf("Lottery payout from %d ticket(s)", len(entries)), now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert lottery payout 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, $5)
|
||||
`, ledgerEntryID, transactionID, winner.AccountID, round.PoolMinor, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert lottery payout ledger entry: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
update lottery_rounds
|
||||
set status = 'completed',
|
||||
winner_user_id = $2,
|
||||
winner_account_id = $3,
|
||||
completed_at = $4
|
||||
where id = $1
|
||||
`, round.ID, winner.UserID, winner.AccountID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete lottery round: %w", err)
|
||||
}
|
||||
|
||||
return &lotterySettlementResult{
|
||||
RoundID: round.ID,
|
||||
WinnerUsername: winnerUsername,
|
||||
WinnerAccount: winnerAccount,
|
||||
PoolMinor: round.PoolMinor,
|
||||
ParticipantCount: len(participants),
|
||||
TicketCount: len(entries),
|
||||
CompletedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) generateUniqueAccountNumber(ctx context.Context, tx pgx.Tx) (string, error) {
|
||||
for i := 0; i < 20; i++ {
|
||||
candidate, err := randomAccountNumber()
|
||||
|
|
@ -853,6 +1263,19 @@ func randomAccountNumber() (string, error) {
|
|||
return fmt.Sprintf("%010d", n), nil
|
||||
}
|
||||
|
||||
func cryptoRandomIndex(limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, fmt.Errorf("limit must be positive")
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(limit)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read random index: %w", err)
|
||||
}
|
||||
|
||||
return int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func balanceForAccountTx(ctx context.Context, tx pgx.Tx, accountID string) (int64, error) {
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ type DashboardData struct {
|
|||
Transactions []db.TransactionView
|
||||
}
|
||||
|
||||
type LotteryPageData struct {
|
||||
User *db.User
|
||||
Lottery *db.LotteryDashboard
|
||||
LastPayout *db.LotteryPayoutView
|
||||
}
|
||||
|
||||
type AdminDashboardData struct {
|
||||
Admin *db.AdminUser
|
||||
Dashboard *db.AdminDashboard
|
||||
|
|
@ -158,6 +164,8 @@ func (a *App) routes() http.Handler {
|
|||
protected.Get("/app", a.userDashboard)
|
||||
protected.Get("/app/transfer", a.showTransfer)
|
||||
protected.Post("/app/transfer", a.createTransfer)
|
||||
protected.Get("/app/lottery", a.lotteryPage)
|
||||
protected.Post("/app/lottery/deposit", a.createLotteryDeposit)
|
||||
protected.Get("/app/transactions", a.transactionsPage)
|
||||
})
|
||||
|
||||
|
|
@ -387,6 +395,94 @@ func (a *App) transactionsPage(w http.ResponseWriter, r *http.Request) {
|
|||
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) lotteryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
lastPayout, err := a.store.SettleDueLotteryRound(r.Context())
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderLotteryPage(w, r, http.StatusOK, updatedUser, lottery, lastPayout, "")
|
||||
}
|
||||
|
||||
func (a *App) createLotteryDeposit(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := a.currentUser(r)
|
||||
if !a.verifyCSRF(r) {
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, "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.renderLotteryError(w, r, user, http.StatusBadRequest, "Enter a valid positive CZK amount.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.store.CreateLotteryDeposit(r.Context(), user.ID, amountMinor); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrInsufficientFunds):
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, "Insufficient funds for this lottery deposit.")
|
||||
case errors.Is(err, db.ErrFrozen):
|
||||
a.renderLotteryError(w, r, user, http.StatusForbidden, "Lottery deposits are not available while the account is frozen.")
|
||||
default:
|
||||
a.renderLotteryError(w, r, user, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.setFlash(w, "Lottery deposit added to the active pool.")
|
||||
http.Redirect(w, r, "/app/lottery", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderLotteryPage(w http.ResponseWriter, r *http.Request, status int, user *db.User, lottery *db.LotteryDashboard, lastPayout *db.LotteryPayoutView, errMsg string) {
|
||||
a.render(w, status, ViewData{
|
||||
Title: "Lottery",
|
||||
Page: "lottery",
|
||||
User: user,
|
||||
CSRFToken: a.csrfToken(r),
|
||||
Flash: a.readFlash(w, r),
|
||||
Error: errMsg,
|
||||
Data: LotteryPageData{
|
||||
User: user,
|
||||
Lottery: lottery,
|
||||
LastPayout: lastPayout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) renderLotteryError(w http.ResponseWriter, r *http.Request, user *db.User, status int, errMsg string) {
|
||||
lottery, err := a.store.GetLotteryDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := a.store.GetUserDashboard(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
a.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderLotteryPage(w, r, status, updatedUser, lottery, nil, errMsg)
|
||||
}
|
||||
|
||||
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)})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue