// 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") }