- 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
259 lines
8.1 KiB
Go
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")
|
|
}
|