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