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 LotteryPageData struct { User *db.User Lottery *db.LotteryDashboard LastPayout *db.LotteryPayoutView } 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) } location, err := time.LoadLocation(cfg.Timezone) if err != nil { dbPool.Close() return nil, fmt.Errorf("load timezone %q: %w", cfg.Timezone, err) } time.Local = location store := db.NewStore(dbPool) renderer, err := NewRenderer(location) 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/lottery", a.lotteryPage) protected.Post("/app/lottery/deposit", a.createLotteryDeposit) 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) 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)}) } 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) }