Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior. Co-authored-by: codegirl-007 <s.raide@gmail.com>
157 lines
3.6 KiB
Go
157 lines
3.6 KiB
Go
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
|
|
}
|