init
This commit is contained in:
commit
ef105e1cac
22 changed files with 3001 additions and 0 deletions
703
internal/http/app.go
Normal file
703
internal/http/app.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue