Replace username-based admin bootstrap with a one-time setup secret, rate-limit login/register, equalize login bcrypt timing, cap passwords at 72 bytes, destroy sessions on logout, and require Secure cookies when PORT is set.
127 lines
2.9 KiB
Go
127 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
"plumber"
|
|
"plumber/internal/blob"
|
|
"plumber/internal/store"
|
|
"plumber/internal/web"
|
|
)
|
|
|
|
func main() {
|
|
_ = godotenv.Load()
|
|
|
|
db, sessions := openDB()
|
|
defer db.Close()
|
|
defer sessions.Close()
|
|
|
|
uploader := blob.FromEnv()
|
|
handler := newHandler(db, sessions, uploader)
|
|
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) 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(),
|
|
TrustProxy: os.Getenv("TRUST_PROXY") == "1",
|
|
Blob: uploader,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
return srv.Handler()
|
|
}
|
|
|
|
// 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)
|
|
defer cancel()
|
|
if err := httpSrv.Shutdown(ctx); err != nil {
|
|
log.Printf("shutdown: %v", err)
|
|
}
|
|
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|