package main import ( "crypto/rand" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "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" ) var httpClient = &http.Client{Timeout: 10 * time.Second} const maxRequestBodySize = 1 << 20 // 1MB func validateHomeserverURL(raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("invalid URL") } if u.Scheme != "https" && u.Scheme != "http" { return fmt.Errorf("URL must use https or http scheme") } if u.Host == "" { return fmt.Errorf("URL must have a host") } host := u.Hostname() if host == "localhost" || host == "127.0.0.1" || host == "::1" { return nil } if net.ParseIP(host) != nil { ip := net.ParseIP(host) if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return fmt.Errorf("requests to private/internal addresses are not allowed") } } return nil } 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"` Homeserver string `yaml:"homeserver"` } 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"` } type ChallengeRequest struct { Npub string `json:"npub"` } 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"` } // --- Matrix auth types --- type MatrixAuthRequest struct { AccessToken string `json:"access_token"` Homeserver string `json:"homeserver"` } type MatrixVoteRequest struct { SessionToken string `json:"session_token"` SelectedSubmission string `json:"selected_submission"` Submitter string `json:"submitter"` SubmissionTime int64 `json:"submission_time"` } 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" } if config.Homeserver == "" { config.Homeserver = "https://matrix.org" } 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) } _, err = db.Exec(` CREATE TABLE IF NOT EXISTS matrix_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, display_name TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ) `) if err != nil { return nil, fmt.Errorf("failed to create matrix_sessions 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) } 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) } } func cleanExpiredSessions(db *sql.DB) { _, err := db.Exec(`DELETE FROM matrix_sessions WHERE expires_at < ?`, time.Now().Unix()) if err != nil { log.Printf("Error cleaning expired matrix sessions: %v", err) } } func generateSessionToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // --- 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) http.HandleFunc(base+"/matrix-login", serveMatrixLoginHTML) http.HandleFunc(base+"/matrixAuth", matrixAuth(room)) http.HandleFunc(base+"/matrixVerify", matrixVerify(room)) http.HandleFunc(base+"/matrixVote", matrixVote(room)) http.HandleFunc(base+"/roomConfig", roomConfigHandler(room)) 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("/matrix-login", serveMatrixLoginHTML) http.HandleFunc("/matrixAuth", matrixAuth(rooms[0])) http.HandleFunc("/matrixVerify", matrixVerify(rooms[0])) http.HandleFunc("/matrixVote", matrixVote(rooms[0])) http.HandleFunc("/roomConfig", roomConfigHandler(rooms[0])) } 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) cleanExpiredSessions(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, "Pick a Title!") fmt.Fprintln(w, "

Pick a Title!

") fmt.Fprintln(w, "") } } 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 serveMatrixLoginHTML(w http.ResponseWriter, r *http.Request) { htmlPath := filepath.Join("htm", "matrix-login.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 var lastActive int64 mostRecentPost := room.UserMap.MostRecentPost for _, user := range room.UserMap.Users { if user.DisplayName == vote.VoterDisplayName { userFound = true lastActive = user.LastActive 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 } config := room.Config activityWindowSeconds := int64(config.VoteActivityHours * 60 * 60) activityThreshold := mostRecentPost - activityWindowSeconds if lastActive < activityThreshold { log.Printf("Vote rejected: User '%s' last active at %d, most recent post %d, threshold %d", vote.VoterDisplayName, lastActive, mostRecentPost, activityThreshold) lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05") mostRecentTime := time.Unix(mostRecentPost, 0).Format("2006-01-02 15:04:05") http.Error(w, fmt.Sprintf("You must have been active within %d hours of the most recent room activity to vote. Your last activity was at %s. Most recent room activity: %s.", config.VoteActivityHours, lastActiveTime, mostRecentTime), 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 } lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05") log.Printf("Vote accepted (legacy): %s (last active %s) voted for '%s'", vote.VoterDisplayName, lastActiveTime, 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 } 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 } _, 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 } 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 } 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) 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 } evt, err := verifySignedEvent(req.SignedEvent, npubHex) if err != nil { http.Error(w, fmt.Sprintf("Signature verification failed: %v", err), http.StatusForbidden) return } 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 } 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 } if evt.Content != req.Challenge { http.Error(w, "Signed message does not match the challenge", http.StatusForbidden) return } _, 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) } 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")) } } // --- Matrix auth endpoints --- func matrixAuth(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(io.LimitReader(r.Body, maxRequestBodySize)) if err != nil { http.Error(w, "Failed to read request", http.StatusBadRequest) return } defer r.Body.Close() var req MatrixAuthRequest if err := json.Unmarshal(body, &req); err != nil { http.Error(w, "Invalid request format", http.StatusBadRequest) return } if req.AccessToken == "" || req.Homeserver == "" { http.Error(w, "access_token and homeserver are required", http.StatusBadRequest) return } if err := validateHomeserverURL(req.Homeserver); err != nil { http.Error(w, "Invalid homeserver URL: "+err.Error(), http.StatusBadRequest) return } hsURL := strings.TrimRight(req.Homeserver, "/") whoamiReq, err := http.NewRequest("GET", hsURL+"/_matrix/client/v3/account/whoami", nil) if err != nil { http.Error(w, "Failed to build whoami request", http.StatusInternalServerError) return } whoamiReq.Header.Set("Authorization", "Bearer "+req.AccessToken) whoamiResp, err := httpClient.Do(whoamiReq) if err != nil { http.Error(w, "Failed to reach homeserver", http.StatusBadGateway) return } defer whoamiResp.Body.Close() if whoamiResp.StatusCode != http.StatusOK { http.Error(w, "Invalid access token", http.StatusUnauthorized) return } var whoamiData struct { UserID string `json:"user_id"` } if err := json.NewDecoder(io.LimitReader(whoamiResp.Body, maxRequestBodySize)).Decode(&whoamiData); err != nil { http.Error(w, "Failed to parse whoami response", http.StatusInternalServerError) return } if whoamiData.UserID == "" { http.Error(w, "Whoami returned empty user_id", http.StatusInternalServerError) return } room.UserMap.Mtx.RLock() var displayName string var userFound bool for _, user := range room.UserMap.Users { if user.UserName == whoamiData.UserID { displayName = user.DisplayName userFound = true break } } room.UserMap.Mtx.RUnlock() if !userFound { log.Printf("Matrix auth rejected: user_id '%s' not found in room", whoamiData.UserID) http.Error(w, "User not found in this room", http.StatusForbidden) return } token, err := generateSessionToken() if err != nil { log.Printf("Error generating session token: %v", err) http.Error(w, "Failed to create session", http.StatusInternalServerError) return } now := time.Now().Unix() _, err = room.DB.Exec(` INSERT INTO matrix_sessions (token, user_id, display_name, created_at, expires_at) VALUES (?, ?, ?, ?, ?) `, token, whoamiData.UserID, displayName, now, now+86400) if err != nil { log.Printf("Error storing matrix session: %v", err) http.Error(w, "Failed to create session", http.StatusInternalServerError) return } log.Printf("Matrix auth: %s (%s) authenticated", whoamiData.UserID, displayName) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "session_token": token, "display_name": displayName, }) } } func matrixVerify(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(io.LimitReader(r.Body, maxRequestBodySize)) if err != nil { http.Error(w, "Failed to read request", http.StatusBadRequest) return } defer r.Body.Close() var req struct { SessionToken string `json:"session_token"` } if err := json.Unmarshal(body, &req); err != nil { http.Error(w, "Invalid request format", http.StatusBadRequest) return } if req.SessionToken == "" { http.Error(w, "session_token is required", http.StatusBadRequest) return } var displayName string err = room.DB.QueryRow(` SELECT display_name FROM matrix_sessions WHERE token = ? AND expires_at > ? `, req.SessionToken, time.Now().Unix()).Scan(&displayName) if err == sql.ErrNoRows { http.Error(w, "Invalid or expired session", http.StatusUnauthorized) return } if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "display_name": displayName, }) } } func matrixVote(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(io.LimitReader(r.Body, maxRequestBodySize)) if err != nil { http.Error(w, "Failed to read request", http.StatusBadRequest) return } defer r.Body.Close() var req MatrixVoteRequest if err := json.Unmarshal(body, &req); err != nil { http.Error(w, "Invalid request format", http.StatusBadRequest) return } if req.SessionToken == "" { http.Error(w, "session_token is required", http.StatusBadRequest) return } if req.SelectedSubmission == "" { http.Error(w, "Selected submission is required", http.StatusBadRequest) return } var displayName string err = room.DB.QueryRow(` SELECT display_name FROM matrix_sessions WHERE token = ? AND expires_at > ? `, req.SessionToken, time.Now().Unix()).Scan(&displayName) if err == sql.ErrNoRows { http.Error(w, "Invalid or expired session", http.StatusUnauthorized) return } if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } 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 matrix vote: %v", err) http.Error(w, "Failed to save vote", http.StatusInternalServerError) return } log.Printf("Matrix vote accepted: %s voted for '%s'", displayName, req.SelectedSubmission) w.WriteHeader(http.StatusOK) w.Write([]byte("Vote recorded successfully")) } } func roomConfigHandler(room *Room) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "homeserver": room.Config.Homeserver, }) } } 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 }