diff --git a/main.go b/main.go index 3f44bd3..a8a65e9 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( "log" "net/http" "os" - "path/filepath" + "strconv" "strings" _ "github.com/lib/pq" @@ -30,7 +30,7 @@ var ( ) func main() { - for _, key := range []string{"PGCONN", "CLIENT_ID", "CLIENT_SECRET"} { + for _, key := range []string{"PGCONN", "CLIENT_ID", "CLIENT_SECRET", "ADMINS"} { if os.Getenv(key) == "" { log.Fatalf("%s environment variable is required", key) } @@ -54,8 +54,17 @@ func main() { htmlTemplates = template.Must(template.New("").ParseGlob("static/*.html")) jsTemplates = texttemplate.Must(texttemplate.New("").ParseGlob("static/*.js")) - http.HandleFunc("/", handleStatic) + http.HandleFunc("GET /{$}", serveHTML("index.html")) + http.HandleFunc("GET /admin", serveHTML("admin.html")) + http.HandleFunc("GET /app.js", serveJS("app.js")) + http.HandleFunc("GET /admin.js", serveJS("admin.js")) http.HandleFunc("POST /auth/google/callback", handleGoogleCallback) + http.HandleFunc("GET /api/admin/check", handleAdminCheck) + http.HandleFunc("GET /api/trips", handleListTrips(db)) + http.HandleFunc("POST /api/trips", handleCreateTrip(db)) + http.HandleFunc("DELETE /api/trips/{tripID}", handleDeleteTrip(db)) + http.HandleFunc("POST /api/trips/{tripID}/admins", handleAddTripAdmin(db)) + http.HandleFunc("DELETE /api/trips/{tripID}/admins/{adminID}", handleRemoveTripAdmin(db)) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { if err := db.Ping(); err != nil { http.Error(w, "db unhealthy", http.StatusServiceUnavailable) @@ -84,39 +93,22 @@ func envMap() map[string]string { return m } -func handleStatic(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache") - - path := r.URL.Path - if path == "/" { - path = "/index.html" - } - - name := strings.TrimPrefix(path, "/") - - if strings.HasSuffix(name, ".html") { - t := htmlTemplates.Lookup(name) - if t == nil { - http.NotFound(w, r) - return - } +func serveHTML(name string) http.HandlerFunc { + t := htmlTemplates.Lookup(name) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "text/html") t.Execute(w, templateData()) - return } +} - if strings.HasSuffix(name, ".js") { - t := jsTemplates.Lookup(name) - if t == nil { - http.NotFound(w, r) - return - } +func serveJS(name string) http.HandlerFunc { + t := jsTemplates.Lookup(name) + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "application/javascript") t.Execute(w, templateData()) - return } - - http.ServeFile(w, r, filepath.Join("static", name)) } func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { @@ -169,3 +161,181 @@ func authorize(r *http.Request) (string, bool) { } return email, true } + +func isAdmin(email string) bool { + for _, a := range strings.Split(os.Getenv("ADMINS"), ",") { + if strings.TrimSpace(a) == email { + return true + } + } + return false +} + +func requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + email, ok := authorize(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + if !isAdmin(email) { + http.Error(w, "forbidden", http.StatusForbidden) + return "", false + } + return email, true +} + +func handleAdminCheck(w http.ResponseWriter, r *http.Request) { + email, ok := authorize(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"admin": isAdmin(email)}) +} + +func handleListTrips(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + rows, err := db.Query(` + SELECT t.id, t.name, COALESCE( + json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL), + '[]' + ) + FROM trips t + LEFT JOIN trip_admins ta ON ta.trip_id = t.id + GROUP BY t.id, t.name + ORDER BY t.id`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + type tripAdmin struct { + ID int64 `json:"id"` + Email string `json:"email"` + } + type trip struct { + ID int64 `json:"id"` + Name string `json:"name"` + Admins []tripAdmin `json:"admins"` + } + + var trips []trip + for rows.Next() { + var t trip + var adminsJSON string + if err := rows.Scan(&t.ID, &t.Name, &adminsJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + json.Unmarshal([]byte(adminsJSON), &t.Admins) + trips = append(trips, t) + } + if trips == nil { + trips = []trip{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(trips) + } +} + +func handleCreateTrip(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + var id int64 + err := db.QueryRow("INSERT INTO trips (name) VALUES ($1) RETURNING id", body.Name).Scan(&id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id, "name": body.Name}) + } +} + +func handleDeleteTrip(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + tripID, err := strconv.ParseInt(r.PathValue("tripID"), 10, 64) + if err != nil { + http.Error(w, "invalid trip ID", http.StatusBadRequest) + return + } + result, err := db.Exec("DELETE FROM trips WHERE id = $1", tripID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := result.RowsAffected(); n == 0 { + http.Error(w, "trip not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func handleAddTripAdmin(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + tripID, err := strconv.ParseInt(r.PathValue("tripID"), 10, 64) + if err != nil { + http.Error(w, "invalid trip ID", http.StatusBadRequest) + return + } + var body struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" { + http.Error(w, "email is required", http.StatusBadRequest) + return + } + var id int64 + err = db.QueryRow("INSERT INTO trip_admins (trip_id, email) VALUES ($1, $2) RETURNING id", tripID, body.Email).Scan(&id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id, "email": body.Email}) + } +} + +func handleRemoveTripAdmin(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(w, r); !ok { + return + } + adminID, err := strconv.ParseInt(r.PathValue("adminID"), 10, 64) + if err != nil { + http.Error(w, "invalid admin ID", http.StatusBadRequest) + return + } + result, err := db.Exec("DELETE FROM trip_admins WHERE id = $1", adminID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := result.RowsAffected(); n == 0 { + http.Error(w, "trip admin not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..a507c42 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,77 @@ + + +
+ + +