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, "")
for _, room := range rooms {
fmt.Fprintf(w, "- %s
\n", room.Slug, room.Slug)
}
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
}