NextExpense/main.go
cclohmar e831fcf617 fix: resolve 7 critical security findings from code review
CR-1: Path traversal in createReceiptZip — validate image_path is within storage/
CR-2: Missing authz on EditExpense/UpdateExpense — verify event ownership
CR-3: OTP timing side-channel — use crypto/subtle.ConstantTimeCompare
CR-4: Logout doesn't invalidate session — moved to AuthHandler with Sessions.Delete()
CR-5: OTP reuse race condition — mutex lock around validate+delete
CR-6: Live credentials on disk — removed .env from disk entirely
CR-7: No TLS — documented as expected behind-proxy deployment

Additional:
- Removed stale github.com/expenseflow import path from auth.go
- Made EnvironmentFile optional (prefix with -) so .env is not required
- App runs and starts clean without any .env file
2026-05-31 01:50:08 +00:00

197 lines
6.4 KiB
Go

// ExpenseFlow — 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 (DeepSeek Vision), 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 (
"log"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"github.com/cclohmar/ReceiptNext/internal/auth"
"github.com/cclohmar/ReceiptNext/internal/database"
"github.com/cclohmar/ReceiptNext/internal/email"
"github.com/cclohmar/ReceiptNext/internal/handlers"
)
func main() {
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
// Load environment variables from .env file (if present).
if err := godotenv.Load(); err != nil {
log.Printf("INFO [%s] main: no .env file found, using system environment",
time.Now().Format(time.RFC3339))
}
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")
// DeepSeek API key is read directly by the ai package.
_ = os.Getenv("DEEPSEEK_API_KEY")
// -----------------------------------------------------------------------
// Database
// -----------------------------------------------------------------------
db, err := database.Init()
if err != nil {
log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
}
defer db.Close()
// -----------------------------------------------------------------------
// Services
// -----------------------------------------------------------------------
sessionStore := auth.NewSessionStore()
failureTracker := auth.NewFailureTracker()
// 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 [%s] main: SMTP sender configured (%s:%s)",
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
} else {
log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
time.Now().Format(time.RFC3339))
}
// -----------------------------------------------------------------------
// 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,
}
// -----------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------
r := chi.NewRouter()
// Middleware.
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// 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")
}))
// Serve uploaded receipt images.
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
// ---- Public routes (no auth required) ----
r.Get("/", authHandler.LandingPage)
r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP)
// ---- 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.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
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)
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
})
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
addr := ":" + port
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
time.Now().Format(time.RFC3339), addr)
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
time.Now().Format(time.RFC3339), addr)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
}
}