Files
titlebot-ng-ng/voteServer/main.go
T

900 lines
24 KiB
Go
Executable File

package main
import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
)
type Submission struct {
Submitter string `json:"submitter"`
SubmissionTime int64 `json:"submission_time"`
Submission string `json:"submission"`
}
type SubmissionBuffer struct {
Submissions []Submission
Mtx sync.Mutex
}
type User struct {
UserName string `json:"user_name"`
DisplayName string `json:"display_name"`
LastActive int64 `json:"last_active"`
NumberOfPosts int `json:"number_of_posts"`
LastSeen int64 `json:"last_seen"`
}
type UserData struct {
Users map[string]User `json:"users"`
MostRecentPost int64 `json:"most_recent_post"`
}
type Vote struct {
VoterDisplayName string `json:"voter_display_name"`
SelectedSubmission string `json:"selected_submission"`
Submitter string `json:"submitter"`
SubmissionTime int64 `json:"submission_time"`
VoteTimestamp int64 `json:"vote_timestamp"`
}
type UserMap struct {
Users map[string]User
MostRecentPost int64
Mtx sync.RWMutex
}
type Config struct {
RoomID string `yaml:"room_id"`
VoteActivityHours int `yaml:"vote_activity_hours"`
VoteHost string `yaml:"vote_host"`
VotePath string `yaml:"vote_path"`
}
type Room struct {
Config *Config
Slug string
DB *sql.DB
SubBuffer *SubmissionBuffer
UserMap *UserMap
}
// --- Nostr auth types ---
type SignedLinkRequest struct {
DisplayName string `json:"display_name"`
Npub string `json:"npub"`
SignedEvent json.RawMessage `json:"signed_event"` // full signed Nostr event
}
type ChallengeRequest struct {
Npub string `json:"npub"` // bech32 npub or hex pubkey
}
type SignedVoteRequest struct {
Npub string `json:"npub"`
Challenge string `json:"challenge"`
SelectedSubmission string `json:"selected_submission"`
Submitter string `json:"submitter"`
SubmissionTime int64 `json:"submission_time"`
SignedEvent json.RawMessage `json:"signed_event"` // full signed Nostr event
}
func loadConfig(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
err = yaml.Unmarshal(data, &config)
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if config.VoteActivityHours == 0 {
config.VoteActivityHours = 72
}
if config.VoteHost == "" {
config.VoteHost = "localhost"
}
return &config, nil
}
func loadConfigs() ([]*Config, error) {
var configs []*Config
files, err := filepath.Glob(filepath.Join("..", "configs", "*.yaml"))
if err == nil && len(files) > 0 {
for _, f := range files {
if strings.Contains(filepath.Base(f), "example") {
continue
}
c, err := loadConfig(f)
if err != nil {
log.Printf("Error loading config %s: %v", f, err)
continue
}
configs = append(configs, c)
}
}
if len(configs) == 0 {
c, err := loadConfig(filepath.Join("..", "config.yaml"))
if err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, nil
}
func loadRoomSlugs() map[string]string {
data, err := os.ReadFile(filepath.Join("..", "room_slugs.json"))
if err != nil {
return nil
}
var slugs map[string]string
if err := json.Unmarshal(data, &slugs); err != nil {
return nil
}
return slugs
}
func slugify(s string) string {
var b strings.Builder
lastDash := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
} else if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func roomSlug(config *Config, slugs map[string]string) string {
if config.VotePath != "" {
return config.VotePath
}
if slugs != nil {
if slug, ok := slugs[config.RoomID]; ok && slug != "" {
return slug
}
}
return slugify(config.RoomID)
}
func safeDBName(roomID string, prefix string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
safe := re.ReplaceAllString(roomID, "_")
return fmt.Sprintf("%s_%s.db", prefix, safe)
}
func initDB(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
voter_display_name TEXT NOT NULL,
selected_submission TEXT NOT NULL,
submitter TEXT,
submission_time INTEGER,
vote_timestamp INTEGER NOT NULL,
UNIQUE(voter_display_name)
)
`)
if err != nil {
return nil, fmt.Errorf("failed to create votes table: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS nostr_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
npub_hex TEXT NOT NULL,
display_name TEXT NOT NULL,
linked_at INTEGER NOT NULL,
UNIQUE(npub_hex),
UNIQUE(display_name)
)
`)
if err != nil {
return nil, fmt.Errorf("failed to create nostr_links table: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS nostr_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
npub_hex TEXT NOT NULL,
challenge TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER DEFAULT 0
)
`)
if err != nil {
return nil, fmt.Errorf("failed to create nostr_challenges table: %w", err)
}
return db, nil
}
// --- Nostr helpers ---
func decodeNpub(npub string) (string, error) {
prefix, extracted, err := nip19.Decode(npub)
if err != nil {
return "", fmt.Errorf("failed to decode npub: %w", err)
}
if prefix != "npub" {
return "", fmt.Errorf("expected npub prefix, got %s", prefix)
}
hexKey, ok := extracted.(string)
if !ok {
return "", fmt.Errorf("unexpected type from npub decode")
}
return hexKey, nil
}
func parsePubkey(input string) (string, error) {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "npub1") {
return decodeNpub(input)
}
// Assume hex
cleaned := strings.TrimPrefix(input, "0x")
if len(cleaned) != 64 {
return "", fmt.Errorf("invalid pubkey: expected 64 hex characters, got %d", len(cleaned))
}
if _, err := hex.DecodeString(cleaned); err != nil {
return "", fmt.Errorf("invalid hex pubkey: %w", err)
}
return cleaned, nil
}
func verifySignedEvent(signedEventJSON json.RawMessage, expectedPubkey string) (*nostr.Event, error) {
var evt nostr.Event
if err := json.Unmarshal(signedEventJSON, &evt); err != nil {
return nil, fmt.Errorf("failed to parse signed event: %w", err)
}
if evt.PubKey != expectedPubkey {
return nil, fmt.Errorf("event pubkey %s does not match expected %s", evt.PubKey, expectedPubkey)
}
ok, err := evt.CheckSignature()
if err != nil {
return nil, fmt.Errorf("signature verification error: %w", err)
}
if !ok {
return nil, fmt.Errorf("invalid signature")
}
return &evt, nil
}
func decodeNpubEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Npub string `json:"npub"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
hexKey, err := decodeNpub(req.Npub)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"hex": hexKey})
}
func encodeNpubEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Hex string `json:"hex"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
npub, err := nip19.EncodePublicKey(req.Hex)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to encode: %v", err), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"npub": npub})
}
func cleanExpiredChallenges(db *sql.DB) {
_, err := db.Exec(`DELETE FROM nostr_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
log.Printf("Error cleaning expired challenges: %v", err)
}
}
// --- Routes ---
func main() {
configs, err := loadConfigs()
if err != nil {
log.Fatalf("Failed to load configs: %v", err)
}
slugs := loadRoomSlugs()
var rooms []*Room
usedSlugs := make(map[string]bool)
for _, config := range configs {
slug := roomSlug(config, slugs)
if usedSlugs[slug] {
log.Printf("Skipping room %s: slug /%s already in use by another config", config.RoomID, slug)
continue
}
usedSlugs[slug] = true
dbName := safeDBName(config.RoomID, "votes")
dbPath := filepath.Join("..", dbName)
db, err := initDB(dbPath)
if err != nil {
log.Printf("Database initialization failed for %s: %v", config.RoomID, err)
continue
}
defer db.Close()
room := &Room{
Config: config,
Slug: slug,
DB: db,
SubBuffer: &SubmissionBuffer{},
UserMap: &UserMap{Users: make(map[string]User)},
}
rooms = append(rooms, room)
go collectSubmissions(room)
go collectUserList(room)
go cleanExpiredChallengesLoop(room)
base := "/" + room.Slug
http.HandleFunc(base+"/", serveHTML)
http.HandleFunc(base+"/css.css", serveCSS)
http.HandleFunc(base+"/js.js", serveJS)
http.HandleFunc(base+"/submissions", serveSubmissions(room.SubBuffer))
http.HandleFunc(base+"/getVotes", getVotes(room))
http.HandleFunc(base+"/link", serveLinkHTML)
http.HandleFunc(base+"/linkNpub", linkNpub(room))
http.HandleFunc(base+"/challenge", challenge(room))
http.HandleFunc(base+"/verifyVote", verifyVote(room))
http.HandleFunc(base+"/decode_npub", decodeNpubEndpoint)
http.HandleFunc(base+"/encode_npub", encodeNpubEndpoint)
log.Printf("Vote server serving room '%s' under %s (db %s)", config.RoomID, base, dbPath)
}
if len(rooms) == 1 {
http.HandleFunc("/submissions", serveSubmissions(rooms[0].SubBuffer))
http.HandleFunc("/getVotes", getVotes(rooms[0]))
http.HandleFunc("/link", serveLinkHTML)
http.HandleFunc("/linkNpub", linkNpub(rooms[0]))
http.HandleFunc("/challenge", challenge(rooms[0]))
http.HandleFunc("/verifyVote", verifyVote(rooms[0]))
http.HandleFunc("/decode_npub", decodeNpubEndpoint)
http.HandleFunc("/encode_npub", encodeNpubEndpoint)
}
http.HandleFunc("/", indexHandler(rooms))
log.Println("Vote server starting on :9081")
log.Fatal(http.ListenAndServe(":9081", nil))
}
func cleanExpiredChallengesLoop(room *Room) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cleanExpiredChallenges(room.DB)
}
}
func indexHandler(rooms []*Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if len(rooms) == 1 {
http.Redirect(w, r, "/"+rooms[0].Slug+"/", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintln(w, "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Pick a Title!</title></head><body>")
fmt.Fprintln(w, "<h1>Pick a Title!</h1>")
fmt.Fprintln(w, "<ul>")
for _, room := range rooms {
fmt.Fprintf(w, "<li><a href='/%s/'>%s</a></li>\n", room.Slug, room.Slug)
}
fmt.Fprintln(w, "</ul></body></html>")
}
}
func serveHTML(w http.ResponseWriter, r *http.Request) {
htmlPath := filepath.Join("htm", "ndx.html")
http.ServeFile(w, r, htmlPath)
}
func serveCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
cssPath := filepath.Join("htm", "css.css")
http.ServeFile(w, r, cssPath)
}
func serveJS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
jsPath := filepath.Join("htm", "js.js")
http.ServeFile(w, r, jsPath)
}
func serveLinkHTML(w http.ResponseWriter, r *http.Request) {
htmlPath := filepath.Join("htm", "link.html")
http.ServeFile(w, r, htmlPath)
}
func serveSubmissions(subBuffer *SubmissionBuffer) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
subBuffer.Mtx.Lock()
defer subBuffer.Mtx.Unlock()
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(subBuffer)
if err != nil {
log.Printf("Error encoding submissions: %v", err)
http.Error(w, "Failed to encode submissions", http.StatusInternalServerError)
return
}
}
}
func collectSubmissions(room *Room) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
fetchSubmissions(room)
for range ticker.C {
fetchSubmissions(room)
}
}
func fetchSubmissions(room *Room) {
url := fmt.Sprintf("http://localhost:9080/%s/subs", room.Slug)
resp, err := http.Get(url)
if err != nil {
log.Printf("Error fetching submissions: %v", err)
return
}
defer resp.Body.Close()
var fetchedBuffer SubmissionBuffer
err = json.NewDecoder(resp.Body).Decode(&fetchedBuffer)
if err != nil {
log.Printf("Error decoding submissions: %v", err)
return
}
room.SubBuffer.Mtx.Lock()
room.SubBuffer.Submissions = fetchedBuffer.Submissions
room.SubBuffer.Mtx.Unlock()
log.Printf("Fetched %d submissions", len(fetchedBuffer.Submissions))
}
func collectUserList(room *Room) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
fetchUserList(room)
for range ticker.C {
fetchUserList(room)
}
}
func fetchUserList(room *Room) {
url := fmt.Sprintf("http://localhost:9080/%s/users", room.Slug)
resp, err := http.Get(url)
if err != nil {
log.Printf("Error fetching users: %v", err)
return
}
defer resp.Body.Close()
var userData UserData
err = json.NewDecoder(resp.Body).Decode(&userData)
if err != nil {
log.Printf("Error decoding users: %v", err)
return
}
room.UserMap.Mtx.Lock()
room.UserMap.Users = userData.Users
room.UserMap.MostRecentPost = userData.MostRecentPost
room.UserMap.Mtx.Unlock()
log.Printf("Fetched %d users (most recent post: %d)", len(userData.Users), userData.MostRecentPost)
}
// --- Old getVotes endpoint (kept for backward compat) ---
func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading request body: %v", err)
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var vote Vote
err = json.Unmarshal(body, &vote)
if err != nil {
log.Printf("Error parsing vote: %v", err)
http.Error(w, "Invalid vote format", http.StatusBadRequest)
return
}
if vote.VoterDisplayName == "" {
http.Error(w, "Voter display name is required", http.StatusBadRequest)
return
}
if vote.SelectedSubmission == "" {
http.Error(w, "Selected submission is required", http.StatusBadRequest)
return
}
room.UserMap.Mtx.RLock()
var userFound bool
for _, user := range room.UserMap.Users {
if user.DisplayName == vote.VoterDisplayName {
userFound = true
break
}
}
room.UserMap.Mtx.RUnlock()
if !userFound {
log.Printf("Vote rejected: User '%s' not found in database", vote.VoterDisplayName)
http.Error(w, "User not found. Please enter your Matrix display name exactly as it appears in the room.", http.StatusForbidden)
return
}
vote.VoteTimestamp = time.Now().Unix()
err = saveVote(room.DB, vote)
if err != nil {
log.Printf("Error saving vote: %v", err)
http.Error(w, "Failed to save vote", http.StatusInternalServerError)
return
}
log.Printf("Vote accepted (legacy): %s voted for '%s'", vote.VoterDisplayName, vote.SelectedSubmission)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Vote recorded successfully"))
}
}
// --- Nostr auth endpoints ---
func linkNpub(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req SignedLinkRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.DisplayName == "" {
http.Error(w, "Display name is required", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
evt, err := verifySignedEvent(req.SignedEvent, npubHex)
if err != nil {
http.Error(w, fmt.Sprintf("Signature verification failed: %v", err), http.StatusForbidden)
return
}
expectedMsg := buildLinkMessage(npubHex, req.DisplayName, room.Slug)
if evt.Content != expectedMsg {
http.Error(w, "Signed message does not match expected linking message", http.StatusForbidden)
return
}
// Check that the Matrix display name exists in the user database
room.UserMap.Mtx.RLock()
var userFound bool
for _, user := range room.UserMap.Users {
if user.DisplayName == req.DisplayName {
userFound = true
break
}
}
room.UserMap.Mtx.RUnlock()
if !userFound {
http.Error(w, "Matrix display name not found in this room's user list", http.StatusForbidden)
return
}
// Store the link
_, err = room.DB.Exec(`
INSERT OR REPLACE INTO nostr_links (npub_hex, display_name, linked_at)
VALUES (?, ?, ?)
`, npubHex, req.DisplayName, time.Now().Unix())
if err != nil {
log.Printf("Error storing npub link: %v", err)
http.Error(w, "Failed to store link", http.StatusInternalServerError)
return
}
log.Printf("Npub linked: %s -> Matrix user '%s'", npubHex[:16]+"...", req.DisplayName)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Linked successfully"))
}
}
func buildLinkMessage(npubHex, displayName, slug string) string {
return fmt.Sprintf("Link npub %s to Matrix user %s for room %s", npubHex, displayName, slug)
}
func challenge(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req ChallengeRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
// Check that this npub is linked
var displayName string
err = room.DB.QueryRow(`SELECT display_name FROM nostr_links WHERE npub_hex = ?`, npubHex).Scan(&displayName)
if err == sql.ErrNoRows {
http.Error(w, "Npub not linked. Please link your npub first.", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Generate challenge message (this is what the user will sign)
now := time.Now().Unix()
challengeMsg := fmt.Sprintf("Vote at %d in room %s", now, room.Slug)
_, err = room.DB.Exec(`
INSERT INTO nostr_challenges (npub_hex, challenge, created_at, expires_at)
VALUES (?, ?, ?, ?)
`, npubHex, challengeMsg, now, now+300) // 5 minute expiry
if err != nil {
log.Printf("Error storing challenge: %v", err)
http.Error(w, "Failed to store challenge", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"challenge": challengeMsg,
"display_name": displayName,
})
}
}
func verifyVote(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req SignedVoteRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.SelectedSubmission == "" {
http.Error(w, "Selected submission is required", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
// Verify the signed event
evt, err := verifySignedEvent(req.SignedEvent, npubHex)
if err != nil {
http.Error(w, fmt.Sprintf("Signature verification failed: %v", err), http.StatusForbidden)
return
}
// Look up the challenge and linked display name
var displayName string
err = room.DB.QueryRow(`
SELECT display_name FROM nostr_links WHERE npub_hex = ?
`, npubHex).Scan(&displayName)
if err == sql.ErrNoRows {
http.Error(w, "Npub not linked", http.StatusForbidden)
return
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Verify the challenge exists, is unused, and hasn't expired
var challengeCount int
err = room.DB.QueryRow(`
SELECT COUNT(*) FROM nostr_challenges
WHERE npub_hex = ? AND challenge = ? AND used = 0 AND expires_at > ?
`, npubHex, req.Challenge, time.Now().Unix()).Scan(&challengeCount)
if err != nil || challengeCount == 0 {
http.Error(w, "Invalid or expired challenge", http.StatusForbidden)
return
}
// Verify the signed event content is the challenge message
if evt.Content != req.Challenge {
http.Error(w, "Signed message does not match the challenge", http.StatusForbidden)
return
}
// Mark challenge as used
_, err = room.DB.Exec(`
UPDATE nostr_challenges SET used = 1
WHERE npub_hex = ? AND challenge = ? AND used = 0
`, npubHex, req.Challenge)
if err != nil {
log.Printf("Error marking challenge used: %v", err)
}
// Record the vote
vote := Vote{
VoterDisplayName: displayName,
SelectedSubmission: req.SelectedSubmission,
Submitter: req.Submitter,
SubmissionTime: req.SubmissionTime,
VoteTimestamp: time.Now().Unix(),
}
err = saveVote(room.DB, vote)
if err != nil {
log.Printf("Error saving vote: %v", err)
http.Error(w, "Failed to save vote", http.StatusInternalServerError)
return
}
log.Printf("Vote accepted: %s (npub %s...) voted for '%s'", displayName, npubHex[:16], req.SelectedSubmission)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Vote recorded successfully"))
}
}
func saveVote(db *sql.DB, vote Vote) error {
_, err := db.Exec(`
INSERT OR REPLACE INTO votes (
voter_display_name,
selected_submission,
submitter,
submission_time,
vote_timestamp
) VALUES (?, ?, ?, ?, ?)
`, vote.VoterDisplayName, vote.SelectedSubmission, vote.Submitter, vote.SubmissionTime, vote.VoteTimestamp)
if err != nil {
return fmt.Errorf("failed to save vote: %w", err)
}
return nil
}