Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list maintainer.
This commit is contained in:
commit
d05b8dc7e0
59 changed files with 8663 additions and 0 deletions
214
main.go
Normal file
214
main.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue