package main import ( "context" "database/sql" "errors" "log" "net" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/joho/godotenv" "plumber" "plumber/internal/blob" "plumber/internal/mail" "plumber/internal/store" "plumber/internal/web" ) func main() { _ = godotenv.Load() db, sessions := openDB() defer db.Close() defer sessions.Close() uploader := blob.FromEnv() notifier, err := mail.FromEnv() if err != nil { log.Fatalf("mail: %v", err) } handler := newHandler(db, sessions, uploader, notifier) run(&http.Server{ Addr: listenAddr(), Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 90 * time.Second, }) } func openDB() (*sql.DB, *store.SessionStore) { databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) if databaseURL == "" { log.Fatal("DATABASE_URL is required") } db, sessions, err := store.OpenPostgres(databaseURL, plumber.SchemaSQL) if err != nil { log.Fatalf("database: %v", err) } log.Printf("database: postgres") return db, sessions } func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler { srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), SecureCookie: secureCookieFromEnv(), TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")), Blob: uploader, Mail: notifier, }) if err != nil { log.Fatalf("server: %v", err) } return srv.Handler() } func parseTrustedProxies(raw string) []*net.IPNet { var out []*net.IPNet for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } _, n, err := net.ParseCIDR(part) if err != nil { log.Fatalf("TRUSTED_PROXY_CIDRS: bad CIDR %q: %v", part, err) } out = append(out, n) } return out } // secureCookieFromEnv defaults to secure when PORT is set (PaaS/production) // and refuses an explicit disable in that environment. func secureCookieFromEnv() bool { v := strings.TrimSpace(os.Getenv("SECURE_COOKIE")) if strings.TrimSpace(os.Getenv("PORT")) != "" { if v == "0" { log.Fatal("SECURE_COOKIE=0 is not allowed when PORT is set") } return true } return v == "1" } func run(httpSrv *http.Server) { errCh := make(chan error, 1) go func() { log.Printf("listening on %s", httpSrv.Addr) errCh <- httpSrv.ListenAndServe() }() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) select { case err := <-errCh: if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } case sig := <-sigCh: log.Printf("shutdown signal: %v", sig) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) err := httpSrv.Shutdown(ctx) cancel() if err != nil { log.Printf("shutdown: %v", err) _ = httpSrv.Close() } select { case err := <-errCh: if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("server exit: %v", err) } case <-time.After(3 * time.Second): log.Printf("server exit: timed out waiting for ListenAndServe") } } } // listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080. func listenAddr() string { if p := strings.TrimSpace(os.Getenv("PORT")); p != "" { if strings.HasPrefix(p, ":") { return p } return ":" + p } return env("LISTEN", ":8080") } func env(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v }