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

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)
}
}