From 7e288ea67add31fcf8e7d42f2c69773606befbe1 Mon Sep 17 00:00:00 2001 From: juancwu <46619361+juancwu@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:46:34 -0500 Subject: [PATCH] add middlewares, handlers and database models --- internal/config/config.go | 41 +++-- internal/ctxkeys/ctx.go | 47 ++++- .../migrations/00002_create_tokens_table.sql | 25 +++ .../00003_create_profiles_table.sql | 19 ++ .../migrations/00004_create_files_table.sql | 30 ++++ internal/handler/home.go | 14 ++ internal/middleware/chain.go | 21 +++ internal/middleware/config.go | 19 ++ internal/middleware/csrf.go | 108 +++++++++++ internal/middleware/logging.go | 70 ++++++++ internal/middleware/ratelimit.go | 151 ++++++++++++++++ internal/middleware/urlpath.go | 15 ++ internal/model/file.go | 23 +++ internal/model/profile.go | 11 ++ internal/model/token.go | 34 ++++ internal/routes/routes.go | 19 +- internal/service/auth.go | 6 + internal/service/email.go | 55 +++++- internal/ui/blocks/themeswitcher.templ | 30 ++++ internal/ui/components/csrf/csrf.templ | 35 ++++ internal/ui/layouts/auth.templ | 14 ++ internal/ui/layouts/base.templ | 169 ++++++++++++++++++ internal/ui/pages/auth.templ | 76 ++++++++ internal/validation/email.go | 27 +++ 24 files changed, 1045 insertions(+), 14 deletions(-) create mode 100644 internal/db/migrations/00002_create_tokens_table.sql create mode 100644 internal/db/migrations/00003_create_profiles_table.sql create mode 100644 internal/db/migrations/00004_create_files_table.sql create mode 100644 internal/handler/home.go create mode 100644 internal/middleware/chain.go create mode 100644 internal/middleware/config.go create mode 100644 internal/middleware/csrf.go create mode 100644 internal/middleware/logging.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/middleware/urlpath.go create mode 100644 internal/model/file.go create mode 100644 internal/model/profile.go create mode 100644 internal/model/token.go create mode 100644 internal/ui/blocks/themeswitcher.templ create mode 100644 internal/ui/components/csrf/csrf.templ create mode 100644 internal/ui/layouts/auth.templ create mode 100644 internal/ui/layouts/base.templ create mode 100644 internal/ui/pages/auth.templ create mode 100644 internal/validation/email.go diff --git a/internal/config/config.go b/internal/config/config.go index 484af08..b484545 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,11 +9,12 @@ import ( ) type Config struct { - AppName string - AppEnv string - AppURL string - Host string - Port string + AppName string + AppTagline string + AppEnv string + AppURL string + Host string + Port string DBDriver string DBConnection string @@ -32,11 +33,12 @@ func Load() *Config { } cfg := &Config{ - AppName: envString("APP_NAME", "Budgething"), - AppEnv: envRequired("APP_ENV"), - AppURL: envRequired("APP_URL"), - Host: envString("HOST", "127.0.0.1"), - Port: envString("PORT", "9000"), + AppName: envString("APP_NAME", "Budgit"), + AppTagline: envString("APP_TAGLINE", "Finance tracking made easy."), + AppEnv: envRequired("APP_ENV"), + AppURL: envRequired("APP_URL"), + Host: envString("HOST", "127.0.0.1"), + Port: envString("PORT", "9000"), DBDriver: envString("DB_DRIVER", "sqlite"), DBConnection: envString("DB_CONNECTION", "./data/local.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)"), @@ -51,6 +53,25 @@ func Load() *Config { return cfg } +func (cfg *Config) IsProduction() bool { + return cfg.AppEnv == "production" +} + +// Sanitized returns a copy of the config with only public/safe fields. +// All secrets, credentials, and sensitive data are excluded. +// Safe to expose in ctx, templates and client-facing contexts. +func (c *Config) Sanitized() *Config { + return &Config{ + AppName: c.AppName, + AppEnv: c.AppEnv, + AppURL: c.AppURL, + Port: c.Port, + AppTagline: c.AppTagline, + + EmailFrom: c.EmailFrom, + } +} + func envString(key, def string) string { value := os.Getenv(key) if value == "" { diff --git a/internal/ctxkeys/ctx.go b/internal/ctxkeys/ctx.go index 2b0c759..41c99bb 100644 --- a/internal/ctxkeys/ctx.go +++ b/internal/ctxkeys/ctx.go @@ -3,14 +3,59 @@ package ctxkeys import ( "context" + "git.juancwu.dev/juancwu/budgething/internal/config" "git.juancwu.dev/juancwu/budgething/internal/model" ) const ( - UserKey string = "user" + UserKey string = "user" + ProfileKey string = "profile" + URLPathKey string = "url_path" + ConfigKey string = "config" + CSRFTokenKey string = "csrf_token" ) func User(ctx context.Context) *model.User { user, _ := ctx.Value(UserKey).(*model.User) return user } + +func WithUser(ctx context.Context, user *model.User) context.Context { + return context.WithValue(ctx, UserKey, user) +} + +func Profile(ctx context.Context) *model.Profile { + profile, _ := ctx.Value(ProfileKey).(*model.Profile) + return profile +} + +func WithProfile(ctx context.Context, profile *model.Profile) context.Context { + return context.WithValue(ctx, ProfileKey, profile) +} + +func URLPath(ctx context.Context) string { + path, _ := ctx.Value(URLPathKey).(string) + return path +} + +func WithURLPath(ctx context.Context, urlPath string) context.Context { + return context.WithValue(ctx, URLPathKey, urlPath) +} + +func Config(ctx context.Context) *config.Config { + cfg, _ := ctx.Value(ConfigKey).(*config.Config) + return cfg +} + +func WithConfig(ctx context.Context, cfg *config.Config) context.Context { + return context.WithValue(ctx, ConfigKey, cfg) +} + +func CSRFToken(ctx context.Context) string { + token, _ := ctx.Value(CSRFTokenKey).(string) + return token +} + +func WithCSRFToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, CSRFTokenKey, token) +} diff --git a/internal/db/migrations/00002_create_tokens_table.sql b/internal/db/migrations/00002_create_tokens_table.sql new file mode 100644 index 0000000..80ced69 --- /dev/null +++ b/internal/db/migrations/00002_create_tokens_table.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token); +CREATE INDEX IF NOT EXISTS idx_tokens_expires_at ON tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens(user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_tokens_user_id; +DROP INDEX IF EXISTS idx_tokens_expires_at; +DROP INDEX IF EXISTS idx_tokens_token; +DROP TABLE IF EXISTS tokens; +-- +goose StatementEnd diff --git a/internal/db/migrations/00003_create_profiles_table.sql b/internal/db/migrations/00003_create_profiles_table.sql new file mode 100644 index 0000000..c06cfe4 --- /dev/null +++ b/internal/db/migrations/00003_create_profiles_table.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + user_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_profiles_user_id; +DROP TABLE IF EXISTS profiles; +-- +goose StatementEnd diff --git a/internal/db/migrations/00004_create_files_table.sql b/internal/db/migrations/00004_create_files_table.sql new file mode 100644 index 0000000..413d2ac --- /dev/null +++ b/internal/db/migrations/00004_create_files_table.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + type TEXT NOT NULL, + filename TEXT NOT NULL, + original_name TEXT, + mime_type TEXT, + size INTEGER, + storage_path TEXT NOT NULL, + public BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id); +CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_type, owner_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_files_owner_type ON files(owner_type, owner_id, type) WHERE type IN ('avatar'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_files_owner_type; +DROP INDEX IF EXISTS idx_files_owner; +DROP INDEX IF EXISTS idx_files_user_id; +DROP TABLE IF EXISTS files; +-- +goose StatementEnd diff --git a/internal/handler/home.go b/internal/handler/home.go new file mode 100644 index 0000000..9331a4c --- /dev/null +++ b/internal/handler/home.go @@ -0,0 +1,14 @@ +package handler + +import "net/http" + +type homeHandler struct{} + +func NewHomeHandler() *homeHandler { + return &homeHandler{} +} + +func (home *homeHandler) NotFoundPage(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 Page Not Found")) +} diff --git a/internal/middleware/chain.go b/internal/middleware/chain.go new file mode 100644 index 0000000..5dbf70a --- /dev/null +++ b/internal/middleware/chain.go @@ -0,0 +1,21 @@ +package middleware + +import "net/http" + +// Chain applies multiple middleware in order (first to last) +// The middleware are executed in the order they are provided +// +// Example: +// +// handler := Chain(mux, +// AuthMiddleware(...), // Executes first +// WithURLPath, // Executes second +// Config(...), // Executes third +// ) +func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler { + // Apply middleware in reverse order so they execute in the order provided + for i := len(middlewares) - 1; i >= 0; i-- { + h = middlewares[i](h) + } + return h +} diff --git a/internal/middleware/config.go b/internal/middleware/config.go new file mode 100644 index 0000000..e9f7a95 --- /dev/null +++ b/internal/middleware/config.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "net/http" + + "git.juancwu.dev/juancwu/budgething/internal/config" + "git.juancwu.dev/juancwu/budgething/internal/ctxkeys" +) + +// Config middleware adds the sanitized app configuration to the request context. +// Sensitive values like JWTSecret and DBPath are excluded for security. +func Config(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ctxkeys.WithConfig(r.Context(), cfg.Sanitized()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..1fcd438 --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,108 @@ +package middleware + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "log/slog" + "net/http" + "strings" + + "git.juancwu.dev/juancwu/budgething/internal/ctxkeys" +) + +const ( + csrfCookieName = "csrf_token" + csrfFormField = "csrf_token" + csrfHeader = "X-CSRF-Token" + csrfTokenLen = 32 +) + +// CSRFProtection validates CSRF tokens on all state-changing requests +func CSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip CSRF check for safe methods (GET, HEAD, OPTIONS) + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { + token := getOrGenerateCSRFToken(w, r) + ctx := ctxkeys.WithCSRFToken(r.Context(), token) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Skip CSRF check for webhooks (external services) + if strings.HasPrefix(r.URL.Path, "/webhooks/") { + next.ServeHTTP(w, r) + return + } + + // Validate CSRF token for state-changing methods (POST, PUT, PATCH, DELETE) + token := getOrGenerateCSRFToken(w, r) + ctx := ctxkeys.WithCSRFToken(r.Context(), token) + + // Get submitted token - try multiple sources in priority order + // 1. Header (HTMX automatic via meta tag) + // 2. Form field (both application/x-www-form-urlencoded and multipart/form-data) + // PostFormValue() automatically parses the request based on Content-Type + submittedToken := r.Header.Get(csrfHeader) + if submittedToken == "" { + submittedToken = r.PostFormValue(csrfFormField) + } + + // Validate token using constant-time comparison + if !validCSRFToken(token, submittedToken) { + slog.Warn("csrf validation failed", + "path", r.URL.Path, + "method", r.Method, + "ip", getClientIP(r), + ) + http.Error(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// getOrGenerateCSRFToken retrieves existing token or generates new one +func getOrGenerateCSRFToken(w http.ResponseWriter, r *http.Request) string { + cookie, err := r.Cookie(csrfCookieName) + if err == nil && cookie.Value != "" && len(cookie.Value) == base64.RawURLEncoding.EncodedLen(csrfTokenLen) { + return cookie.Value + } + + token := generateCSRFToken() + + cfg := ctxkeys.Config(r.Context()) + isProduction := cfg != nil && cfg.IsProduction() + + // Set cookie with SameSite=Lax for CSRF protection + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: isProduction, // Secure flag based on APP_ENV (safer than r.TLS behind load balancers) + SameSite: http.SameSiteLaxMode, + MaxAge: 86400 * 7, // 7 days + }) + + return token +} + +// generateCSRFToken creates cryptographically secure random token +func generateCSRFToken() string { + bytes := make([]byte, csrfTokenLen) + _, err := rand.Read(bytes) + if err != nil { + panic("failed to generate csrf token: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(bytes) +} + +// validCSRFToken performs constant-time comparison of tokens +func validCSRFToken(expected, actual string) bool { + if expected == "" || actual == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..2285dea --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "log/slog" + "net/http" + "strings" + "time" +) + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +func (rw *responseWriter) WriteHeader(code int) { + if !rw.written { + rw.statusCode = code + rw.written = true + rw.ResponseWriter.WriteHeader(code) + } +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + return rw.ResponseWriter.Write(b) +} + +// Paths to skip logging (static assets, etc.) +var skipLoggingPaths = []string{ + "/assets/", + "/uploads/", + "/favicon.ico", +} + +// RequestLogging logs HTTP requests with method, path, status, and duration +// Skips logging for paths defined in skipLoggingPaths +func RequestLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip logging for configured paths + for _, prefix := range skipLoggingPaths { + if strings.HasPrefix(r.URL.Path, prefix) { + next.ServeHTTP(w, r) + return + } + } + + start := time.Now() + + rw := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + written: false, + } + + next.ServeHTTP(rw, r) + + duration := time.Since(start) + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", rw.statusCode, + "duration_ms", duration.Milliseconds(), + "remote_addr", getClientIP(r), + ) + }) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..cb5c14e --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,151 @@ +package middleware + +import ( + "log/slog" + "net/http" + "strings" + "sync" + "time" +) + +// RateLimiter tracks request counts per IP address +type RateLimiter struct { + mu sync.RWMutex + requests map[string][]time.Time + limit int // Max requests allowed + window time.Duration // Time window for rate limiting +} + +// NewRateLimiter creates a new rate limiter +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + requests: make(map[string][]time.Time), + limit: limit, + window: window, + } + + // Start cleanup goroutine to prevent memory leak + go rl.cleanupLoop() + + return rl +} + +// Allow checks if request from IP should be allowed +func (rl *RateLimiter) Allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-rl.window) + + // Get requests for this IP + requests := rl.requests[ip] + + // Remove old requests outside time window + validRequests := []time.Time{} + for _, reqTime := range requests { + if reqTime.After(cutoff) { + validRequests = append(validRequests, reqTime) + } + } + + // Check if limit exceeded + if len(validRequests) >= rl.limit { + rl.requests[ip] = validRequests + return false + } + + // Add current request + validRequests = append(validRequests, now) + rl.requests[ip] = validRequests + + return true +} + +// cleanupLoop periodically removes old entries to prevent memory leak +func (rl *RateLimiter) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + rl.cleanup() + } +} + +// cleanup removes IPs with no recent requests +func (rl *RateLimiter) cleanup() { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-rl.window * 2) // Keep data for 2x window + + for ip, requests := range rl.requests { + // Check if all requests are old + allOld := true + for _, reqTime := range requests { + if reqTime.After(cutoff) { + allOld = false + break + } + } + + // Remove IP if all requests are old + if allOld { + delete(rl.requests, ip) + } + } +} + +// RateLimitAuth creates middleware for auth endpoints +// Limits: 5 requests per 15 minutes per IP +func RateLimitAuth() func(http.HandlerFunc) http.HandlerFunc { + limiter := NewRateLimiter(5, 15*time.Minute) + + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get real IP (handle proxies) + ip := getClientIP(r) + + // Check rate limit + if !limiter.Allow(ip) { + slog.Warn("rate limit exceeded", + "ip", ip, + "path", r.URL.Path, + ) + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + + next(w, r) + } + } +} + +// getClientIP extracts real client IP from request +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header (proxy/load balancer) + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + // Take first IP in list + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Check X-Real-IP header + xri := r.Header.Get("X-Real-IP") + if xri != "" { + return strings.TrimSpace(xri) + } + + // Fallback to RemoteAddr + ip := r.RemoteAddr + // Remove port if present + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + + return ip +} diff --git a/internal/middleware/urlpath.go b/internal/middleware/urlpath.go new file mode 100644 index 0000000..bf7687d --- /dev/null +++ b/internal/middleware/urlpath.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "net/http" + + "git.juancwu.dev/juancwu/budgething/internal/ctxkeys" +) + +// WithURLPath adds the current URL's path to the context +func WithURLPath(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxWithPath := ctxkeys.WithURLPath(r.Context(), r.URL.Path) + next.ServeHTTP(w, r.WithContext(ctxWithPath)) + }) +} diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..9087082 --- /dev/null +++ b/internal/model/file.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +const ( + FileTypeAvatar = "avatar" +) + +type File struct { + ID string `db:"id"` + UserID string `db:"user_id"` // Who owns/created this file + OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file + OwnerID string `db:"owner_id"` // Polymorphic FK + Type string `db:"type"` + Filename string `db:"filename"` + OriginalName string `db:"original_name"` + MimeType string `db:"mime_type"` + Size int64 `db:"size"` + StoragePath string `db:"storage_path"` + CreatedAt time.Time `db:"created_at"` +} diff --git a/internal/model/profile.go b/internal/model/profile.go new file mode 100644 index 0000000..32ef07a --- /dev/null +++ b/internal/model/profile.go @@ -0,0 +1,11 @@ +package model + +import "time" + +type Profile struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/internal/model/token.go b/internal/model/token.go new file mode 100644 index 0000000..0746c7d --- /dev/null +++ b/internal/model/token.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" +) + +type Token struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Type string `db:"type"` // "email_verify" or "password_reset" + Token string `db:"token"` + ExpiresAt time.Time `db:"expires_at"` + UsedAt *time.Time `db:"used_at"` + CreatedAt time.Time `db:"created_at"` +} + +const ( + TokenTypeEmailVerify = "email_verify" + TokenTypePasswordReset = "password_reset" + TokenTypeEmailChange = "email_change" + TokenTypeMagicLink = "magic_link" +) + +func (t *Token) IsExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +func (t *Token) IsUsed() bool { + return t.UsedAt != nil +} + +func (t *Token) IsValid() bool { + return !t.IsExpired() && !t.IsUsed() +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 1f07eb2..da3227d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -12,9 +12,14 @@ import ( func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler() + home := handler.NewHomeHandler() mux := http.NewServeMux() + // ==================================================================================== + // PUBLIC ROUTES + // ==================================================================================== + // Static sub, _ := fs.Sub(assets.AssetsFS, ".") mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) @@ -22,5 +27,17 @@ func SetupRoutes(a *app.App) http.Handler { // Auth pages mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage)) - return mux + // 404 + mux.HandleFunc("/{path...}", home.NotFoundPage) + + // Global middlewares + handler := middleware.Chain( + mux, + middleware.Config(a.Cfg), + middleware.RequestLogging, + middleware.CSRFProtection, + middleware.WithURLPath, + ) + + return handler } diff --git a/internal/service/auth.go b/internal/service/auth.go index d4d5490..1086e7b 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -14,6 +14,12 @@ var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrNoPassword = errors.New("account uses passwordless login. Use magic link") ErrPasswordsDoNotMatch = errors.New("passwords do not match") + ErrEmailAlreadyExists = errors.New("email already exists") + ErrWeakPassword = errors.New("password must be at least 12 characters") + ErrCommonPassword = errors.New("password is too common, please choose a stronger one") + ErrEmailNotVerified = errors.New("email not verified") + ErrInvalidEmail = errors.New("invalid email address") + ErrNameRequired = errors.New("name is required") ) type AuthService struct { diff --git a/internal/service/email.go b/internal/service/email.go index 4eef9b6..8040e50 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "log/slog" "github.com/resend/resend-go/v2" @@ -10,12 +11,16 @@ import ( type EmailParams struct { From string To []string + Bcc []string + Cc []string + ReplyTo string Subject string Text string + Html string } type EmailClient interface { - SendWithContext(ctx context.Context, params EmailParams) (string, error) + SendWithContext(ctx context.Context, params *EmailParams) (string, error) } type ResendClient struct { @@ -33,12 +38,16 @@ func NewResendClient(apiKey string) *ResendClient { return &ResendClient{client: client} } -func (c *ResendClient) SendWithContext(ctx context.Context, params EmailParams) (string, error) { +func (c *ResendClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) { res, err := c.client.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ From: params.From, To: params.To, + Bcc: params.Bcc, + Cc: params.Cc, + ReplyTo: params.ReplyTo, Subject: params.Subject, Text: params.Text, + Html: params.Html, }) if err != nil { return "", err @@ -63,3 +72,45 @@ func NewEmailService(client EmailClient, fromEmail, appURL, appName string, isDe appName: appName, } } + +func (s *EmailService) SendMagicLinkEmail(email, token, name string) error { + magicURL := fmt.Sprintf("%s/auth/magic-link/%s", s.appURL, token) + subject, body := magicLinkEmailTemplate(magicURL, s.appName) + + if s.isDev { + slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL) + return nil + } + + if s.client == nil { + return fmt.Errorf("email service not configured (missing RESEND_API_KEY)") + } + + params := &EmailParams{ + From: s.fromEmail, + To: []string{email}, + Subject: subject, + Text: body, + } + + _, err := s.client.SendWithContext(context.Background(), params) + if err == nil { + slog.Info("email sent", "type", "magic_link", "to", email) + } + return err +} + +func magicLinkEmailTemplate(magicURL, appName string) (string, string) { + subject := fmt.Sprintf("Sign in to %s", appName) + body := fmt.Sprintf(`Click this link to sign in to your account: +%s + +This link expires in 10 minutes and can only be used once. + +If you didn't request this, ignore this email. + +Best, +The %s Team`, magicURL, appName) + + return subject, body +} diff --git a/internal/ui/blocks/themeswitcher.templ b/internal/ui/blocks/themeswitcher.templ new file mode 100644 index 0000000..1b788d5 --- /dev/null +++ b/internal/ui/blocks/themeswitcher.templ @@ -0,0 +1,30 @@ +package blocks + +import ( + "git.juancwu.dev/juancwu/budgething/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgething/internal/ui/components/icon" +) + +type ThemeSwitcherProps struct { + Class string +} + +// ThemeSwitcher renders only the UI button. +// The interactive script is handled globally in the layout. +templ ThemeSwitcher(props ...ThemeSwitcherProps) { + {{ var p ThemeSwitcherProps }} + if len(props) > 0 { + {{ p = props[0] }} + } + @button.Button(button.Props{ + Size: button.SizeIcon, + Variant: button.VariantGhost, + Class: p.Class, + Attributes: templ.Attributes{ + "data-theme-switcher": "true", + "aria-label": "Toggle theme", + }, + }) { + @icon.Eclipse(icon.Props{Size: 20}) + } +} diff --git a/internal/ui/components/csrf/csrf.templ b/internal/ui/components/csrf/csrf.templ new file mode 100644 index 0000000..c6e2644 --- /dev/null +++ b/internal/ui/components/csrf/csrf.templ @@ -0,0 +1,35 @@ +package csrf + +import "git.juancwu.dev/juancwu/budgething/internal/ctxkeys" + +// Token renders a hidden CSRF token input for form submissions. +// +// Usage in forms: +// +//
+// +// Security: This token protects against Cross-Site Request Forgery (CSRF) attacks. +// The token is validated server-side via middleware.CSRFProtection middleware. +// +// How it works: +// 1. Server generates random token and stores in HttpOnly cookie +// 2. Server renders token in this hidden input +// 3. On form submit, both cookie and form field are sent +// 4. Server compares: cookie token == form token (constant-time comparison) +// 5. If match → request allowed, if mismatch → 403 Forbidden +// +// Why this protects against CSRF: +// - Attacker can trigger cookie to be sent (automatic browser behavior) +// - But attacker CANNOT read cookie value (HttpOnly + SameSite) +// - Therefore attacker CANNOT set correct form field value +// - Server rejects request because tokens don't match +// +// This is called "Double Submit Cookie" pattern - industry standard used by +// Stripe, GitHub, Shopify, and recommended by OWASP. +templ Token() { + +} diff --git a/internal/ui/layouts/auth.templ b/internal/ui/layouts/auth.templ new file mode 100644 index 0000000..9dbb60f --- /dev/null +++ b/internal/ui/layouts/auth.templ @@ -0,0 +1,14 @@ +package layouts + +import "git.juancwu.dev/juancwu/budgething/internal/ui/blocks" + +templ Auth(seo SEOProps) { + @Base(seo) { +Sign in or create your account
+