Add RSVP functionality with signed token auth
This commit is contained in:
94
main.go
94
main.go
@@ -2,7 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
@@ -51,6 +54,7 @@ func init() {
|
|||||||
func main() {
|
func main() {
|
||||||
http.HandleFunc("/", handleStatic)
|
http.HandleFunc("/", handleStatic)
|
||||||
http.HandleFunc("/auth/google/callback", handleGoogleCallback)
|
http.HandleFunc("/auth/google/callback", handleGoogleCallback)
|
||||||
|
http.HandleFunc("/api/rsvp/", handleRSVP)
|
||||||
|
|
||||||
log.Println("server starting on :8080")
|
log.Println("server starting on :8080")
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
@@ -117,12 +121,100 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email := payload.Claims["email"].(string)
|
||||||
|
|
||||||
profile := map[string]any{
|
profile := map[string]any{
|
||||||
"email": payload.Claims["email"],
|
"email": email,
|
||||||
"name": payload.Claims["name"],
|
"name": payload.Claims["name"],
|
||||||
"picture": payload.Claims["picture"],
|
"picture": payload.Claims["picture"],
|
||||||
|
"token": signEmail(email),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(profile)
|
json.NewEncoder(w).Encode(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signEmail(email string) string {
|
||||||
|
h := hmac.New(sha256.New, []byte(os.Getenv("TOKEN_SECRET")))
|
||||||
|
h.Write([]byte(email))
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(email)) + "." + sig
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyToken(token string) (string, bool) {
|
||||||
|
parts := strings.SplitN(token, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
emailBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
email := string(emailBytes)
|
||||||
|
if signEmail(email) != token {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
eventID := strings.TrimPrefix(r.URL.Path, "/api/rsvp/")
|
||||||
|
if eventID == "" {
|
||||||
|
http.Error(w, "missing event id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
email, ok := verifyToken(strings.TrimPrefix(token, "Bearer "))
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
var numPeople int
|
||||||
|
var totalPeople int
|
||||||
|
err := db.QueryRow("SELECT num_people FROM rsvps WHERE event_id = $1 AND google_username = $2", eventID, email).Scan(&numPeople)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
numPeople = 0
|
||||||
|
} else if err != nil {
|
||||||
|
log.Println("[ERROR] failed to query rsvp:", err)
|
||||||
|
http.Error(w, "database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = db.QueryRow("SELECT COALESCE(SUM(num_people), 0) FROM rsvps WHERE event_id = $1", eventID).Scan(&totalPeople)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[ERROR] failed to query total:", err)
|
||||||
|
http.Error(w, "database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{"numPeople": numPeople, "totalPeople": totalPeople})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
NumPeople int `json:"numPeople"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO rsvps (event_id, google_username, num_people) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (event_id, google_username) DO UPDATE SET num_people = $3
|
||||||
|
`, eventID, email, req.NumPeople)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[ERROR] failed to upsert rsvp:", err)
|
||||||
|
http.Error(w, "database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var totalPeople int
|
||||||
|
db.QueryRow("SELECT COALESCE(SUM(num_people), 0) FROM rsvps WHERE event_id = $1", eventID).Scan(&totalPeople)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{"numPeople": req.NumPeople, "totalPeople": totalPeople})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,30 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
.header .spacer { flex: 1; }
|
.header .spacer { flex: 1; }
|
||||||
|
.event-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.event-header img {
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.event-header p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.rsvp-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--wa-color-neutral-80);
|
||||||
|
}
|
||||||
|
.rsvp-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="opacity: 0">
|
<body style="opacity: 0">
|
||||||
@@ -43,14 +67,35 @@
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
||||||
</div>
|
</div>
|
||||||
<wa-card style="text-align: center;">
|
<wa-card>
|
||||||
<img src="/afac26-logo.png" alt="Applause for a Cause" style="height: 120px; border-radius: 8px; margin-bottom: 1rem;">
|
<div class="event-header">
|
||||||
|
<img src="/afac26-logo.png" alt="Applause for a Cause">
|
||||||
<p>Feb 7, 2026 · 6:30 PM</p>
|
<p>Feb 7, 2026 · 6:30 PM</p>
|
||||||
<p>Helios Gym</p>
|
<p>Helios Gym</p>
|
||||||
|
</div>
|
||||||
|
<div class="rsvp-section">
|
||||||
|
<div id="rsvp-prompt">
|
||||||
|
<strong>RSVP now!</strong>
|
||||||
|
<div class="rsvp-controls">
|
||||||
|
<span>Number of people:</span>
|
||||||
|
<wa-input type="number" id="num-people" min="1" value="1" style="width: 80px;"></wa-input>
|
||||||
|
<wa-button variant="brand" id="rsvp-btn">RSVP</wa-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rsvp-status" style="display: none;">
|
||||||
|
<strong id="rsvp-message"></strong>
|
||||||
|
<div class="rsvp-controls">
|
||||||
|
<span>Change to:</span>
|
||||||
|
<wa-input type="number" id="num-people-update" min="0" value="1" style="width: 80px;"></wa-input>
|
||||||
|
<wa-button variant="neutral" id="update-btn">Update</wa-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</wa-card>
|
</wa-card>
|
||||||
|
<wa-button variant="text" size="small" href="/" style="margin-top: 1rem;">« Events</wa-button>
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { auth, logout } from '/app.js';
|
import { auth, logout, api } from '/app.js';
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||||
document.documentElement.className = e.matches ? 'wa-dark' : 'wa-light';
|
document.documentElement.className = e.matches ? 'wa-dark' : 'wa-light';
|
||||||
@@ -60,6 +105,40 @@
|
|||||||
document.getElementById('main').style.display = 'block';
|
document.getElementById('main').style.display = 'block';
|
||||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||||
|
|
||||||
|
const eventId = 'afac26';
|
||||||
|
|
||||||
|
async function loadRSVP() {
|
||||||
|
const data = await api('GET', `/api/rsvp/${eventId}`);
|
||||||
|
updateUI(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(data) {
|
||||||
|
if (data.numPeople > 0) {
|
||||||
|
document.getElementById('rsvp-prompt').style.display = 'none';
|
||||||
|
document.getElementById('rsvp-status').style.display = 'block';
|
||||||
|
const word = data.numPeople === 1 ? 'person' : 'people';
|
||||||
|
document.getElementById('rsvp-message').textContent = `You're RSVPed for ${data.numPeople} ${word}.`;
|
||||||
|
document.getElementById('num-people-update').value = data.numPeople;
|
||||||
|
} else {
|
||||||
|
document.getElementById('rsvp-prompt').style.display = 'block';
|
||||||
|
document.getElementById('rsvp-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('rsvp-btn').addEventListener('click', async () => {
|
||||||
|
const numPeople = parseInt(document.getElementById('num-people').value) || 1;
|
||||||
|
const data = await api('POST', `/api/rsvp/${eventId}`, { numPeople });
|
||||||
|
updateUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('update-btn').addEventListener('click', async () => {
|
||||||
|
const numPeople = parseInt(document.getElementById('num-people-update').value) || 0;
|
||||||
|
const data = await api('POST', `/api/rsvp/${eventId}`, { numPeople });
|
||||||
|
updateUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadRSVP();
|
||||||
|
|
||||||
await customElements.whenDefined('wa-card');
|
await customElements.whenDefined('wa-card');
|
||||||
document.body.style.opacity = 1;
|
document.body.style.opacity = 1;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,25 @@ export function logout() {
|
|||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function api(method, path, body) {
|
||||||
|
const profile = getProfile();
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + (profile?.token || ''),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
function bind(data) {
|
function bind(data) {
|
||||||
document.querySelectorAll('[data-bind]').forEach(el => {
|
document.querySelectorAll('[data-bind]').forEach(el => {
|
||||||
const key = el.dataset.bind;
|
const key = el.dataset.bind;
|
||||||
|
|||||||
Reference in New Issue
Block a user