diff --git a/configs/config.yaml b/configs/config.yaml index 6dd1fe7..f858c3d 100755 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -24,3 +24,6 @@ initial_sync_timeout: 300 # Initial sync timeout for post_results (ms) post_loop_interval: 1 # Delay between post_results loop iterations (s) user_list_interval: 300 # How often the user-list service refreshes (s) user_list_retry_interval: 60 # Retry delay on user-list error (s) + +# --- Feature toggles --- +suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled) diff --git a/configs/config_example.yaml b/configs/config_example.yaml index 493eaa6..3973add 100755 --- a/configs/config_example.yaml +++ b/configs/config_example.yaml @@ -22,3 +22,6 @@ initial_sync_timeout: 300 # Initial sync timeout for post_results (ms) post_loop_interval: 1 # Delay between post_results loop iterations (s) user_list_interval: 300 # How often the user-list service refreshes (s) user_list_retry_interval: 60 # Retry delay on user-list error (s) + +# --- Feature toggles --- +suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled) diff --git a/configs/daLunch.yaml.hold b/configs/daLunch.yaml.hold index 4f8a6f2..1c72ce4 100644 --- a/configs/daLunch.yaml.hold +++ b/configs/daLunch.yaml.hold @@ -12,3 +12,4 @@ dbName: "" vote_host: "agmninex.online" vote_path: "lunch" # URL slug for this room's voting page (http://:9081/) vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote +suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled) diff --git a/configs/mainChat.yaml.hold b/configs/mainChat.yaml.hold index dd3f16b..d4df6a0 100755 --- a/configs/mainChat.yaml.hold +++ b/configs/mainChat.yaml.hold @@ -12,3 +12,4 @@ dbName: "" vote_host: "agmninex.online" vote_path: "jb-chat" # URL slug for this room's voting page (http://:9081/) vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote +suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled) diff --git a/room.py b/room.py index 50c3fda..0098089 100755 --- a/room.py +++ b/room.py @@ -43,6 +43,7 @@ class Config: self.post_loop_interval = config.get("post_loop_interval", 1) self.user_list_interval = config.get("user_list_interval", 300) self.user_list_retry_interval = config.get("user_list_retry_interval", 60) + self.suggestion_announce_interval = config.get("suggestion_announce_interval", 0) class Room: def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None): @@ -67,6 +68,7 @@ class Room: self.banned_db_path = "banned_users.db" self.init_db() self.init_banned_db() + self.init_suggestion_counter_db() def safe_db_name(self, room_id): @@ -193,6 +195,47 @@ class Room: dbutil.commit(conn) conn.close() + def init_suggestion_counter_db(self): + """Initialize the suggestion counter table for periodic announcements.""" + conn = dbutil.connect(self.db_path) + c = conn.cursor() + _ = c.execute(''' + CREATE TABLE IF NOT EXISTS suggestion_counter ( + id INTEGER PRIMARY KEY CHECK (id = 1), + count INTEGER DEFAULT 0 + ) + ''') + _ = c.execute('INSERT OR IGNORE INTO suggestion_counter (id, count) VALUES (1, 0)') + dbutil.commit(conn) + conn.close() + + def get_suggestion_counter(self): + conn = dbutil.connect(self.db_path) + c = conn.cursor() + _ = c.execute('SELECT count FROM suggestion_counter WHERE id = 1') + row = c.fetchone() + conn.close() + return row[0] if row else 0 + + def increment_suggestion_counter(self, amount=1): + conn = dbutil.connect(self.db_path) + c = conn.cursor() + _ = c.execute('UPDATE suggestion_counter SET count = count + ? WHERE id = 1', (amount,)) + dbutil.commit(conn) + conn.close() + return self.get_suggestion_counter() + + async def check_and_announce_vote_beta(self, new_count): + """Post a beta announcement if the suggestion count hits the configured interval.""" + interval = self.config.suggestion_announce_interval + if interval <= 0: + return + if new_count > 0 and new_count % interval == 0: + url = self.vote_url() + await self.alert_post( + f"Psst! HTTP voting is now in beta! You can vote on titles at:\n{url}" + ) + async def reject_banned_suggestion(self, post): """Reply to a banned user's suggestion and do not insert it.""" await self.alert_post(f"{post.display_name}: this user has been banned from submitting titles") diff --git a/scraper.py b/scraper.py index 3c92ece..8cd898d 100755 --- a/scraper.py +++ b/scraper.py @@ -35,6 +35,9 @@ async def main(): # Scrape and write to database await room.get_convos() room.write_to_db() + if room.newly_inserted_posts: + count = room.increment_suggestion_counter(len(room.newly_inserted_posts)) + await room.check_and_announce_vote_beta(count) # Reply to banned users that they cannot submit titles await room.acknowledge_rejected_suggestions() # Ack newly recorded suggestions with a ✅ reaction diff --git a/voteServer/go.mod b/voteServer/go.mod index f2b542e..194d5c8 100755 --- a/voteServer/go.mod +++ b/voteServer/go.mod @@ -3,6 +3,34 @@ module vs go 1.24.9 require ( - github.com/mattn/go-sqlite3 v1.14.32 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/mattn/go-sqlite3 v1.14.32 + github.com/nbd-wtf/go-nostr v0.52.3 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/bytedance/sonic v1.13.1 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect ) diff --git a/voteServer/go.sum b/voteServer/go.sum index d1edb0d..adfb184 100755 --- a/voteServer/go.sum +++ b/voteServer/go.sum @@ -1,5 +1,173 @@ +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= +github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI= +github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/voteServer/htm/css.css b/voteServer/htm/css.css index 870d776..348751b 100755 --- a/voteServer/htm/css.css +++ b/voteServer/htm/css.css @@ -16,13 +16,21 @@ body { padding: 20px; } -#display_name { +input[type="text"] { width: 100%; padding: 10px; - margin-bottom: 20px; + margin-bottom: 15px; font-size: 16px; border: 1px solid #ccc; border-radius: 4px; + box-sizing: border-box; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: bold; + color: #333; } #suggestion_list { @@ -37,7 +45,7 @@ body { border-radius: 4px; } -#submit_vote { +button { padding: 10px 20px; font-size: 16px; background-color: #4CAF50; @@ -45,9 +53,159 @@ body { border: none; border-radius: 4px; cursor: pointer; + margin-top: 10px; + margin-right: 5px; +} + +button:hover { + background-color: #45a049; +} + +button:disabled { + background-color: #999; + cursor: not-allowed; +} + +#submit_vote { + margin-top: 20px; + font-size: 20px; +} + +#auth_section { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + background: #fafafa; +} + +#vote_section { margin-top: 20px; } -#submit_vote:hover { - background-color: #45a049; +#logged_in_as { + padding: 10px; + background: #e8f5e9; + border-radius: 4px; + margin-bottom: 15px; + font-weight: bold; + color: #2e7d32; +} + +.info-box { + padding: 8px 12px; + margin-bottom: 15px; + background: #e3f2fd; + border-radius: 4px; + color: #1565c0; + font-size: 14px; +} + +.success { + padding: 8px 12px; + margin-top: 10px; + background: #e8f5e9; + border-radius: 4px; + color: #2e7d32; +} + +.error { + padding: 8px 12px; + margin-top: 10px; + background: #ffebee; + border-radius: 4px; + color: #c62828; +} + +#connect_btn { + background-color: #f57c00; + margin-bottom: 15px; +} + +#connect_btn:hover { + background-color: #ef6c00; +} + +#auth_btn { + background-color: #1976d2; +} + +#auth_btn:hover { + background-color: #1565c0; +} + +#link_link { + display: inline-block; + margin-top: 10px; + color: #666; + font-size: 14px; +} + +#link_link:hover { + color: #333; +} + +#auth_method_select { + text-align: center; + margin-bottom: 10px; +} + +#auth_method_select p { + margin-bottom: 15px; + font-size: 16px; + color: #555; +} + +#matrix_auth_btn { + background-color: #0dbd8b; + margin-right: 10px; +} + +#matrix_auth_btn:hover { + background-color: #0aa37a; +} + +#nostr_auth_btn { + background-color: #f57c00; +} + +#nostr_auth_btn:hover { + background-color: #ef6c00; +} + +#login_form { + max-width: 400px; +} + +#login_form input[type="password"] { + width: 100%; + padding: 10px; + margin-bottom: 15px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; +} + +#back_link { + display: inline-block; + margin-top: 20px; + color: #666; + font-size: 14px; +} + +#back_link:hover { + color: #333; +} + +#sign_out_btn { + background-color: #999; + font-size: 12px; + padding: 4px 12px; + margin-top: 5px; + margin-bottom: 15px; +} + +#sign_out_btn:hover { + background-color: #777; } diff --git a/voteServer/htm/js.js b/voteServer/htm/js.js index 5db6fe3..322ed65 100755 --- a/voteServer/htm/js.js +++ b/voteServer/htm/js.js @@ -1,19 +1,185 @@ -// Fetch submissions from the JSON endpoint and populate the suggestion list +let currentUser = null; +let currentChallenge = null; +let authMethod = null; + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// --- Matrix auth --- + +function goToMatrixLogin() { + const slug = window.location.pathname.split('/').filter(Boolean)[0]; + window.location.href = '/' + slug + '/matrix-login'; +} + +function signOut() { + sessionStorage.removeItem('matrix_session_token'); + currentUser = null; + authMethod = null; + window.location.reload(); +} + +function showNostrAuth() { + document.getElementById('auth_method_select').style.display = 'none'; + document.getElementById('nostr_auth_section').style.display = 'block'; +} + +async function checkMatrixSession() { + const sessionToken = sessionStorage.getItem('matrix_session_token'); + if (!sessionToken) return false; + + try { + const slug = window.location.pathname.split('/').filter(Boolean)[0]; + const resp = await fetch('/' + slug + '/matrixVerify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_token: sessionToken }) + }); + + if (resp.ok) { + const data = await resp.json(); + currentUser = { displayName: data.display_name, sessionToken: sessionToken }; + authMethod = 'matrix'; + + document.getElementById('auth_section').style.display = 'none'; + document.getElementById('vote_section').style.display = 'block'; + document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name; + + loadSubmissions(); + return true; + } else { + sessionStorage.removeItem('matrix_session_token'); + } + } catch (e) { + sessionStorage.removeItem('matrix_session_token'); + } + return false; +} + +// --- Nostr auth --- + +function hasNip07() { + return typeof window.nostr !== 'undefined' && window.nostr !== null; +} + +async function connectNip07() { + if (!hasNip07()) { + setAuthStatus('No Nostr browser extension detected. Enter your npub manually.', true); + return; + } + try { + const hexKey = await window.nostr.getPublicKey(); + const resp = await fetch('encode_npub', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hex: hexKey }) + }); + if (resp.ok) { + const data = await resp.json(); + document.getElementById('npub_input').value = data.npub; + document.getElementById('nip07_badge').style.display = 'block'; + setAuthStatus('Connected! Click "Sign In to Vote" to continue.'); + } else { + document.getElementById('npub_input').value = hexKey; + setAuthStatus('Connected (using hex key). Click "Sign In to Vote".'); + } + } catch (err) { + setAuthStatus('Error: ' + err.message, true); + } +} + +async function authenticate() { + const npub = document.getElementById('npub_input').value.trim(); + if (!npub) { + setAuthStatus('Enter your npub or connect AlbyHub first.', true); + return; + } + + setAuthStatus('Requesting challenge...'); + + try { + const resp = await fetch('challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ npub: npub }) + }); + + if (resp.status === 404) { + setAuthStatus('Npub not linked. Please link your identity first.', true); + return; + } + + if (!resp.ok) { + const text = await resp.text(); + setAuthStatus('Error: ' + text, true); + return; + } + + const data = await resp.json(); + currentChallenge = data.challenge; + + if (!hasNip07()) { + setAuthStatus('No Nostr extension. Please install AlbyHub to sign.', true); + return; + } + + setAuthStatus('Please sign the challenge in your extension...'); + + const signedEvent = await window.nostr.signEvent({ + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: currentChallenge + }); + + currentUser = { + npub: npub, + displayName: data.display_name, + signedEvent: signedEvent + }; + authMethod = 'nostr'; + + document.getElementById('auth_section').style.display = 'none'; + document.getElementById('vote_section').style.display = 'block'; + document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name; + + loadSubmissions(); + + } catch (err) { + if (err.message && err.message.includes('denied')) { + setAuthStatus('Signature request denied.', true); + } else { + setAuthStatus('Error: ' + err.message, true); + } + } +} + +function setAuthStatus(msg, isError) { + const el = document.getElementById('auth_status'); + el.innerHTML = msg; + el.className = isError ? 'error' : 'success'; +} + +// --- Submissions --- + async function loadSubmissions() { try { const response = await fetch('submissions'); const data = await response.json(); - + const submissionList = document.getElementById('suggestion_list'); - submissionList.innerHTML = ''; // Clear existing items - + submissionList.innerHTML = ''; + if (data.Submissions && data.Submissions.length > 0) { - data.Submissions.forEach((sub, index) => { + data.Submissions.forEach((sub) => { const li = document.createElement('li'); li.innerHTML = ` - ${sub.submitter} SUGGESTED : - ${sub.submission} - + ${escapeHtml(sub.submitter)} SUGGESTED : + ${escapeHtml(sub.submission)} + `; submissionList.appendChild(li); }); @@ -26,71 +192,103 @@ async function loadSubmissions() { } } -// Iterate over all radio inputs and collect selected vote -function getVotes() { - const displayName = document.getElementById('display_name').value.trim(); - - if (!displayName) { - alert('Please enter your display name'); +// --- Voting --- + +async function submitVote() { + if (!currentUser) { + alert('Please sign in first'); return; } - - // Get the selected radio button + const selectedRadio = document.querySelector('input[name="vote"]:checked'); - if (!selectedRadio) { alert('Please select a title to vote for'); return; } - - // Collect vote data + const voteData = { - voter_display_name: displayName, selected_submission: selectedRadio.value, submitter: selectedRadio.dataset.submitter, - submission_time: parseInt(selectedRadio.dataset.time), - vote_timestamp: Math.floor(Date.now() / 1000) + submission_time: parseInt(selectedRadio.dataset.time) }; - - // Send to the /getVotes endpoint - fetch('getVotes', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(voteData) - }) - .then(response => { - if (response.ok) { - alert('Vote submitted successfully!'); - // Clear the selection - selectedRadio.checked = false; - document.getElementById('display_name').value = ''; - } else { - return response.text().then(text => { - throw new Error(text || 'Failed to submit vote'); + + try { + if (authMethod === 'matrix') { + const resp = await fetch('matrixVote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_token: currentUser.sessionToken, + ...voteData + }) }); + + if (resp.ok) { + alert('Vote submitted successfully!'); + selectedRadio.checked = false; + } else { + const text = await resp.text(); + alert('Error: ' + text); + } + } else if (authMethod === 'nostr') { + const challengeResp = await fetch('challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ npub: currentUser.npub }) + }); + + if (!challengeResp.ok) { + const text = await challengeResp.text(); + alert('Error getting challenge: ' + text); + return; + } + + const challengeData = await challengeResp.json(); + const challengeMsg = challengeData.challenge; + + const signedEvent = await window.nostr.signEvent({ + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: challengeMsg + }); + + const voteResp = await fetch('verifyVote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + npub: currentUser.npub, + challenge: challengeMsg, + ...voteData, + signed_event: signedEvent + }) + }); + + if (voteResp.ok) { + alert('Vote submitted successfully!'); + selectedRadio.checked = false; + } else { + const text = await voteResp.text(); + alert('Error: ' + text); + } } - }) - .catch(error => { + } catch (error) { console.error('Error submitting vote:', error); alert('Error submitting vote: ' + error.message); - }); + } } -// Load submissions when page loads -document.addEventListener('DOMContentLoaded', () => { - loadSubmissions(); - - // Add submit button if it doesn't exist - if (!document.getElementById('submit_vote')) { - const submitButton = document.createElement('button'); - submitButton.id = 'submit_vote'; - submitButton.textContent = '⛵⛵⛵⛵⛵⛵⛵⛵⛵'; - submitButton.onclick = getVotes; - document.body.appendChild(submitButton); +// --- Init --- + +document.addEventListener('DOMContentLoaded', async () => { + setInterval(() => { + if (currentUser) loadSubmissions(); + }, 30000); + + const matrixAuthed = await checkMatrixSession(); + if (matrixAuthed) return; + + if (hasNip07()) { + document.getElementById('nip07_badge').style.display = 'block'; } - - // Optionally refresh submissions periodically - setInterval(loadSubmissions, 30000); // Refresh every 30 seconds }); diff --git a/voteServer/htm/link.html b/voteServer/htm/link.html new file mode 100644 index 0000000..9cb9b6f --- /dev/null +++ b/voteServer/htm/link.html @@ -0,0 +1,143 @@ + + + + + + Link Nostr Identity + + + +

Link Your Nostr Identity

+

Connect your npub to your Matrix display name so you can vote.

+ +
+ + + + + + + + + + + +
+
+ + + + diff --git a/voteServer/htm/matrix-login.html b/voteServer/htm/matrix-login.html new file mode 100644 index 0000000..9c87ede --- /dev/null +++ b/voteServer/htm/matrix-login.html @@ -0,0 +1,149 @@ + + + + + + Sign in with Matrix + + + +

Sign in with Matrix

+

Log in with your Matrix account to vote.

+ +
+ + + + + + + + + + + +
+
+ + Back to voting page + + + + diff --git a/voteServer/htm/ndx.html b/voteServer/htm/ndx.html index 9918c37..5e3f689 100755 --- a/voteServer/htm/ndx.html +++ b/voteServer/htm/ndx.html @@ -8,15 +8,44 @@

Choose Your Favorite Title

- - - -

Suggestions:

-
    - -
- +
+
+

How would you like to sign in?

+ + +
+ + +
+ + + - \ No newline at end of file + diff --git a/voteServer/main.go b/voteServer/main.go index ca72461..9a0799f 100755 --- a/voteServer/main.go +++ b/voteServer/main.go @@ -1,12 +1,17 @@ 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" @@ -14,10 +19,40 @@ import ( "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"` @@ -61,14 +96,50 @@ type Config struct { 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 + Config *Config + Slug string + DB *sql.DB SubBuffer *SubmissionBuffer - UserMap *UserMap + 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) { @@ -83,21 +154,21 @@ func loadConfig(configPath string) (*Config, error) { return nil, fmt.Errorf("failed to parse config: %w", err) } - // Set default value for VoteActivityHours if not specified if config.VoteActivityHours == 0 { config.VoteActivityHours = 72 } - // Set default value for VoteHost if not specified if config.VoteHost == "" { config.VoteHost = "localhost" } + if config.Homeserver == "" { + config.Homeserver = "https://matrix.org" + } + return &config, nil } -// Load one or more room configs. If a configs/ directory exists, load all -// yaml files from it; otherwise fall back to the single ../config.yaml. func loadConfigs() ([]*Config, error) { var configs []*Config @@ -127,7 +198,6 @@ func loadConfigs() ([]*Config, error) { return configs, nil } -// Load the slug map written by the bot (room_id -> slug). func loadRoomSlugs() map[string]string { data, err := os.ReadFile(filepath.Join("..", "room_slugs.json")) if err != nil { @@ -179,7 +249,6 @@ func initDB(dbPath string) (*sql.DB, error) { return nil, fmt.Errorf("failed to open database: %w", err) } - // Create votes table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -195,9 +264,189 @@ func initDB(dbPath string) (*sql.DB, error) { 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 { @@ -236,6 +485,7 @@ func main() { go collectSubmissions(room) go collectUserList(room) + go cleanExpiredChallengesLoop(room) base := "/" + room.Slug http.HandleFunc(base+"/", serveHTML) @@ -243,14 +493,35 @@ func main() { 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) } - // Backward compatibility: a single room is also served at the root paths. 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)) @@ -259,7 +530,15 @@ func main() { log.Fatal(http.ListenAndServe(":9081", nil)) } -// Index page listing every room being served +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 { @@ -277,27 +556,33 @@ func indexHandler(rooms []*Room) func(w http.ResponseWriter, r *http.Request) { } } -// Serve the HTML page func serveHTML(w http.ResponseWriter, r *http.Request) { htmlPath := filepath.Join("htm", "ndx.html") http.ServeFile(w, r, htmlPath) } -// Serve the CSS file 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) } -// Serve the JavaScript file 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) } -// Serve the submissions data as JSON +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() @@ -313,15 +598,12 @@ func serveSubmissions(subBuffer *SubmissionBuffer) func(w http.ResponseWriter, r } } -// Pull the data from the json endpoint so that we can vote on them func collectSubmissions(room *Room) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() - // Initial fetch fetchSubmissions(room) - // Periodic fetches for range ticker.C { fetchSubmissions(room) } @@ -350,15 +632,12 @@ func fetchSubmissions(room *Room) { log.Printf("Fetched %d submissions", len(fetchedBuffer.Submissions)) } -// Pull the user list from the json endpoint so we can confirm voters func collectUserList(room *Room) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() - // Initial fetch fetchUserList(room) - // Periodic fetches for range ticker.C { fetchUserList(room) } @@ -380,7 +659,6 @@ func fetchUserList(room *Room) { return } - // Update the user map with write lock room.UserMap.Mtx.Lock() room.UserMap.Users = userData.Users room.UserMap.MostRecentPost = userData.MostRecentPost @@ -389,7 +667,8 @@ func fetchUserList(room *Room) { log.Printf("Fetched %d users (most recent post: %d)", len(userData.Users), userData.MostRecentPost) } -// Receive the votes via POST when they come in and save to database +// --- 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 { @@ -397,7 +676,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { return } - // Read the request body body, err := io.ReadAll(r.Body) if err != nil { log.Printf("Error reading request body: %v", err) @@ -406,7 +684,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - // Parse the vote var vote Vote err = json.Unmarshal(body, &vote) if err != nil { @@ -415,7 +692,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { return } - // Validate the vote if vote.VoterDisplayName == "" { http.Error(w, "Voter display name is required", http.StatusBadRequest) return @@ -425,7 +701,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { return } - // Validate that the voter has been active within the configured time window room.UserMap.Mtx.RLock() var userFound bool var lastActive int64 @@ -445,7 +720,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { return } - // Check if user was active within configured hours of the most recent post in the room config := room.Config activityWindowSeconds := int64(config.VoteActivityHours * 60 * 60) activityThreshold := mostRecentPost - activityWindowSeconds @@ -457,10 +731,8 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { return } - // Set vote timestamp vote.VoteTimestamp = time.Now().Unix() - // Save vote to database (replaces any previous vote from this user) err = saveVote(room.DB, vote) if err != nil { log.Printf("Error saving vote: %v", err) @@ -469,17 +741,485 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) { } lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05") - log.Printf("Vote accepted: %s (last active %s) voted for '%s'", vote.VoterDisplayName, lastActiveTime, vote.SelectedSubmission) + log.Printf("Vote accepted (legacy): %s (last active %s) voted for '%s'", vote.VoterDisplayName, lastActiveTime, vote.SelectedSubmission) - // Send success response w.WriteHeader(http.StatusOK) w.Write([]byte("Vote recorded successfully")) } } -// Save a vote to the database (replaces any previous vote from this user) +// --- 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 { - // Use INSERT OR REPLACE to automatically handle updating existing votes _, err := db.Exec(` INSERT OR REPLACE INTO votes ( voter_display_name,