add middlewares, handlers and database models
This commit is contained in:
parent
979a415b95
commit
7e288ea67a
24 changed files with 1045 additions and 14 deletions
|
|
@ -9,11 +9,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppName string
|
AppName string
|
||||||
AppEnv string
|
AppTagline string
|
||||||
AppURL string
|
AppEnv string
|
||||||
Host string
|
AppURL string
|
||||||
Port string
|
Host string
|
||||||
|
Port string
|
||||||
|
|
||||||
DBDriver string
|
DBDriver string
|
||||||
DBConnection string
|
DBConnection string
|
||||||
|
|
@ -32,11 +33,12 @@ func Load() *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
AppName: envString("APP_NAME", "Budgething"),
|
AppName: envString("APP_NAME", "Budgit"),
|
||||||
AppEnv: envRequired("APP_ENV"),
|
AppTagline: envString("APP_TAGLINE", "Finance tracking made easy."),
|
||||||
AppURL: envRequired("APP_URL"),
|
AppEnv: envRequired("APP_ENV"),
|
||||||
Host: envString("HOST", "127.0.0.1"),
|
AppURL: envRequired("APP_URL"),
|
||||||
Port: envString("PORT", "9000"),
|
Host: envString("HOST", "127.0.0.1"),
|
||||||
|
Port: envString("PORT", "9000"),
|
||||||
|
|
||||||
DBDriver: envString("DB_DRIVER", "sqlite"),
|
DBDriver: envString("DB_DRIVER", "sqlite"),
|
||||||
DBConnection: envString("DB_CONNECTION", "./data/local.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)"),
|
DBConnection: envString("DB_CONNECTION", "./data/local.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)"),
|
||||||
|
|
@ -51,6 +53,25 @@ func Load() *Config {
|
||||||
return cfg
|
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 {
|
func envString(key, def string) string {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,59 @@ package ctxkeys
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/config"
|
||||||
"git.juancwu.dev/juancwu/budgething/internal/model"
|
"git.juancwu.dev/juancwu/budgething/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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 {
|
func User(ctx context.Context) *model.User {
|
||||||
user, _ := ctx.Value(UserKey).(*model.User)
|
user, _ := ctx.Value(UserKey).(*model.User)
|
||||||
return 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
25
internal/db/migrations/00002_create_tokens_table.sql
Normal file
25
internal/db/migrations/00002_create_tokens_table.sql
Normal file
|
|
@ -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
|
||||||
19
internal/db/migrations/00003_create_profiles_table.sql
Normal file
19
internal/db/migrations/00003_create_profiles_table.sql
Normal file
|
|
@ -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
|
||||||
30
internal/db/migrations/00004_create_files_table.sql
Normal file
30
internal/db/migrations/00004_create_files_table.sql
Normal file
|
|
@ -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
|
||||||
14
internal/handler/home.go
Normal file
14
internal/handler/home.go
Normal file
|
|
@ -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"))
|
||||||
|
}
|
||||||
21
internal/middleware/chain.go
Normal file
21
internal/middleware/chain.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
19
internal/middleware/config.go
Normal file
19
internal/middleware/config.go
Normal file
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/middleware/csrf.go
Normal file
108
internal/middleware/csrf.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
70
internal/middleware/logging.go
Normal file
70
internal/middleware/logging.go
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
151
internal/middleware/ratelimit.go
Normal file
151
internal/middleware/ratelimit.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
15
internal/middleware/urlpath.go
Normal file
15
internal/middleware/urlpath.go
Normal file
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
23
internal/model/file.go
Normal file
23
internal/model/file.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
11
internal/model/profile.go
Normal file
11
internal/model/profile.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
34
internal/model/token.go
Normal file
34
internal/model/token.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,14 @@ import (
|
||||||
|
|
||||||
func SetupRoutes(a *app.App) http.Handler {
|
func SetupRoutes(a *app.App) http.Handler {
|
||||||
auth := handler.NewAuthHandler()
|
auth := handler.NewAuthHandler()
|
||||||
|
home := handler.NewHomeHandler()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// ====================================================================================
|
||||||
|
// PUBLIC ROUTES
|
||||||
|
// ====================================================================================
|
||||||
|
|
||||||
// Static
|
// Static
|
||||||
sub, _ := fs.Sub(assets.AssetsFS, ".")
|
sub, _ := fs.Sub(assets.AssetsFS, ".")
|
||||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
|
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
|
// Auth pages
|
||||||
mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage))
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ var (
|
||||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
ErrNoPassword = errors.New("account uses passwordless login. Use magic link")
|
ErrNoPassword = errors.New("account uses passwordless login. Use magic link")
|
||||||
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
|
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 {
|
type AuthService struct {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/resend/resend-go/v2"
|
"github.com/resend/resend-go/v2"
|
||||||
|
|
@ -10,12 +11,16 @@ import (
|
||||||
type EmailParams struct {
|
type EmailParams struct {
|
||||||
From string
|
From string
|
||||||
To []string
|
To []string
|
||||||
|
Bcc []string
|
||||||
|
Cc []string
|
||||||
|
ReplyTo string
|
||||||
Subject string
|
Subject string
|
||||||
Text string
|
Text string
|
||||||
|
Html string
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailClient interface {
|
type EmailClient interface {
|
||||||
SendWithContext(ctx context.Context, params EmailParams) (string, error)
|
SendWithContext(ctx context.Context, params *EmailParams) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResendClient struct {
|
type ResendClient struct {
|
||||||
|
|
@ -33,12 +38,16 @@ func NewResendClient(apiKey string) *ResendClient {
|
||||||
return &ResendClient{client: client}
|
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{
|
res, err := c.client.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||||
From: params.From,
|
From: params.From,
|
||||||
To: params.To,
|
To: params.To,
|
||||||
|
Bcc: params.Bcc,
|
||||||
|
Cc: params.Cc,
|
||||||
|
ReplyTo: params.ReplyTo,
|
||||||
Subject: params.Subject,
|
Subject: params.Subject,
|
||||||
Text: params.Text,
|
Text: params.Text,
|
||||||
|
Html: params.Html,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -63,3 +72,45 @@ func NewEmailService(client EmailClient, fromEmail, appURL, appName string, isDe
|
||||||
appName: appName,
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
30
internal/ui/blocks/themeswitcher.templ
Normal file
30
internal/ui/blocks/themeswitcher.templ
Normal file
|
|
@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/ui/components/csrf/csrf.templ
Normal file
35
internal/ui/components/csrf/csrf.templ
Normal file
|
|
@ -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:
|
||||||
|
//
|
||||||
|
// <form action="/auth/login" method="POST">
|
||||||
|
// @csrf.Token()
|
||||||
|
// <input name="email" type="email">
|
||||||
|
// <button>Login</button>
|
||||||
|
// </form>
|
||||||
|
//
|
||||||
|
// 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() {
|
||||||
|
<input type="hidden" name="csrf_token" value={ ctxkeys.CSRFToken(ctx) }/>
|
||||||
|
}
|
||||||
14
internal/ui/layouts/auth.templ
Normal file
14
internal/ui/layouts/auth.templ
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package layouts
|
||||||
|
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/blocks"
|
||||||
|
|
||||||
|
templ Auth(seo SEOProps) {
|
||||||
|
@Base(seo) {
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute top-4 right-4 z-10">
|
||||||
|
@blocks.ThemeSwitcher()
|
||||||
|
</div>
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/ui/layouts/base.templ
Normal file
169
internal/ui/layouts/base.templ
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
package layouts
|
||||||
|
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/dialog"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/sidebar"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/collapsible"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ctxkeys"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/dropdown"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/toast"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/calendar"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/datepicker"
|
||||||
|
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/progress"
|
||||||
|
import "fmt"
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SEOProps struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Base(props ...SEOProps) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta name="csrf-token" content={ ctxkeys.CSRFToken(ctx) }/>
|
||||||
|
if len(props) > 0 {
|
||||||
|
@seo(props[0])
|
||||||
|
} else {
|
||||||
|
{{ cfg := ctxkeys.Config(ctx) }}
|
||||||
|
<title>{ cfg.AppName }</title>
|
||||||
|
}
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/favicon/favicon.ico"/>
|
||||||
|
<link href={ "/assets/css/output.css?v=" + templ.EscapeString(fmt.Sprintf("%d", time.Now().Unix())) } rel="stylesheet"/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" integrity="sha384-ZBXiYtYQ6hJ2Y0ZNoYuI+Nq5MqWBr+chMrS/RkXpNzQCApHEhOt2aY8EJgqwHLkJ" crossorigin="anonymous"></script>
|
||||||
|
// Component scripts
|
||||||
|
@input.Script()
|
||||||
|
@sidebar.Script()
|
||||||
|
@dialog.Script()
|
||||||
|
@collapsible.Script()
|
||||||
|
@dropdown.Script()
|
||||||
|
@popover.Script()
|
||||||
|
@toast.Script()
|
||||||
|
@calendar.Script()
|
||||||
|
@datepicker.Script()
|
||||||
|
@progress.Script()
|
||||||
|
// Site-wide enhancements
|
||||||
|
@themeScript()
|
||||||
|
// Must run before body to prevent flash
|
||||||
|
@smoothScrollScript()
|
||||||
|
// HTMX CSRF configuration
|
||||||
|
@htmxCSRFScript()
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
{ children... }
|
||||||
|
// Global Toast Container
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
// Built with goilerplate Badge
|
||||||
|
<a
|
||||||
|
href="https://goilerplate.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="fixed bottom-4 right-4 z-40 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-full bg-background/80 backdrop-blur-sm transition-all hover:scale-105 hover:shadow-md flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-foreground animate-pulse"></span>
|
||||||
|
Built with goilerplate
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ seo(props SEOProps) {
|
||||||
|
{{ cfg := ctxkeys.Config(ctx) }}
|
||||||
|
{{ baseURL := cfg.AppURL }}
|
||||||
|
{{ appName := cfg.AppName }}
|
||||||
|
{{ appTagline := cfg.AppTagline }}
|
||||||
|
{{ fullTitle := props.Title + " - " + appName }}
|
||||||
|
@templ.Fragment("seo-title") {
|
||||||
|
<title id="page-title" hx-swap-oob="true">{ fullTitle }</title>
|
||||||
|
}
|
||||||
|
<meta name="description" content={ props.Description }/>
|
||||||
|
// Author
|
||||||
|
<meta name="author" content={ appName }/>
|
||||||
|
// Robots Tags
|
||||||
|
<meta name="robots" content="index, follow"/>
|
||||||
|
// Canonical URL
|
||||||
|
<link rel="canonical" href={ baseURL + props.Path }/>
|
||||||
|
// OpenGraph Tags
|
||||||
|
<meta property="og:title" content={ fullTitle }/>
|
||||||
|
<meta property="og:description" content={ props.Description }/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
<meta property="og:url" content={ baseURL + props.Path }/>
|
||||||
|
<meta property="og:site_name" content={ appName }/>
|
||||||
|
// OpenGraph Image
|
||||||
|
<meta property="og:image" content={ baseURL + "/assets/img/social-preview.png" }/>
|
||||||
|
<meta property="og:image:width" content="1200"/>
|
||||||
|
<meta property="og:image:height" content="630"/>
|
||||||
|
<meta property="og:image:alt" content={ appName + " - " + appTagline }/>
|
||||||
|
// Twitter Card
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content={ fullTitle }/>
|
||||||
|
<meta name="twitter:description" content={ props.Description }/>
|
||||||
|
<meta name="twitter:image" content={ baseURL + "/assets/img/social-preview.png" }/>
|
||||||
|
<meta name="twitter:image:alt" content={ appName + " - " + appTagline }/>
|
||||||
|
// Theme Color
|
||||||
|
<meta name="theme-color" content="#000000"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ themeScript() {
|
||||||
|
<script nonce={ templ.GetNonce(ctx) }>
|
||||||
|
// Apply saved theme or system preference on load
|
||||||
|
if (localStorage.theme === 'dark' || (!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle handler
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-theme-switcher]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const isDark = document.documentElement.classList.toggle('dark');
|
||||||
|
localStorage.theme = isDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ smoothScrollScript() {
|
||||||
|
// Smooth scrolling for anchor links - works site-wide
|
||||||
|
<script nonce={ templ.GetNonce(ctx) }>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', function (e) {
|
||||||
|
// Only prevent default for same-page anchors
|
||||||
|
const href = this.getAttribute('href');
|
||||||
|
if (href && href !== '#' && href.startsWith('#')) {
|
||||||
|
const target = document.querySelector(href);
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ htmxCSRFScript() {
|
||||||
|
// Configure HTMX to automatically send CSRF token with all requests
|
||||||
|
<script nonce={ templ.GetNonce(ctx) }>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Listen for htmx requests and add CSRF token header
|
||||||
|
document.body.addEventListener('htmx:configRequest', function(event) {
|
||||||
|
// Get CSRF token from meta tag
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (meta) {
|
||||||
|
// Add token as X-CSRF-Token header to all HTMX requests
|
||||||
|
event.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
76
internal/ui/pages/auth.templ
Normal file
76
internal/ui/pages/auth.templ
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ctxkeys"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/csrf"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/form"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/components/label"
|
||||||
|
"git.juancwu.dev/juancwu/budgething/internal/ui/layouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Auth(errorMsg string) {
|
||||||
|
{{ cfg := ctxkeys.Config(ctx) }}
|
||||||
|
@layouts.Auth(layouts.SEOProps{
|
||||||
|
Title: "Welcome",
|
||||||
|
Description: "Sign in or create your account",
|
||||||
|
Path: ctxkeys.URLPath(ctx),
|
||||||
|
}) {
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Variant: button.VariantSecondary,
|
||||||
|
Size: button.SizeLg,
|
||||||
|
Href: "/",
|
||||||
|
}) {
|
||||||
|
@icon.Layers()
|
||||||
|
{ cfg.AppName }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold">Welcome</h2>
|
||||||
|
<p class="text-muted-foreground mt-2">Sign in or create your account</p>
|
||||||
|
</div>
|
||||||
|
<form action="/auth/magic-link" method="POST" class="space-y-6">
|
||||||
|
@csrf.Token()
|
||||||
|
@form.Item() {
|
||||||
|
@label.Label(label.Props{
|
||||||
|
For: "email",
|
||||||
|
Class: "block mb-2",
|
||||||
|
}) {
|
||||||
|
Email
|
||||||
|
}
|
||||||
|
@input.Input(input.Props{
|
||||||
|
ID: "email",
|
||||||
|
Name: "email",
|
||||||
|
Type: input.TypeEmail,
|
||||||
|
Placeholder: "name@example.com",
|
||||||
|
HasError: errorMsg != "",
|
||||||
|
Attributes: templ.Attributes{"autofocus": ""},
|
||||||
|
})
|
||||||
|
if errorMsg != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ errorMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Type: button.TypeSubmit,
|
||||||
|
FullWidth: true,
|
||||||
|
}) {
|
||||||
|
Continue with Email
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
<!-- Password option -->
|
||||||
|
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
<a href="/auth/password" class="text-primary hover:underline">
|
||||||
|
Sign in with password instead
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/validation/email.go
Normal file
27
internal/validation/email.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateEmail validates email format and length
|
||||||
|
// Uses Go's built-in net/mail parser which follows RFC 5322
|
||||||
|
func ValidateEmail(email string) error {
|
||||||
|
// Check length (RFC 5321: local part max 64, domain max 255, total max 254 with @)
|
||||||
|
if len(email) > 254 {
|
||||||
|
return errors.New("email address is too long (max 254 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
return errors.New("email address is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse using Go's RFC 5322 compliant parser
|
||||||
|
_, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid email address format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue