Turnpike/main.go

215 lines
8.4 KiB
Go
Raw Normal View History

package main
import (
"crypto/rand"
"database/sql"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
)
//go:embed frontend/dist
var frontendFS embed.FS
type App struct {
db *sql.DB
secret string
tokenExpiry int
broker *Broker
// SMTP settings — populated from CLI flags; config table fills any gaps.
smtpHost string
smtpPort int
smtpUser string
smtpPassword string
smtpFrom string
smtpFromName string
baseURL string
}
func main() {
addr := flag.String("addr", "0.0.0.0:8180", "listen address")
dbPath := flag.String("db", "turnpike.db", "SQLite database path")
secret := flag.String("secret", "", "JWT signing secret (or set TURNPIKE_SECRET)")
tokenExpiry := flag.Int("token-expiry", 24, "JWT expiry in hours")
smtpHost := flag.String("smtp-host", "", "SMTP server hostname")
smtpPort := flag.Int("smtp-port", 0, "SMTP server port (0 = use stored value or 587)")
smtpUser := flag.String("smtp-user", "", "SMTP username")
smtpPass := flag.String("smtp-password", "", "SMTP password")
smtpFrom := flag.String("smtp-from", "", "SMTP from address")
smtpName := flag.String("smtp-from-name", "", "SMTP from display name")
baseURL := flag.String("base-url", "", "Public base URL for volunteer token links (e.g. https://example.com)")
flag.Parse()
if *secret == "" {
*secret = os.Getenv("TURNPIKE_SECRET")
}
db, err := initDB(*dbPath)
if err != nil {
log.Fatalf("database init: %v", err)
}
defer db.Close()
app := &App{
db: db,
tokenExpiry: *tokenExpiry,
broker: newBroker(),
smtpHost: *smtpHost,
smtpPort: *smtpPort,
smtpUser: *smtpUser,
smtpPassword: *smtpPass,
smtpFrom: *smtpFrom,
smtpFromName: *smtpName,
baseURL: *baseURL,
}
if *secret == "" {
*secret, err = app.getOrCreateSecret()
if err != nil {
log.Fatalf("secret: %v", err)
}
}
app.secret = *secret
if err := app.bootstrapAdmin(); err != nil {
log.Fatalf("bootstrap: %v", err)
}
mux := http.NewServeMux()
app.registerRoutes(mux)
log.Printf("Turnpike listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, mux))
}
func (app *App) registerRoutes(mux *http.ServeMux) {
auth := app.requireAuth
mux.HandleFunc("POST /api/login", app.handleLogin)
mux.HandleFunc("POST /api/logout", auth(app.handleLogout))
mux.HandleFunc("GET /api/me", auth(app.handleMe))
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate"))
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin"))
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin"))
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull))
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
// Kiosk — authenticated by volunteer token, no JWT required.
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
mux.HandleFunc("DELETE /api/v/{token}/shifts/{id}", app.handleKioskUnclaim)
// Serve embedded frontend, falling through to index.html for SPA routing.
distFS, err := fs.Sub(frontendFS, "frontend/dist")
if err != nil {
log.Fatalf("embed: %v", err)
}
fileServer := http.FileServer(http.FS(distFS))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/" {
// Strip leading slash and check if file exists
if _, err := fs.Stat(distFS, path[1:]); err == nil {
fileServer.ServeHTTP(w, r)
return
}
}
// All other paths: serve index.html (SPA client-side routing)
r2 := *r
r2.URL.Path = "/"
fileServer.ServeHTTP(w, &r2)
})
}
func (app *App) bootstrapAdmin() error {
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
if adminUser == "" || adminPass == "" {
return nil
}
n, err := app.countUsers()
if err != nil || n > 0 {
return err
}
hash, err := hashPassword(adminPass)
if err != nil {
return err
}
_, err = app.createUser(adminUser, hash, "admin", []int{})
if err != nil {
return err
}
log.Printf("Created admin user: %s", adminUser)
return nil
}
func (app *App) getOrCreateSecret() (string, error) {
app.db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
var s string
err := app.db.QueryRow(`SELECT value FROM config WHERE key = 'jwt_secret'`).Scan(&s)
if err == nil && s != "" {
return s, nil
}
secret := generateSecret()
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('jwt_secret', ?)`, secret)
log.Printf("Generated new JWT secret and stored in database")
return secret, nil
}
func generateSecret() string {
b := make([]byte, 32)
rand.Read(b)
return fmt.Sprintf("%x", b)
}