Compare commits

2 Commits
Author SHA1 Message Date
nolan e3cb0ebac3 Add Nostr-verified voting via NIP-07 browser extension 2026-08-20 10:40:28 -05:00
nolan 07e456c573 Tweaked donation request 2026-08-14 13:15:30 -05:00
8 changed files with 1105 additions and 122 deletions
+1 -1
View File
@@ -728,7 +728,7 @@ class Room:
NOTE: HTTP VOTING IS IN PRE-ALPHA. IF YOU WOULD LIKE TO SEE THE EARLY PRE-RELEASE, GO TO {self.vote_url()}
SUPPORT MAGBOT HERE:
MAKE MAGBOT BETTER:
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
+30 -2
View File
@@ -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
)
+168
View File
@@ -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=
+98 -5
View File
@@ -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,94 @@ 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;
}
+178 -59
View File
@@ -1,17 +1,125 @@
// Fetch submissions from the JSON endpoint and populate the suggestion list
let currentUser = null; // { npub, displayName }
let currentChallenge = null;
// --- 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 <a href="link">link your identity</a> 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
});
// Store auth state
currentUser = {
npub: npub,
displayName: data.display_name,
signedEvent: signedEvent
};
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 = `
<span class="suggestor">${sub.submitter} SUGGESTED</span> :
<span class="suggestor">${sub.submitter} SUGGESTED</span> :
<span class="suggestion">${sub.submission}</span>
<input type="radio" name="vote" value="${sub.submission}" data-submitter="${sub.submitter}" data-time="${sub.submission_time}">
`;
@@ -26,71 +134,82 @@ 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)
};
// 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 {
// Request a fresh challenge for this vote
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;
}
})
.catch(error => {
const challengeData = await challengeResp.json();
const challengeMsg = challengeData.challenge;
// Sign the challenge
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: challengeMsg
});
// Submit the vote
const voteResp = await fetch('verifyVote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
npub: currentUser.npub,
challenge: challengeMsg,
selected_submission: selectedRadio.value,
submitter: selectedRadio.dataset.submitter,
submission_time: parseInt(selectedRadio.dataset.time),
signed_event: signedEvent
})
});
if (voteResp.ok) {
alert('Vote submitted successfully!');
selectedRadio.checked = false;
} else {
const text = await voteResp.text();
alert('Error: ' + text);
}
} catch (error) {
console.error('Error submitting vote:', error);
alert('Error submitting vote: ' + error.message);
});
}
}
// Load submissions when page loads
// --- Init ---
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);
// Auto-connect if NIP-07 is available
if (hasNip07()) {
document.getElementById('nip07_badge').style.display = 'block';
}
// Optionally refresh submissions periodically
setInterval(loadSubmissions, 30000); // Refresh every 30 seconds
// Refresh submissions periodically
setInterval(() => {
if (currentUser) loadSubmissions();
}, 30000);
});
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Nostr Identity</title>
<link rel="stylesheet" href="css.css">
</head>
<body>
<h1>Link Your Nostr Identity</h1>
<p>Connect your npub to your Matrix display name so you can vote.</p>
<div id="auth_section">
<div id="nip07_status" class="info-box" style="display:none;">
Nostr browser extension detected.
</div>
<label for="display_name">Matrix Display Name:</label>
<input type="text" id="display_name" placeholder="Enter your Matrix display name exactly">
<label for="npub_input">Nostr Public Key (npub):</label>
<input type="text" id="npub_input" placeholder="npub1...">
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
<button id="link_btn" onclick="linkNpub()">Link & Sign</button>
<div id="status"></div>
</div>
<script>
let currentNpub = null;
async function connectNip07() {
if (!window.nostr) {
setStatus('No Nostr browser extension detected. Please enter your npub manually.', true);
return;
}
try {
const pubkey = await window.nostr.getPublicKey();
// Convert hex to npub bech32
const resp = await fetch('encode_npub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hex: pubkey})
});
if (resp.ok) {
const data = await resp.json();
document.getElementById('npub_input').value = data.npub;
currentNpub = data.npub;
setStatus('Connected! Npub loaded from extension.');
} else {
// Fallback: just use hex
document.getElementById('npub_input').value = pubkey;
currentNpub = pubkey;
setStatus('Connected! Paste this as npub if needed: ' + pubkey);
}
} catch (err) {
setStatus('Error connecting to extension: ' + err.message, true);
}
}
async function linkNpub() {
const displayName = document.getElementById('display_name').value.trim();
const npub = document.getElementById('npub_input').value.trim();
if (!displayName) {
setStatus('Please enter your Matrix display name.', true);
return;
}
if (!npub) {
setStatus('Please enter your npub or connect AlbyHub.', true);
return;
}
setStatus('Requesting signature...');
try {
// Build the linking message
// We need to get the hex pubkey first
let npubHex = npub;
// If it looks like a bech32 npub, we need to decode it client-side
// For simplicity, we'll send the npub and let the server decode it
// But we need the hex to build the message for signing
// Try to get hex from npub via server
const decodeResp = await fetch('decode_npub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({npub: npub})
});
if (!decodeResp.ok) {
if (!window.nostr) {
setStatus('Could not decode npub. Please install a Nostr extension.', true);
return;
}
setStatus('Could not decode npub via server.', true);
return;
}
const decodeData = await decodeResp.json();
npubHex = decodeData.hex;
// Build the expected linking message
const slug = window.location.pathname.split('/')[1];
const linkMsg = `Link npub ${npubHex} to Matrix user ${displayName} for room ${slug}`;
let signedEvent;
if (window.nostr) {
// Sign via NIP-07 extension
signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: linkMsg
});
} else {
setStatus('No Nostr extension available. Please install AlbyHub or another NIP-07 signer.', true);
return;
}
setStatus('Sending link request...');
const resp = await fetch('linkNpub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
display_name: displayName,
npub: npub,
signed_event: signedEvent
})
});
if (resp.ok) {
setStatus('Successfully linked! You can now <a href="/">go vote</a>.');
document.getElementById('link_btn').disabled = true;
} else {
const text = await resp.text();
setStatus('Error: ' + text, true);
}
} catch (err) {
setStatus('Error: ' + err.message, true);
}
}
function setStatus(msg, isError) {
const el = document.getElementById('status');
el.innerHTML = msg;
el.className = isError ? 'error' : 'success';
}
</script>
</body>
</html>
+29 -9
View File
@@ -8,15 +8,35 @@
</head>
<body>
<h1>Choose Your Favorite Title</h1>
<label for="display_name">Matrix Display Name (Not Matrix Base Username):</label>
<input type="text" id="display_name" placeholder="Enter your display name">
<h2>Suggestions:</h2>
<ul id="suggestion_list">
<!-- Populated Dynamically Via JS -->
</ul>
<div id="auth_section">
<div id="nip07_badge" class="info-box" style="display:none;">
Nostr extension connected
</div>
<div id="auth_npub_row">
<label for="npub_input">Nostr Public Key:</label>
<input type="text" id="npub_input" placeholder="npub1... or connect extension">
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
</div>
<button id="auth_btn" onclick="authenticate()">Sign In to Vote</button>
<a id="link_link" href="link">First time? Link your npub</a>
<div id="auth_status"></div>
</div>
<div id="vote_section" style="display:none;">
<div id="logged_in_as"></div>
<h2>Suggestions:</h2>
<ul id="suggestion_list">
<!-- Populated Dynamically Via JS -->
</ul>
<button id="submit_vote" onclick="submitVote()">&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;</button>
</div>
<script src="js.js"></script>
</body>
</html>
</html>
+447 -46
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -14,6 +15,8 @@ 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"
)
@@ -71,6 +74,27 @@ type Room struct {
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 {
@@ -83,12 +107,10 @@ 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"
}
@@ -96,8 +118,6 @@ func loadConfig(configPath string) (*Config, error) {
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 +147,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 +198,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 +213,160 @@ 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)
}
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 {
@@ -236,6 +405,7 @@ func main() {
go collectSubmissions(room)
go collectUserList(room)
go cleanExpiredChallengesLoop(room)
base := "/" + room.Slug
http.HandleFunc(base+"/", serveHTML)
@@ -243,14 +413,25 @@ 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)
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("/", indexHandler(rooms))
@@ -259,7 +440,14 @@ 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)
}
}
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 +465,28 @@ 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 serveSubmissions(subBuffer *SubmissionBuffer) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
subBuffer.Mtx.Lock()
@@ -313,15 +502,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 +536,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 +563,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 +571,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 +580,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 +588,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 +596,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,15 +605,11 @@ 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
mostRecentPost := room.UserMap.MostRecentPost
for _, user := range room.UserMap.Users {
if user.DisplayName == vote.VoterDisplayName {
userFound = true
lastActive = user.LastActive
break
}
}
@@ -445,22 +621,8 @@ 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
if lastActive < activityThreshold {
log.Printf("Vote rejected: User '%s' last active at %d, most recent post %d, threshold %d", vote.VoterDisplayName, lastActive, mostRecentPost, activityThreshold)
lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05")
mostRecentTime := time.Unix(mostRecentPost, 0).Format("2006-01-02 15:04:05")
http.Error(w, fmt.Sprintf("You must have been active within %d hours of the most recent room activity to vote. Your last activity was at %s. Most recent room activity: %s.", config.VoteActivityHours, lastActiveTime, mostRecentTime), http.StatusForbidden)
return
}
// 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)
@@ -468,18 +630,257 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
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 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)
// 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)
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,