471cc3ad8c
Replace email-based auth (Resend) with phone-based auth (Twilio SMS). Add BBQ_FEATURES env var for toggling features at deploy time — when auth is disabled, no login routes are registered and the app works as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
4.7 KiB
Go
203 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/ryanchen/bbq/db"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
//go:embed schema.sql
|
|
var schemaSQL string
|
|
|
|
var pageTmpl map[string]*template.Template
|
|
|
|
type Server struct {
|
|
q *db.Queries
|
|
db *sql.DB
|
|
baseURL string
|
|
features Features
|
|
|
|
// SSE: map of event slug -> set of channels
|
|
mu sync.Mutex
|
|
clients map[string]map[chan struct{}]struct{}
|
|
}
|
|
|
|
func NewServer(database *sql.DB, baseURL string, features Features) *Server {
|
|
return &Server{
|
|
q: db.New(database),
|
|
db: database,
|
|
baseURL: baseURL,
|
|
features: features,
|
|
clients: make(map[string]map[chan struct{}]struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *Server) subscribe(slug string) chan struct{} {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ch := make(chan struct{}, 1)
|
|
if s.clients[slug] == nil {
|
|
s.clients[slug] = make(map[chan struct{}]struct{})
|
|
}
|
|
s.clients[slug][ch] = struct{}{}
|
|
return ch
|
|
}
|
|
|
|
func (s *Server) unsubscribe(slug string, ch chan struct{}) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if m, ok := s.clients[slug]; ok {
|
|
delete(m, ch)
|
|
if len(m) == 0 {
|
|
delete(s.clients, slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) notify(slug string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for ch := range s.clients[slug] {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
dbPath := "bbq.db"
|
|
if v := os.Getenv("BBQ_DB"); v != "" {
|
|
dbPath = v
|
|
}
|
|
port := "8080"
|
|
if v := os.Getenv("PORT"); v != "" {
|
|
port = v
|
|
}
|
|
baseURL := os.Getenv("BBQ_BASE_URL") // e.g. https://bbq.torrtle.co
|
|
features := parseFeatures(os.Getenv("BBQ_FEATURES"))
|
|
|
|
database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer database.Close()
|
|
database.SetMaxOpenConns(1)
|
|
|
|
if _, err := database.Exec(schemaSQL); err != nil {
|
|
log.Fatal("schema init: ", err)
|
|
}
|
|
runMigrations(database)
|
|
|
|
funcMap := template.FuncMap{
|
|
"pct": func(count, max int64) int64 {
|
|
if max == 0 {
|
|
return 0
|
|
}
|
|
return (count * 100) / max
|
|
},
|
|
"sub": func(a, b int64) int64 {
|
|
return a - b
|
|
},
|
|
}
|
|
|
|
// Parse each page with layout + shared partials
|
|
pageTmpl = make(map[string]*template.Template)
|
|
shared := []string{"templates/layout.html", "templates/slots.html"}
|
|
for _, page := range []string{"home", "event", "login", "dashboard", "name"} {
|
|
files := append([]string{"templates/" + page + ".html"}, shared...)
|
|
pageTmpl[page] = template.Must(
|
|
template.New("").Funcs(funcMap).ParseFS(templateFS, files...),
|
|
)
|
|
}
|
|
// slots-only partial (for HTMX responses)
|
|
pageTmpl["slots"] = template.Must(
|
|
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
|
|
)
|
|
|
|
srv := NewServer(database, baseURL, features)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(srv.sessionMiddleware)
|
|
|
|
// Static files
|
|
r.Handle("/static/*", http.FileServer(http.FS(staticFS)))
|
|
|
|
// Auth (conditional on feature flag)
|
|
if features.Auth {
|
|
r.Get("/login", srv.handleLoginPage)
|
|
r.Post("/login", srv.handleLoginSubmit)
|
|
r.Post("/login/verify", srv.handleVerifyCode)
|
|
r.Post("/logout", srv.handleLogout)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(srv.requireAuth)
|
|
r.Get("/dashboard", srv.handleDashboard)
|
|
r.Get("/account/name", srv.handleNamePage)
|
|
r.Post("/account/name", srv.handleNameSubmit)
|
|
})
|
|
}
|
|
|
|
// Home / create event
|
|
r.Get("/", srv.handleHome)
|
|
r.Post("/events", srv.handleCreateEvent)
|
|
|
|
// Guest event view
|
|
r.Get("/e/{slug}", srv.handleEvent)
|
|
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
|
|
r.Post("/e/{slug}/claim", srv.handleClaim)
|
|
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
|
|
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
|
|
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
|
|
|
// OG image
|
|
r.Get("/e/{slug}/og.png", srv.handleOGImage)
|
|
|
|
// SSE
|
|
r.Get("/e/{slug}/sse", srv.handleSSE)
|
|
|
|
// Admin
|
|
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
|
|
r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription)
|
|
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
|
|
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
|
|
|
|
server := &http.Server{Addr: ":" + port, Handler: r}
|
|
|
|
go func() {
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sig
|
|
log.Println("shutting down...")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
server.Shutdown(ctx)
|
|
}()
|
|
|
|
fmt.Printf("Listening on http://localhost:%s\n", port)
|
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Fatal(err)
|
|
}
|
|
}
|