package main import ( "log" "net/http" "os" "strings" "time" root "marmic/servicetrade-toolbox" "marmic/servicetrade-toolbox/internal/handlers/web" "marmic/servicetrade-toolbox/internal/middleware" "github.com/gorilla/csrf" "github.com/gorilla/handlers" "github.com/gorilla/mux" ) func main() { err := root.InitializeWebTemplates() // Note the change here if err != nil { log.Fatalf("Failed to initialize web templates: %v", err) } r := mux.NewRouter() // Make app proxy-aware (X-Forwarded-Proto/Host) so downstream middlewares see correct scheme/host r.Use(handlers.ProxyHeaders) // Global security headers middleware r.Use(middleware.SecurityHeaders) // Serve embedded static files staticFS, err := root.GetStaticFS() if err != nil { log.Fatalf("Failed to get static filesystem: %v", err) } r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Serve static files // r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // Auth routes (login remains outside CSRF) r.HandleFunc("/login", web.LoginHandler).Methods("GET", "POST") // CSRF protection for state-changing routes csrfKey := os.Getenv("CSRF_AUTH_KEY") if len(csrfKey) < 32 { log.Println("WARNING: CSRF_AUTH_KEY is not set or too short; using insecure default for development only") csrfKey = "this-is-a-very-insecure-dev-key-please-set-env" } secureCookies := os.Getenv("APP_SECURE_COOKIES") != "false" disableCSRF := os.Getenv("CSRF_DISABLE") == "true" // Trusted origins for reverse-proxy/HTTPS setups (comma-separated env) var trustedOrigins []string if envTO := os.Getenv("CSRF_TRUSTED_ORIGINS"); envTO != "" { for _, part := range strings.Split(envTO, ",") { o := strings.TrimSpace(part) if o != "" { trustedOrigins = append(trustedOrigins, o) } } } else { // Sensible defaults for your known hosts; override via CSRF_TRUSTED_ORIGINS in prod trustedOrigins = []string{ "https://dev.toolbox.nicpatterson.info", "https://toolbox.nicpatterson.info", "http://localhost:8080", "http://127.0.0.1:8080", } } csrfOpts := []csrf.Option{ csrf.Secure(secureCookies), csrf.SameSite(csrf.SameSiteLaxMode), csrf.CookieName("XSRF-TOKEN"), csrf.HttpOnly(false), // allow htmx script to read and send the token csrf.Path("/"), csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Log the specific CSRF failure reason to help debugging origin := r.Header.Get("Origin") referer := r.Header.Get("Referer") scheme := "http" if r.TLS != nil { scheme = "https" } if xfProto := r.Header.Get("X-Forwarded-Proto"); xfProto != "" { scheme = xfProto } if reason := csrf.FailureReason(r); reason != nil { log.Printf("CSRF validation failed: %v | host=%s scheme=%s origin=%s referer=%s", reason, r.Host, scheme, origin, referer) } else { log.Printf("CSRF validation failed: unknown reason | host=%s scheme=%s origin=%s referer=%s", r.Host, scheme, origin, referer) } http.Error(w, "Forbidden - CSRF", http.StatusForbidden) })), } if len(trustedOrigins) > 0 { log.Printf("CSRF trusted origins: %v", trustedOrigins) csrfOpts = append(csrfOpts, csrf.TrustedOrigins(trustedOrigins)) } csrfMw := csrf.Protect([]byte(csrfKey), csrfOpts...) // Protected routes protected := r.PathPrefix("/").Subrouter() protected.Use(middleware.AuthMiddleware) if disableCSRF { log.Println("WARNING: CSRF is DISABLED by environment (CSRF_DISABLE=true) - DO NOT USE IN PRODUCTION") } else { // Environment-guarded CSRF mode selection appEnv := os.Getenv("APP_ENV") // expected: production, staging, dev, local useSimple := os.Getenv("CSRF_MODE") == "simple" if appEnv == "production" && useSimple { log.Fatal("CSRF_MODE=simple is not allowed in production") } if useSimple && appEnv != "production" { log.Println("INFO: Using simple CSRF mode (double-submit cookie) for non-production environment") protected.Use(middleware.CSRFSimple) } else { protected.Use(csrfMw) protected.Use(middleware.CSRFExposeToken) } } protected.HandleFunc("/", web.DashboardHandler).Methods("GET") protected.HandleFunc("/logout", web.LogoutHandler).Methods("POST") protected.HandleFunc("/jobs", web.JobsHandler).Methods("GET", "POST") protected.HandleFunc("/invoices", web.InvoicesHandler).Methods("GET", "POST") protected.HandleFunc("/ok-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/draft-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/failed-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/pending_accounting-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/processed-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/void-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT") protected.HandleFunc("/admin", web.AdminHandler).Methods("GET") protected.HandleFunc("/assets", web.AssetsHandler).Methods("GET") protected.HandleFunc("/companies", web.CompaniesHandler).Methods("GET") protected.HandleFunc("/contacts", web.ContactsHandler).Methods("GET") protected.HandleFunc("/contracts", web.ContractsHandler).Methods("GET") protected.HandleFunc("/generic", web.GenericHandler).Methods("GET") protected.HandleFunc("/locations", web.LocationsHandler).Methods("GET") protected.HandleFunc("/notifications", web.NotificationsHandler).Methods("GET") protected.HandleFunc("/quotes", web.QuotesHandler).Methods("GET") protected.HandleFunc("/services", web.ServicesHandler).Methods("GET") protected.HandleFunc("/tags", web.TagsHandler).Methods("GET") protected.HandleFunc("/users", web.UsersHandler).Methods("GET") // Document upload routes protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET") protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST") protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST") protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET") protected.HandleFunc("/documents/upload/job/file", web.UploadJobFileHandler).Methods("GET") // Document removal routes protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET") protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST") protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST") protected.HandleFunc("/documents/remove/job/file", web.RemovalJobFileHandler).Methods("GET") protected.HandleFunc("/documents/remove/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET") protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST") protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST") protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET") // Reports & utilities protected.HandleFunc("/reports/invoice-clock", web.InvoiceClockReportHandler).Methods("POST") port := os.Getenv("PORT") if port == "" { port = "8080" } log.Println("Server starting on :" + port) // Create a custom server with appropriate timeouts server := &http.Server{ Addr: ":" + port, Handler: r, ReadTimeout: 2 * time.Hour, // Large timeout for big file uploads WriteTimeout: 2 * time.Hour, // Large timeout for big file responses IdleTimeout: 120 * time.Second, // How long to wait for the next request } log.Fatal(server.ListenAndServe()) }