NextReceipt/main.go
cclohmar e862d494aa feat: add product search with HTMX live filtering
- Search purchasers by product name, store, or notes (case-insensitive)
- GET /search?q=... returns purchase list fragment
- 300ms debounced keyup trigger on search input
- Empty query returns all purchases
2026-06-21 19:00:34 +00:00

259 lines
8.1 KiB
Go

// NextReceipt — AI-Powered Receipt Saver for Warranty & Returns
//
// A production-ready, mobile-first Progressive Web App (PWA) that uses
// passwordless email OTP login, AI receipt extraction (Gemini / OpenAI),
// and receipt storage for warranty and return tracking.
//
// 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/NextReceipt/internal/auth"
"github.com/cclohmar/NextReceipt/internal/database"
"github.com/cclohmar/NextReceipt/internal/email"
"github.com/cclohmar/NextReceipt/internal/handlers"
"github.com/cclohmar/NextReceipt/internal/utils"
)
func main() {
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
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,
}
dashboardHandler := handlers.NewDashboardHandler(db)
purchaseHandler := handlers.NewPurchaseHandler(db)
// -----------------------------------------------------------------------
// 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 PWA files.
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)
// ---- Logout ----
r.Post("/logout", authHandler.Logout)
// ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Dashboard.
r.Get("/dashboard", dashboardHandler.Dashboard)
// Search.
r.Get("/search", dashboardHandler.SearchPurchases)
// Purchases.
r.Post("/purchases/upload", purchaseHandler.UploadReceipt)
r.Post("/purchases", purchaseHandler.SavePurchase)
r.Get("/purchases/{id}/edit", purchaseHandler.EditPurchase)
r.Put("/purchases/{id}", purchaseHandler.UpdatePurchase)
r.Delete("/purchases/{id}", purchaseHandler.DeletePurchase)
// Storage (receipt images) — protected by auth + path traversal check.
r.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: NextReceipt 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")
}