2026-03-03 11:27:07 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"embed"
|
|
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 13:38:31 -06:00
|
|
|
var buildID = "dev"
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
//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))
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing"))
|
2026-03-03 11:27:07 -06:00
|
|
|
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper"))
|
2026-03-04 10:53:42 -06:00
|
|
|
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing"))
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper"))
|
2026-03-04 10:53:42 -06:00
|
|
|
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"))
|
|
|
|
|
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper"))
|
2026-03-04 14:19:51 -06:00
|
|
|
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing"))
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
|
2026-03-04 10:53:42 -06:00
|
|
|
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"))
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing"))
|
|
|
|
|
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing"))
|
|
|
|
|
|
|
|
|
|
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
|
|
|
|
|
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead"))
|
|
|
|
|
|
|
|
|
|
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing"))
|
|
|
|
|
|
|
|
|
|
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing"))
|
|
|
|
|
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing"))
|
2026-03-03 11:27:07 -06:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-03-03 13:38:31 -06:00
|
|
|
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, map[string]string{"build": buildID})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 12:00:36 -06:00
|
|
|
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing"))
|
2026-03-03 17:59:35 -06:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
// 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)
|
|
|
|
|
}
|