package main import ( "crypto/rand" "database/sql" "embed" "flag" "fmt" "io/fs" "log" "net/http" "os" ) var buildID = "dev" //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/participants", auth(app.handleListParticipants, "admin", "ticketing", "gate")) mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gate")) mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gate")) mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gate")) mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/{id}/email-code", 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/settings/reset-attendees", auth(app.handleResetAttendees, "admin")) mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin")) mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "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)) mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"build": buildID}) }) mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "volunteer_lead")) // Public endpoints — no JWT required. mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup) mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) // 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) }