2026-08-06 21:34:52 -05:00
package main
import (
2026-09-04 11:56:39 -05:00
"crypto/rand"
2026-08-06 21:34:52 -05:00
"database/sql"
2026-09-04 11:56:39 -05:00
"encoding/base64"
"encoding/hex"
2026-08-06 21:34:52 -05:00
"encoding/json"
"fmt"
"io"
"log"
2026-09-04 11:56:39 -05:00
"net"
2026-08-06 21:34:52 -05:00
"net/http"
2026-09-04 11:56:39 -05:00
"net/url"
2026-08-06 21:34:52 -05:00
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
2026-09-04 11:56:39 -05:00
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
2026-08-06 21:34:52 -05:00
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
)
2026-09-04 11:56:39 -05:00
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
}
2026-08-06 21:34:52 -05:00
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"`
2026-09-04 11:56:39 -05:00
Homeserver string `yaml:"homeserver"`
2026-08-06 21:34:52 -05:00
}
type Room struct {
2026-09-04 11:56:39 -05:00
Config * Config
Slug string
DB * sql . DB
2026-08-06 21:34:52 -05:00
SubBuffer * SubmissionBuffer
2026-09-04 11:56:39 -05:00
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"`
2026-08-06 21:34:52 -05:00
}
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"
}
2026-09-04 11:56:39 -05:00
if config . Homeserver == "" {
config . Homeserver = "https://matrix.org"
}
2026-08-06 21:34:52 -05:00
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 )
}
2026-09-04 11:56:39 -05:00
_ , 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 )
}
2026-08-06 21:34:52 -05:00
return db , nil
}
2026-09-04 11:56:39 -05:00
// --- 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 ---
2026-08-06 21:34:52 -05:00
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 )
2026-09-04 11:56:39 -05:00
go cleanExpiredChallengesLoop ( room )
2026-08-06 21:34:52 -05:00
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 ))
2026-09-04 11:56:39 -05:00
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 ))
2026-08-06 21:34:52 -05:00
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 ]))
2026-09-04 11:56:39 -05:00
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 ]))
2026-08-06 21:34:52 -05:00
}
http . HandleFunc ( "/" , indexHandler ( rooms ))
log . Println ( "Vote server starting on :9081" )
log . Fatal ( http . ListenAndServe ( ":9081" , nil ))
}
2026-09-04 11:56:39 -05:00
func cleanExpiredChallengesLoop ( room * Room ) {
ticker := time . NewTicker ( 5 * time . Minute )
defer ticker . Stop ()
for range ticker . C {
cleanExpiredChallenges ( room . DB )
cleanExpiredSessions ( room . DB )
}
}
2026-08-06 21:34:52 -05:00
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 )
}
2026-09-04 11:56:39 -05:00
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 )
}
2026-08-06 21:34:52 -05:00
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 )
}
2026-09-04 11:56:39 -05:00
// --- Old getVotes endpoint (kept for backward compat) ---
2026-08-06 21:34:52 -05:00
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" )
2026-09-04 11:56:39 -05:00
log . Printf ( "Vote accepted (legacy): %s (last active %s) voted for '%s'" , vote . VoterDisplayName , lastActiveTime , vote . SelectedSubmission )
2026-08-06 21:34:52 -05:00
w . WriteHeader ( http . StatusOK )
w . Write ([] byte ( "Vote recorded successfully" ))
}
}
2026-09-04 11:56:39 -05:00
// --- 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 ,
})
}
}
2026-08-06 21:34:52 -05:00
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
}