NextExpense/main.go
cclohmar 517e95adc2 chore: rebrand ReceiptNext to NextExpense
- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense
- Update all import paths across 7 Go source files
- Update templates (titles, headings, branding)
- Update static files (manifest.json, sw.js, CSS)
- Update config (Makefile, install.sh, .env.example)
- Update README with new name and URLs
- Rename service file receiptnext.service -> nextexpense.service
- Update install paths, service names, log paths in install.sh
2026-06-21 18:39:48 +00:00

284 lines
9.3 KiB
Go

// NextExpense — AI-Powered Expense Tracker
//
// A production-ready, mobile-first Progressive Web App (PWA) that uses
// passwordless email OTP login, event-based expense tracking, AI receipt
// extraction (Gemini / OpenAI), and event filing (CSV/PDF via email).
//
// Usage:
// Copy .env.example to .env and fill in credentials, then:
// go run main.go
//
// The server starts on the port specified by the PORT env var (default 8080).
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"github.com/cclohmar/NextExpense/internal/auth"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/handlers"
"github.com/cclohmar/NextExpense/internal/utils"
)
func main() {
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
// Load environment variables from .env file (if present).
if err := godotenv.Load(); err != nil {
log.Printf("INFO main: no .env file found, using system environment")
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// SMTP configuration.
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := os.Getenv("SMTP_PORT")
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
// -----------------------------------------------------------------------
// Database
// -----------------------------------------------------------------------
db, err := database.Init()
if err != nil {
log.Fatalf("FATAL main: database init: %v", err)
}
defer db.Close()
// -----------------------------------------------------------------------
// Services
// -----------------------------------------------------------------------
sessionStore := auth.NewSessionStore()
failureTracker := auth.NewFailureTracker()
// Start background session cleanup.
go func() {
for {
time.Sleep(15 * time.Minute)
sessionStore.Cleanup()
}
}()
// Create the email sender only if SMTP credentials are configured.
var emailSender *email.Sender
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, smtpUser)
log.Printf("INFO main: SMTP sender configured (%s:%s)", smtpHost, smtpPort)
} else {
log.Printf("WARN main: SMTP not configured — OTP emails will not be sent")
}
// -----------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------
authHandler := &handlers.AuthHandler{
DB: db,
Sessions: sessionStore,
FailureTracker: failureTracker,
EmailSender: emailSender,
}
eventHandler := handlers.NewEventHandler(db)
expenseHandler := handlers.NewExpenseHandler(db)
fileHandler := &handlers.FileHandler{
DB: db,
EmailSender: emailSender,
}
// Start background cleanup of expired download packages.
fileHandler.StartDownloadCleanup()
// -----------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------
r := chi.NewRouter()
// Middleware.
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// Request body size limit (10 MB) on all endpoints.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
next.ServeHTTP(w, r)
})
})
// Security headers.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; script-src 'self' https://unpkg.com/htmx.org@1.9.10 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
next.ServeHTTP(w, r)
})
})
// Request ID middleware for log tracing.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = utils.NewUUID()[:8]
}
ctx := context.WithValue(r.Context(), "req_id", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
// PWA headers for service worker.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sw.js" {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
}
next.ServeHTTP(w, r)
})
})
// Static file serving.
fileServer := http.FileServer(http.Dir("static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// Serve the manifest.json and sw.js from the root for PWA compliance.
r.Get("/sw.js", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
http.ServeFile(w, r, "static/sw.js")
}))
r.Get("/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "static/manifest.json")
}))
// iOS PWA / Safari root-level icon requests.
r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/apple-touch-icon-120x120.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.svg")
}))
// ---- Public routes (no auth required) ----
r.Get("/", authHandler.LandingPage)
r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP)
// Download link (token-based auth, no login required).
r.Get("/dl/{token}", fileHandler.ServeDownload)
r.Get("/dl/{token}/{name}", fileHandler.ServeDownload)
// ---- Logout (invalidates server-side session + clears cookie) ----
r.Post("/logout", authHandler.Logout)
// ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Events.
r.Get("/dashboard", eventHandler.Dashboard)
r.Get("/onboarding", authHandler.OnboardingPage)
r.Post("/onboarding", authHandler.SaveOnboarding)
r.Get("/profile", authHandler.ProfilePage)
r.Post("/profile", authHandler.SaveProfile)
r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}", eventHandler.UpdateEvent)
r.Get("/events/{id}/edit", eventHandler.EditEvent)
r.Delete("/events/{id}", eventHandler.DeleteEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Post("/events/{id}/close", eventHandler.CloseEvent)
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
// Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
r.Post("/expenses", expenseHandler.SaveExpense)
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
r.Delete("/expenses/{id}", expenseHandler.DeleteExpense)
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
r.Post("/events/{id}/generate", fileHandler.GenerateReport)
r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink)
// Storage (receipt images) — protected by auth + path traversal check.
r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) {
imagePath := strings.TrimPrefix(r.URL.Path, "/storage/")
cleanPath := filepath.Clean(imagePath)
if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, "../") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, filepath.Join("storage", cleanPath))
})
})
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
addr := ":" + port
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on SIGINT / SIGTERM.
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("INFO main: received signal %v, shutting down...", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("ERROR main: graceful shutdown: %v", err)
}
}()
log.Printf("INFO main: NextExpense server starting on %s", addr)
log.Printf("INFO main: open http://localhost%s in your browser", addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("FATAL main: server error: %v", err)
}
log.Printf("INFO main: server stopped")
}