diff --git a/build/bin/nostr-poster b/build/bin/nostr-poster index 66cb78d..c0f0d06 100755 Binary files a/build/bin/nostr-poster and b/build/bin/nostr-poster differ diff --git a/build/config/config.yaml b/build/config/config.yaml index c519e94..b461b74 100644 --- a/build/config/config.yaml +++ b/build/config/config.yaml @@ -1,6 +1,7 @@ app_name: "Nostr Poster" server_port: 8765 log_level: "info" +allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7" bot: keys_file: "./keys.json" diff --git a/build/db/nostr-poster.db-shm b/build/db/nostr-poster.db-shm index a608d93..cf527ce 100644 Binary files a/build/db/nostr-poster.db-shm and b/build/db/nostr-poster.db-shm differ diff --git a/build/db/nostr-poster.db-wal b/build/db/nostr-poster.db-wal index 04563d2..d26265b 100644 Binary files a/build/db/nostr-poster.db-wal and b/build/db/nostr-poster.db-wal differ diff --git a/cmd/server/main.go b/cmd/server/main.go index 9ec1c7b..226d0c2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -104,11 +104,8 @@ func main() { nil, // Supported types will be discovered logger, func(url, method string, payload []byte) (string, error) { - // Get the private key for the bot - // This is a placeholder - in the real implementation - // you would need to determine which bot's key to use - // based on the context of the upload - botPubkey := "" + // Replace with a valid bot's public key. + botPubkey := "your_valid_bot_pubkey_here" privkey, err := keyStore.GetPrivateKey(botPubkey) if err != nil { return "", err @@ -122,11 +119,8 @@ func main() { cfg.Media.Blossom.ServerURL, logger, func(url, method string) (string, error) { - // Get the private key for the bot - // This is a placeholder - in the real implementation - // you would need to determine which bot's key to use - // based on the context of the upload - botPubkey := "" + // Replace with the appropriate bot's public key + botPubkey := "your_valid_bot_pubkey_here" privkey, err := keyStore.GetPrivateKey(botPubkey) if err != nil { return "", err diff --git a/config/config.yaml b/config/config.yaml index c519e94..b461b74 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,6 +1,7 @@ app_name: "Nostr Poster" server_port: 8765 log_level: "info" +allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7" bot: keys_file: "./keys.json" diff --git a/go.mod b/go.mod index e3f4659..cb99256 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( 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.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect diff --git a/go.sum b/go.sum index ae83fe9..7ee7d06 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,30 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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 v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +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.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -14,12 +35,18 @@ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 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/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.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -37,15 +64,32 @@ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaC github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 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.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -71,6 +115,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nbd-wtf/go-nostr v0.50.0 h1:MgL/HPnWSTb5BFCL9RuzYQQpMrTi67MvHem4nWFn47E= github.com/nbd-wtf/go-nostr v0.50.0/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8= +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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -105,6 +158,7 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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= @@ -127,6 +181,9 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= @@ -135,23 +192,55 @@ golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsG golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +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= google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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.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= diff --git a/internal/api/bot_service.go b/internal/api/bot_service.go index d709195..279df13 100644 --- a/internal/api/bot_service.go +++ b/internal/api/bot_service.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - + "github.com/nbd-wtf/go-nostr" // required for key derivation "git.sovbit.dev/Enki/nostr-poster/internal/crypto" "git.sovbit.dev/Enki/nostr-poster/internal/db" "git.sovbit.dev/Enki/nostr-poster/internal/models" @@ -18,11 +18,11 @@ import ( // BotService provides functionality for managing bots type BotService struct { - db *db.DB - keyStore *crypto.KeyStore - eventMgr *events.EventManager - relayMgr *relay.Manager - logger *zap.Logger + db *db.DB + keyStore *crypto.KeyStore + eventMgr *events.EventManager + relayMgr *relay.Manager + logger *zap.Logger } // NewBotService creates a new BotService @@ -34,14 +34,19 @@ func NewBotService( logger *zap.Logger, ) *BotService { return &BotService{ - db: db, - keyStore: keyStore, - eventMgr: eventMgr, - relayMgr: relayMgr, - logger: logger, + db: db, + keyStore: keyStore, + eventMgr: eventMgr, + relayMgr: relayMgr, + logger: logger, } } +// GetPrivateKey returns the private key for the given pubkey from the keystore. +func (s *BotService) GetPrivateKey(pubkey string) (string, error) { + return s.keyStore.GetPrivateKey(pubkey) +} + // ListUserBots lists all bots owned by a user func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) { query := ` @@ -49,13 +54,13 @@ func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) { WHERE b.owner_pubkey = ? ORDER BY b.created_at DESC ` - + var bots []*models.Bot err := s.db.Select(&bots, query, ownerPubkey) if err != nil { return nil, fmt.Errorf("failed to list bots: %w", err) } - + // Load associated data for each bot for _, bot := range bots { if err := s.loadBotRelatedData(bot); err != nil { @@ -64,7 +69,7 @@ func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) { zap.Error(err)) } } - + return bots, nil } @@ -74,18 +79,18 @@ func (s *BotService) GetBotByID(botID int64, ownerPubkey string) (*models.Bot, e SELECT b.* FROM bots b WHERE b.id = ? AND b.owner_pubkey = ? ` - + var bot models.Bot err := s.db.Get(&bot, query, botID, ownerPubkey) if err != nil { return nil, fmt.Errorf("failed to get bot: %w", err) } - + // Load associated data if err := s.loadBotRelatedData(&bot); err != nil { return &bot, fmt.Errorf("failed to load related data: %w", err) } - + return &bot, nil } @@ -94,32 +99,72 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { // Start a transaction tx, err := s.db.Beginx() if err != nil { + s.logger.Error("Failed to start transaction", zap.Error(err)) return nil, fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() - + + s.logger.Info("Creating new bot", + zap.String("name", bot.Name), + zap.String("privkey_type", detectKeyType(bot.EncryptedPrivkey))) + // Check if we need to generate a keypair - if bot.Pubkey == "" { + if bot.Pubkey == "" && bot.EncryptedPrivkey == "" { // Generate a new keypair + s.logger.Info("Generating new keypair") pubkey, privkey, err := s.keyStore.GenerateKey() if err != nil { + s.logger.Error("Failed to generate keypair", zap.Error(err)) return nil, fmt.Errorf("failed to generate keypair: %w", err) } - + bot.Pubkey = pubkey bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore + s.logger.Info("Generated keypair successfully", zap.String("pubkey", pubkey)) } else if bot.EncryptedPrivkey != "" { + // If only privkey is provided, derive the pubkey + if bot.Pubkey == "" { + privkey := bot.EncryptedPrivkey + s.logger.Info("Deriving pubkey from provided privkey", + zap.String("key_type", detectKeyType(privkey))) + + if len(privkey) > 4 && privkey[:4] == "nsec" { + s.logger.Debug("Decoding nsec key") + var decodeErr error + privkey, decodeErr = s.keyStore.DecodeNsecKey(privkey) + if decodeErr != nil { + s.logger.Error("Failed to decode nsec key", zap.Error(decodeErr)) + return nil, fmt.Errorf("failed to decode nsec key: %w", decodeErr) + } + s.logger.Debug("Successfully decoded nsec key to hex format") + } + + derivedPub, err := nostr.GetPublicKey(privkey) + if err != nil { + s.logger.Error("Invalid private key", zap.Error(err)) + return nil, fmt.Errorf("invalid private key: %w", err) + } + bot.Pubkey = derivedPub + s.logger.Info("Successfully derived pubkey", zap.String("pubkey", derivedPub)) + } + // Import the provided keypair + s.logger.Debug("Importing keypair to keystore") if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil { + s.logger.Error("Failed to import keypair", zap.Error(err)) return nil, fmt.Errorf("failed to import keypair: %w", err) } + s.logger.Debug("Successfully imported keypair to keystore") } else { + s.logger.Error("Missing key information", + zap.Bool("has_pubkey", bot.Pubkey != ""), + zap.Bool("has_privkey", bot.EncryptedPrivkey != "")) return nil, errors.New("either provide both pubkey and privkey or none for auto-generation") } - + // Set created time bot.CreatedAt = time.Now() - + // Insert the bot query := ` INSERT INTO bots ( @@ -127,7 +172,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { profile_picture, banner, created_at, owner_pubkey ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` - + result, err := tx.Exec( query, bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio, @@ -135,16 +180,18 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { bot.CreatedAt, bot.OwnerPubkey, ) if err != nil { + s.logger.Error("Failed to insert bot", zap.Error(err)) return nil, fmt.Errorf("failed to insert bot: %w", err) } - + // Get the inserted ID botID, err := result.LastInsertId() if err != nil { + s.logger.Error("Failed to get inserted ID", zap.Error(err)) return nil, fmt.Errorf("failed to get inserted ID: %w", err) } bot.ID = botID - + // Create default post config postConfig := &models.PostConfig{ BotID: botID, @@ -153,22 +200,23 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { PostTemplate: "", Enabled: false, } - + postConfigQuery := ` INSERT INTO post_config ( bot_id, hashtags, interval_minutes, post_template, enabled ) VALUES (?, ?, ?, ?, ?) ` - + _, err = tx.Exec( postConfigQuery, postConfig.BotID, postConfig.Hashtags, postConfig.IntervalMinutes, postConfig.PostTemplate, postConfig.Enabled, ) if err != nil { + s.logger.Error("Failed to insert post config", zap.Error(err)) return nil, fmt.Errorf("failed to insert post config: %w", err) } - + // Create default media config mediaConfig := &models.MediaConfig{ BotID: botID, @@ -177,22 +225,23 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { Nip94ServerURL: "", BlossomServerURL: "", } - + mediaConfigQuery := ` INSERT INTO media_config ( bot_id, primary_service, fallback_service, nip94_server_url, blossom_server_url ) VALUES (?, ?, ?, ?, ?) ` - + _, err = tx.Exec( mediaConfigQuery, mediaConfig.BotID, mediaConfig.PrimaryService, mediaConfig.FallbackService, mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL, ) if err != nil { + s.logger.Error("Failed to insert media config", zap.Error(err)) return nil, fmt.Errorf("failed to insert media config: %w", err) } - + // Add default relays defaultRelays := []struct { URL string @@ -203,24 +252,28 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { {"wss://nostr.mutinywallet.com", true, true}, {"wss://relay.nostr.band", true, true}, } - + relayQuery := ` INSERT INTO relays (bot_id, url, read, write) VALUES (?, ?, ?, ?) ` - + for _, relay := range defaultRelays { _, err = tx.Exec(relayQuery, botID, relay.URL, relay.Read, relay.Write) if err != nil { + s.logger.Error("Failed to insert relay", + zap.String("url", relay.URL), + zap.Error(err)) return nil, fmt.Errorf("failed to insert relay: %w", err) } } - + // Commit the transaction if err := tx.Commit(); err != nil { + s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, fmt.Errorf("failed to commit transaction: %w", err) } - + // Load associated data bot.PostConfig = postConfig bot.MediaConfig = mediaConfig @@ -229,7 +282,12 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { {BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true}, {BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true}, } - + + s.logger.Info("Bot created successfully", + zap.Int64("id", botID), + zap.String("name", bot.Name), + zap.String("pubkey", bot.Pubkey)) + return bot, nil } @@ -240,7 +298,7 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { if err != nil { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } - + // We don't update the pubkey or encrypted_privkey query := ` UPDATE bots SET @@ -253,23 +311,23 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { banner = ? WHERE id = ? AND owner_pubkey = ? ` - + _, err = s.db.Exec( query, - bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, + bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner, bot.ID, bot.OwnerPubkey, ) if err != nil { return nil, fmt.Errorf("failed to update bot: %w", err) } - + // Get the updated bot updatedBot, err := s.GetBotByID(bot.ID, bot.OwnerPubkey) if err != nil { return nil, fmt.Errorf("failed to retrieve updated bot: %w", err) } - + return updatedBot, nil } @@ -280,26 +338,26 @@ func (s *BotService) DeleteBot(botID int64, ownerPubkey string) error { if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } - + // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() - + // Delete the bot (cascade will handle related tables) query := `DELETE FROM bots WHERE id = ? AND owner_pubkey = ?` _, err = tx.Exec(query, botID, ownerPubkey) if err != nil { return fmt.Errorf("failed to delete bot: %w", err) } - + // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } - + return nil } @@ -315,14 +373,14 @@ func (s *BotService) UpdateBotConfig( if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } - + // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() - + // Update post config if provided if postConfig != nil { query := ` @@ -333,7 +391,7 @@ func (s *BotService) UpdateBotConfig( enabled = ? WHERE bot_id = ? ` - + _, err = tx.Exec( query, postConfig.Hashtags, postConfig.IntervalMinutes, @@ -344,7 +402,7 @@ func (s *BotService) UpdateBotConfig( return fmt.Errorf("failed to update post config: %w", err) } } - + // Update media config if provided if mediaConfig != nil { query := ` @@ -355,7 +413,7 @@ func (s *BotService) UpdateBotConfig( blossom_server_url = ? WHERE bot_id = ? ` - + _, err = tx.Exec( query, mediaConfig.PrimaryService, mediaConfig.FallbackService, @@ -366,12 +424,12 @@ func (s *BotService) UpdateBotConfig( return fmt.Errorf("failed to update media config: %w", err) } } - + // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } - + return nil } @@ -382,7 +440,7 @@ func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Re if err != nil { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } - + // Get the relays query := ` SELECT id, bot_id, url, read, write @@ -390,13 +448,13 @@ func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Re WHERE bot_id = ? ORDER BY id ` - + var relays []*models.Relay err = s.db.Select(&relays, query, botID) if err != nil { return nil, fmt.Errorf("failed to get relays: %w", err) } - + return relays, nil } @@ -407,20 +465,20 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []* if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } - + // Start a transaction tx, err := s.db.Beginx() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() - + // Delete existing relays _, err = tx.Exec("DELETE FROM relays WHERE bot_id = ?", botID) if err != nil { return fmt.Errorf("failed to delete existing relays: %w", err) } - + // Insert new relays for _, relay := range relays { _, err = tx.Exec( @@ -431,12 +489,12 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []* return fmt.Errorf("failed to insert relay: %w", err) } } - + // Commit the transaction if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } - + return nil } @@ -447,13 +505,13 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { if err != nil { return fmt.Errorf("bot not found or not owned by user: %w", err) } - + // Create and sign the metadata event event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) if err != nil { return fmt.Errorf("failed to create metadata event: %w", err) } - + // Set up relay connections for _, relay := range bot.Relays { if relay.Write { @@ -464,20 +522,20 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { } } } - + // Publish the event ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + published, err := s.relayMgr.PublishEvent(ctx, event) if err != nil { return fmt.Errorf("failed to publish profile: %w", err) } - + s.logger.Info("Published profile to relays", zap.Int64("botID", botID), zap.Strings("relays", published)) - + return nil } @@ -490,7 +548,7 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error { return fmt.Errorf("failed to load post config: %w", err) } bot.PostConfig = &postConfig - + // Load media config var mediaConfig models.MediaConfig err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", bot.ID) @@ -498,7 +556,7 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error { return fmt.Errorf("failed to load media config: %w", err) } bot.MediaConfig = &mediaConfig - + // Load relays var relays []*models.Relay err = s.db.Select(&relays, "SELECT * FROM relays WHERE bot_id = ?", bot.ID) @@ -506,6 +564,24 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error { return fmt.Errorf("failed to load relays: %w", err) } bot.Relays = relays - + return nil -} \ No newline at end of file +} + +// Helper function to detect key type for logging +func detectKeyType(key string) string { + if key == "" { + return "empty" + } + if len(key) > 4 { + if key[:4] == "nsec" { + return "nsec" + } else if key[:4] == "npub" { + return "npub" + } + } + if len(key) == 64 { + return "hex" + } + return "unknown" +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 0fd2eb5..bd6cef3 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -2,13 +2,23 @@ package api import ( + "context" + "fmt" "net/http" + "os" + "path/filepath" "strconv" + "strings" + "time" + "regexp" "github.com/gin-gonic/gin" + "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/auth" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" + "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" + "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" "go.uber.org/zap" ) @@ -79,6 +89,9 @@ func (a *API) setupRoutes() { botGroup.POST("/:id/run", a.runBotNow) botGroup.POST("/:id/enable", a.enableBot) botGroup.POST("/:id/disable", a.disableBot) + + // NEW: Manual post creation + botGroup.POST("/:id/post", a.createManualPost) } // Content management @@ -88,6 +101,9 @@ func (a *API) setupRoutes() { contentGroup.GET("/:botId", a.listBotContent) contentGroup.POST("/:botId/upload", a.uploadContent) contentGroup.DELETE("/:botId/:filename", a.deleteContent) + + // NEW: Media server upload + contentGroup.POST("/:botId/uploadToMediaServer", a.uploadToMediaServer) } // Stats @@ -100,6 +116,7 @@ func (a *API) setupRoutes() { // Serve the web UI a.router.StaticFile("/", "./web/index.html") + a.router.StaticFile("/content.html", "./web/content.html") a.router.Static("/assets", "./web/assets") // Handle 404s for SPA @@ -198,27 +215,41 @@ func (a *API) listBots(c *gin.Context) { c.JSON(http.StatusOK, bots) } -// createBot creates a new bot func (a *API) createBot(c *gin.Context) { pubkey := c.GetString("pubkey") + a.logger.Info("Bot creation request received", zap.String("owner_pubkey", pubkey)) var bot models.Bot if err := c.ShouldBindJSON(&bot); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) + a.logger.Error("Invalid bot data", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()}) return } + // Log received data + a.logger.Info("Received bot creation data", + zap.String("name", bot.Name), + zap.String("display_name", bot.DisplayName), + zap.Bool("has_privkey", bot.EncryptedPrivkey != ""), + zap.Bool("has_pubkey", bot.Pubkey != "")) + // Set the owner bot.OwnerPubkey = pubkey // Create the bot createdBot, err := a.botService.CreateBot(&bot) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot"}) a.logger.Error("Failed to create bot", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + a.logger.Info("Bot created successfully", + zap.Int64("bot_id", createdBot.ID), + zap.String("name", createdBot.Name)) + + createdBot.EncryptedPrivkey = "" + c.JSON(http.StatusCreated, createdBot) } @@ -556,20 +587,401 @@ func (a *API) disableBot(c *gin.Context) { // listBotContent lists the content files for a bot func (a *API) listBotContent(c *gin.Context) { - // This will be implemented when we add content management - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) + pubkey := c.GetString("pubkey") + + botIDStr := c.Param("botId") + botID, err := strconv.ParseInt(botIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Ensure the bot belongs to the user + _, err = a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Build the path to the bot's content folder + // e.g. content/bot_123 + contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) + // If the folder doesn't exist yet, return empty + if _, err := os.Stat(contentDir); os.IsNotExist(err) { + c.JSON(http.StatusOK, []string{}) + return + } + + // Read the directory + entries, err := os.ReadDir(contentDir) + if err != nil { + a.logger.Error("Failed to read content folder", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read content folder"}) + return + } + + // Collect filenames + var files []string + for _, entry := range entries { + // skip subdirs if you want, or handle them + if entry.IsDir() { + continue + } + files = append(files, entry.Name()) + } + + c.JSON(http.StatusOK, files) } // uploadContent uploads content for a bot func (a *API) uploadContent(c *gin.Context) { - // This will be implemented when we add content management - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) + pubkey := c.GetString("pubkey") + + botIDStr := c.Param("botId") + botID, err := strconv.ParseInt(botIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Ensure the bot belongs to user + _, err = a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Get the file from the form data + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file in request"}) + return + } + + // Build the path: e.g. content/bot_123 + contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) + // Ensure the directory exists + if err := os.MkdirAll(contentDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot folder"}) + return + } + + // Destination path + destPath := filepath.Join(contentDir, file.Filename) + + // Save the file + if err := c.SaveUploadedFile(file, destPath); err != nil { + a.logger.Error("Failed to save uploaded file", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "File uploaded successfully", "filename": file.Filename}) } // deleteContent deletes content for a bot func (a *API) deleteContent(c *gin.Context) { - // This will be implemented when we add content management - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) + pubkey := c.GetString("pubkey") + + botIDStr := c.Param("botId") + botID, err := strconv.ParseInt(botIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + filename := c.Param("filename") + if filename == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "No filename"}) + return + } + + // Ensure the bot belongs to user + _, err = a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Build the path + contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) + filePath := filepath.Join(contentDir, filename) + + // Remove the file + if err := os.Remove(filePath); err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + } else { + a.logger.Error("Failed to delete file", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"}) + } + return + } + + c.Status(http.StatusOK) +} + +// uploadToMediaServer handles uploading a file from the server to a media server +func (a *API) uploadToMediaServer(c *gin.Context) { + pubkey := c.GetString("pubkey") + botIDStr := c.Param("botId") + botID, err := strconv.ParseInt(botIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Look up the bot to get its details (including its pubkey and media config) + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Parse request body. + var req struct { + Filename string `json:"filename" binding:"required"` + Service string `json:"service" binding:"required"` + ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL. + } + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + // Build path to the file + contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) + filePath := filepath.Join(contentDir, req.Filename) + + // Verify the file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + // Create a new uploader instance that uses the bot's key. + var uploader scheduler.MediaUploader + if req.Service == "blossom" { + // Get the base Blossom server URL + serverURL := bot.MediaConfig.BlossomServerURL + if req.ServerURL != "" { + serverURL = req.ServerURL + } + + // Log the URL for debugging purposes + a.logger.Info("Creating Blossom uploader with server URL", + zap.String("original_url", serverURL)) + + // According to BUD-02 specification, the upload endpoint should be /upload + // Make sure the URL ends with /upload + if !strings.HasSuffix(serverURL, "/upload") { + serverURL = strings.TrimSuffix(serverURL, "/") + "/upload" + a.logger.Info("Adding /upload endpoint to URL", + zap.String("complete_url", serverURL)) + } + + uploader = blossom.NewUploader( + serverURL, + a.logger, + func(url, method string) (string, error) { + privkey, err := a.botService.GetPrivateKey(bot.Pubkey) + if err != nil { + return "", err + } + return blossom.CreateBlossomAuthHeader(url, method, privkey) + }, + ) + } else if req.Service == "nip94" { + serverURL := bot.MediaConfig.Nip94ServerURL + if req.ServerURL != "" { + serverURL = req.ServerURL + } + uploader = nip94.NewUploader( + serverURL, + "", // Download URL will be discovered + nil, // Supported types will be discovered + a.logger, + func(url, method string, payload []byte) (string, error) { + privkey, err := a.botService.GetPrivateKey(bot.Pubkey) + if err != nil { + return "", err + } + return nip94.CreateNIP98AuthHeader(url, method, payload, privkey) + }, + ) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown service"}) + return + } + + caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename)) + altText := caption + + mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText) + if err != nil { + a.logger.Error("Failed to upload to media server", + zap.String("file", filePath), + zap.String("service", req.Service), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload to media server: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "url": mediaURL, + "hash": mediaHash, + "service": req.Service, + }) +} + +// Updated createManualPost function in routes.go to properly process kind 20 posts +func (a *API) createManualPost(c *gin.Context) { + pubkey := c.GetString("pubkey") + botIDStr := c.Param("id") + botID, err := strconv.ParseInt(botIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Ensure the bot belongs to the user + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Parse request body + var req struct { + Kind int `json:"kind" binding:"required"` + Content string `json:"content" binding:"required"` + Title string `json:"title"` + Alt string `json:"alt"` // Added to support alt text for images + Hashtags []string `json:"hashtags"` + } + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + // For kind 20 (picture post), title is required + if req.Kind == 20 && req.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"}) + return + } + + // Process content to extract media URLs + var mediaURL string + var mediaType string + var mediaHash string + + // Check if content contains URLs + re := regexp.MustCompile(`https?://[^\s]+`) + matches := re.FindAllString(req.Content, -1) + + if len(matches) > 0 { + mediaURL = matches[0] // Use the first URL found + + // Try to determine media type - this is a placeholder + // In a real implementation, you might want to make an HTTP head request + // or infer from URL extension + mediaType = inferMediaTypeFromURL(mediaURL) + + // We don't have a hash yet, but we could calculate it if needed + // This would require downloading the file, which might be too heavy + mediaHash = "" + } + + // Create the appropriate event + var event *nostr.Event + var eventErr error + + switch req.Kind { + case 1: + // Standard text note + // Create tags + var tags []nostr.Tag + for _, tag := range req.Hashtags { + tags = append(tags, nostr.Tag{"t", tag}) + } + + event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags) + case 20: + // Picture post + event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( + bot.Pubkey, + req.Title, + req.Content, + mediaURL, + mediaType, + mediaHash, + req.Alt, // Use the alt text if provided + req.Hashtags, + ) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"}) + return + } + + if eventErr != nil { + a.logger.Error("Failed to create event", zap.Error(eventErr)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()}) + return + } + + // Configure relay manager + for _, relay := range bot.Relays { + if relay.Write { + if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { + a.logger.Warn("Failed to add relay", + zap.String("url", relay.URL), + zap.Error(err)) + } + } + } + + // Publish to relays + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + publishedRelays, err := a.botService.relayMgr.PublishEvent(ctx, event) + if err != nil { + a.logger.Error("Failed to publish event", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()}) + return + } + + // Return success + c.JSON(http.StatusOK, gin.H{ + "message": "Post published successfully", + "event_id": event.ID, + "relays": publishedRelays, + }) +} + +// Helper function to infer media type from URL +func inferMediaTypeFromURL(url string) string { + // Basic implementation - check file extension + lowerURL := strings.ToLower(url) + + // Image types + if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { + return "image/jpeg" + } else if strings.HasSuffix(lowerURL, ".png") { + return "image/png" + } else if strings.HasSuffix(lowerURL, ".gif") { + return "image/gif" + } else if strings.HasSuffix(lowerURL, ".webp") { + return "image/webp" + } else if strings.HasSuffix(lowerURL, ".svg") { + return "image/svg+xml" + } else if strings.HasSuffix(lowerURL, ".avif") { + return "image/avif" + } + + // Default to generic image type if we can't determine specifically + return "image/jpeg" } // getStats gets overall statistics @@ -582,4 +994,15 @@ func (a *API) getStats(c *gin.Context) { func (a *API) getBotStats(c *gin.Context) { // This will be implemented when we add statistics c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) +} + +// Helper function to extract hashtags from tags +func extractHashtags(tags []nostr.Tag) []string { + var hashtags []string + for _, tag := range tags { + if tag[0] == "t" && len(tag) > 1 { + hashtags = append(hashtags, tag[1]) + } + } + return hashtags } \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 29fdbe5..8b0a127 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,46 +11,48 @@ import ( // Config holds all configuration for the application type Config struct { - // General configuration - AppName string `mapstructure:"app_name"` - ServerPort int `mapstructure:"server_port"` - LogLevel string `mapstructure:"log_level"` - - // Bot configuration - Bot struct { - KeysFile string `mapstructure:"keys_file"` - ContentDir string `mapstructure:"content_dir"` - ArchiveDir string `mapstructure:"archive_dir"` - DefaultInterval int `mapstructure:"default_interval"` // in minutes - } `mapstructure:"bot"` - - // Database configuration - DB struct { - Path string `mapstructure:"path"` - } `mapstructure:"db"` - - // Media services configuration - Media struct { - DefaultService string `mapstructure:"default_service"` // "nip94" or "blossom" - - // NIP-94 configuration - NIP94 struct { - ServerURL string `mapstructure:"server_url"` - RequireAuth bool `mapstructure:"require_auth"` - } `mapstructure:"nip94"` - - // Blossom configuration - Blossom struct { - ServerURL string `mapstructure:"server_url"` - } `mapstructure:"blossom"` - } `mapstructure:"media"` - - // Default relays - Relays []struct { - URL string `mapstructure:"url"` - Read bool `mapstructure:"read"` - Write bool `mapstructure:"write"` - } `mapstructure:"relays"` + // General configuration + AppName string `mapstructure:"app_name"` + ServerPort int `mapstructure:"server_port"` + LogLevel string `mapstructure:"log_level"` + + // Bot configuration + Bot struct { + KeysFile string `mapstructure:"keys_file"` + ContentDir string `mapstructure:"content_dir"` + ArchiveDir string `mapstructure:"archive_dir"` + DefaultInterval int `mapstructure:"default_interval"` // in minutes + } `mapstructure:"bot"` + + // Database configuration + DB struct { + Path string `mapstructure:"path"` + } `mapstructure:"db"` + + // Media services configuration + Media struct { + DefaultService string `mapstructure:"default_service"` // "nip94" or "blossom" + + // NIP-94 configuration + NIP94 struct { + ServerURL string `mapstructure:"server_url"` + RequireAuth bool `mapstructure:"require_auth"` + } `mapstructure:"nip94"` + + // Blossom configuration + Blossom struct { + ServerURL string `mapstructure:"server_url"` + } `mapstructure:"blossom"` + } `mapstructure:"media"` + + // Default relays + Relays []struct { + URL string `mapstructure:"url"` + Read bool `mapstructure:"read"` + Write bool `mapstructure:"write"` + } `mapstructure:"relays"` + + AllowedNpub string `mapstructure:"allowed_npub"` // NEW } // LoadConfig loads the configuration from file or environment variables @@ -59,7 +61,7 @@ func LoadConfig(configPath string) (*Config, error) { // Set defaults v.SetDefault("app_name", "Nostr Poster") - v.SetDefault("server_port", 8080) + v.SetDefault("server_port", 8765) v.SetDefault("log_level", "info") v.SetDefault("bot.keys_file", "keys.json") @@ -74,11 +76,11 @@ func LoadConfig(configPath string) (*Config, error) { // Default relays v.SetDefault("relays", []map[string]interface{}{ - {"url": "wss://relay.damus.io", "read": true, "write": true}, - {"url": "wss://nostr.mutinywallet.com", "read": true, "write": true}, - {"url": "wss://relay.nostr.band", "read": true, "write": true}, + {"url": "wss://freelay.sovbit.host", "read": true, "write": true}, }) + v.SetDefault("allowed_npub", "") + // Setup config file search if configPath != "" { // Use config file from the flag @@ -157,6 +159,7 @@ func (c *Config) Save(configPath string) error { }, }, "relays": c.Relays, + "allowed_npub": c.AllowedNpub, }) if err != nil { diff --git a/internal/crypto/keys.go b/internal/crypto/keys.go index f0a5dba..733de51 100644 --- a/internal/crypto/keys.go +++ b/internal/crypto/keys.go @@ -3,7 +3,6 @@ package crypto import ( "crypto/rand" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -13,6 +12,7 @@ import ( "sync" "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" // Added import for proper NSEC decoding "golang.org/x/crypto/nacl/secretbox" ) @@ -104,6 +104,15 @@ func (ks *KeyStore) GenerateKey() (pubkey, privkey string, err error) { // AddKey imports an existing private key func (ks *KeyStore) AddKey(pubkey, privkey string) error { + // Check if the private key is in NSEC format and decode it if needed + if len(privkey) > 4 && privkey[:4] == "nsec" { + var err error + privkey, err = decodePrivateKey(privkey) + if err != nil { + return fmt.Errorf("failed to decode nsec key: %w", err) + } + } + // Validate the key pair derivedPub, err := nostr.GetPublicKey(privkey) if err != nil { @@ -196,6 +205,11 @@ func (ks *KeyStore) ListKeys() []string { return keys } +// DecodeNsecKey decodes an nsec key to hex format +func (ks *KeyStore) DecodeNsecKey(nsecKey string) (string, error) { + return decodePrivateKey(nsecKey) +} + // encryptKey encrypts a private key using the store's password func (ks *KeyStore) encryptKey(privkey string) (string, error) { // Generate a random nonce @@ -286,22 +300,28 @@ func (ks *KeyStore) ChangePassword(newPassword string) error { return ks.saveKeys() } -// decodePrivateKey decodes an nsec private key +// decodePrivateKey decodes an nsec private key using proper NIP-19 format func decodePrivateKey(nsecKey string) (string, error) { - if len(nsecKey) < 4 || nsecKey[:4] != "nsec" { - return "", fmt.Errorf("invalid nsec key") - } - - data, err := base64.StdEncoding.DecodeString(nsecKey[4:]) - if err != nil { - return "", fmt.Errorf("failed to decode base64: %w", err) - } - - // Remove the version byte and checksum - if len(data) < 2 { - return "", fmt.Errorf("invalid nsec data length") - } - - privkeyBytes := data[1 : len(data)-4] - return hex.EncodeToString(privkeyBytes), nil + if len(nsecKey) < 4 || nsecKey[:4] != "nsec" { + return "", fmt.Errorf("invalid nsec key") + } + + // Use the proper NIP-19 decoder + prefix, data, err := nip19.Decode(nsecKey) + if err != nil { + return "", fmt.Errorf("failed to decode nsec key: %w", err) + } + + // Verify that we got the right type of key + if prefix != "nsec" { + return "", fmt.Errorf("expected nsec prefix, got %s", prefix) + } + + // For nsec keys, data should be a hex string of the private key + hexKey, ok := data.(string) + if !ok { + return "", fmt.Errorf("invalid nsec data format") + } + + return hexKey, nil } \ No newline at end of file diff --git a/internal/media/upload/blossom/upload.go b/internal/media/upload/blossom/upload.go index f6c8535..62f3c63 100644 --- a/internal/media/upload/blossom/upload.go +++ b/internal/media/upload/blossom/upload.go @@ -2,48 +2,57 @@ package blossom import ( - "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" - "mime/multipart" "net/http" + "net/url" "os" "path/filepath" + "strings" "time" "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "git.sovbit.dev/Enki/nostr-poster/internal/utils" "go.uber.org/zap" ) // BlobDescriptor represents metadata about a blob stored with Blossom type BlobDescriptor struct { - URL string `json:"url,omitempty"` - SHA256 string `json:"sha256,omitempty"` - MimeType string `json:"mime_type,omitempty"` - Size int64 `json:"size,omitempty"` - Height int `json:"height,omitempty"` - Width int `json:"width,omitempty"` - Alt string `json:"alt,omitempty"` - Caption string `json:"caption,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` + Alt string `json:"alt,omitempty"` + Caption string `json:"caption,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } -// UploadResponse represents the response from a Blossom upload -type UploadResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Blob BlobDescriptor `json:"blob"` +// BlossomResponse represents the response from a Blossom upload +type BlossomResponse struct { + Status string `json:"status"` + Message string `json:"message"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Type string `json:"type"` + Uploaded int64 `json:"uploaded"` + BlurHash string `json:"blurhash"` + Dim string `json:"dim"` + PaymentRequest string `json:"payment_request"` + Visibility int `json:"visibility"` } // Uploader implements the media upload functionality for Blossom type Uploader struct { - serverURL string - logger *zap.Logger + serverURL string + logger *zap.Logger // Function to get a signed auth header (for Blossom authentication) getAuthHeader func(url, method string) (string, error) } @@ -63,242 +72,314 @@ func NewUploader( logger = zap.NewNop() } } - + return &Uploader{ - serverURL: serverURL, - logger: logger, + serverURL: serverURL, + logger: logger, getAuthHeader: getAuthHeader, } } -// UploadFile uploads a file to a Blossom server +// getEnhancedContentType tries multiple methods to get the most accurate content type +func getEnhancedContentType(filePath string) (string, error) { + // Get extension to help with MIME type detection + ext := strings.ToLower(filepath.Ext(filePath)) + + // Try standard content type detection first + contentType, err := utils.GetFileContentType(filePath) + if err != nil { + return "", fmt.Errorf("failed to determine content type: %w", err) + } + + // Override with more specific MIME types for certain file extensions + // These are common file extensions that sometimes get generic MIME types + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg", nil + case ".png": + return "image/png", nil + case ".gif": + return "image/gif", nil + case ".webp": + return "image/webp", nil + case ".svg": + return "image/svg+xml", nil + case ".mp4": + return "video/mp4", nil + case ".webm": + return "video/webm", nil + case ".mov": + return "video/quicktime", nil + case ".mp3": + return "audio/mpeg", nil + case ".wav": + return "audio/wav", nil + case ".pdf": + return "application/pdf", nil + } + + // If we got a generic type like "application/octet-stream", try to be more specific + if contentType == "application/octet-stream" || contentType == "text/plain" { + // Add more specific mappings for binary files that might be detected as octet-stream + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg", nil + case ".png": + return "image/png", nil + case ".mp4": + return "video/mp4", nil + } + } + + return contentType, nil +} + +// UploadFile uploads a file to a Blossom server using raw binary data in the request body func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) { - // Open the file - file, err := os.Open(filePath) - if err != nil { - return "", "", fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - // Get file info - fileInfo, err := file.Stat() - if err != nil { - return "", "", fmt.Errorf("failed to get file info: %w", err) - } - - // Calculate file hash - hasher := sha256.New() - if _, err := io.Copy(hasher, file); err != nil { - return "", "", fmt.Errorf("failed to calculate file hash: %w", err) - } - - // Reset file pointer - if _, err := file.Seek(0, 0); err != nil { - return "", "", fmt.Errorf("failed to reset file: %w", err) - } - - // Get hash as hex - fileHash := hex.EncodeToString(hasher.Sum(nil)) - - // Get content type - contentType, err := utils.GetFileContentType(filePath) - if err != nil { - return "", "", fmt.Errorf("failed to determine content type: %w", err) - } - - // Create a buffer for the multipart form - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - - // Add the file - part, err := writer.CreateFormFile("file", filepath.Base(filePath)) - if err != nil { - return "", "", fmt.Errorf("failed to create form file: %w", err) - } - - if _, err := io.Copy(part, file); err != nil { - return "", "", fmt.Errorf("failed to copy file to form: %w", err) - } - - // Add caption if provided - if caption != "" { - if err := writer.WriteField("caption", caption); err != nil { - return "", "", fmt.Errorf("failed to add caption: %w", err) - } - } - - // Add alt text if provided - if altText != "" { - if err := writer.WriteField("alt", altText); err != nil { - return "", "", fmt.Errorf("failed to add alt text: %w", err) - } - } - - // Add file size information - if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil { - return "", "", fmt.Errorf("failed to add file size: %w", err) - } - - // Add content type - if err := writer.WriteField("content_type", contentType); err != nil { - return "", "", fmt.Errorf("failed to add content type: %w", err) - } - - // Add file hash (for integrity verification) - if err := writer.WriteField("hash", fileHash); err != nil { - return "", "", fmt.Errorf("failed to add file hash: %w", err) - } - - // Close the writer - if err := writer.Close(); err != nil { - return "", "", fmt.Errorf("failed to close multipart writer: %w", err) - } - - // Create the upload URL - uploadURL := fmt.Sprintf("%s/upload", u.serverURL) - - // Create the request - req, err := http.NewRequest("PUT", uploadURL, &requestBody) - if err != nil { - return "", "", fmt.Errorf("failed to create request: %w", err) - } - - // Set content type - req.Header.Set("Content-Type", writer.FormDataContentType()) - - // Add authorization header if available - if u.getAuthHeader != nil { - authHeader, err := u.getAuthHeader(uploadURL, "PUT") - if err != nil { - return "", "", fmt.Errorf("failed to create auth header: %w", err) - } - - req.Header.Set("Authorization", authHeader) - } - - // Create HTTP client with timeout - client := &http.Client{ - Timeout: 2 * time.Minute, - } - - // Send the request - resp, err := client.Do(req) - if err != nil { - return "", "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Check response status - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - bodyBytes, _ := io.ReadAll(resp.Body) - return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes)) - } - - // Parse response - var uploadResp UploadResponse - if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { - return "", "", fmt.Errorf("failed to parse response: %w", err) - } - - // Check for success - if uploadResp.Status != "success" { - return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message) - } - - // Get the media URL - mediaURL := fmt.Sprintf("%s/%s", u.serverURL, uploadResp.Blob.SHA256) - - // Verify the returned hash matches our calculated hash - if uploadResp.Blob.SHA256 != fileHash { - u.logger.Warn("Server returned different hash than calculated", - zap.String("calculated", fileHash), - zap.String("returned", uploadResp.Blob.SHA256)) - } - - return mediaURL, uploadResp.Blob.SHA256, nil + // Log information about the upload + u.logger.Info("Uploading file to Blossom server", + zap.String("filePath", filePath), + zap.String("serverURL", u.serverURL)) + + // Open the file + file, err := os.Open(filePath) + if err != nil { + return "", "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Get file info + fileInfo, err := file.Stat() + if err != nil { + return "", "", fmt.Errorf("failed to get file info: %w", err) + } + fileSize := fileInfo.Size() + + // Calculate file hash before reading the file for the request body + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", "", fmt.Errorf("failed to calculate file hash: %w", err) + } + fileHash := hex.EncodeToString(hasher.Sum(nil)) + + // Reset file pointer to the beginning + if _, err := file.Seek(0, 0); err != nil { + return "", "", fmt.Errorf("failed to reset file: %w", err) + } + + // Get enhanced content type + contentType, err := getEnhancedContentType(filePath) + if err != nil { + return "", "", fmt.Errorf("failed to determine content type: %w", err) + } + + u.logger.Info("File details", + zap.String("filename", filepath.Base(filePath)), + zap.String("contentType", contentType), + zap.Int64("size", fileSize), + zap.String("hash", fileHash)) + + // Create the request with the file as the raw body + // This follows BUD-02 which states the endpoint must accept binary data in the body + req, err := http.NewRequest("PUT", u.serverURL, file) + if err != nil { + return "", "", fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", contentType) + req.Header.Set("Content-Length", fmt.Sprintf("%d", fileSize)) + + // Add additional headers that might help the server + req.Header.Set("X-Content-Type", contentType) + req.Header.Set("X-File-Name", filepath.Base(filePath)) + req.Header.Set("X-File-Size", fmt.Sprintf("%d", fileSize)) + req.Header.Set("X-File-Hash", fileHash) + + // If we have caption or alt text, add them as headers + if caption != "" { + req.Header.Set("X-Caption", caption) + } + if altText != "" { + req.Header.Set("X-Alt", altText) + } + + // Add authorization header if available + if u.getAuthHeader != nil { + authHeader, err := u.getAuthHeader(u.serverURL, "PUT") + if err != nil { + return "", "", fmt.Errorf("failed to create auth header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 2 * time.Minute, + } + + // Send the request + u.logger.Info("Sending raw binary request to Blossom server", + zap.String("contentType", contentType), + zap.Int64("contentLength", fileSize)) + + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read the response body for logging + bodyBytes, _ := io.ReadAll(resp.Body) + bodyStr := string(bodyBytes) + + // Log response details + u.logger.Info("Received response from server", + zap.Int("statusCode", resp.StatusCode), + zap.String("body", bodyStr)) + + // Check response status - accept 200, 201, and 202 as success + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusCreated && + resp.StatusCode != http.StatusAccepted { + return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr) + } + + // Parse response + var blossomResp BlossomResponse + if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil { + return "", "", fmt.Errorf("failed to parse response: %w", err) + } + + // Check for success + if blossomResp.Status != "success" { + return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message) + } + + // Log the successful response + u.logger.Info("Upload successful", + zap.String("url", blossomResp.URL), + zap.String("hash", blossomResp.SHA256), + zap.String("dimensions", blossomResp.Dim), + zap.Int64("size", blossomResp.Size)) + + // Use the URL directly from the response + return blossomResp.URL, blossomResp.SHA256, nil } // DeleteFile deletes a file from the Blossom server func (u *Uploader) DeleteFile(fileHash string) error { // Create the delete URL deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash) - + // Create the request req, err := http.NewRequest("DELETE", deleteURL, nil) if err != nil { return fmt.Errorf("failed to create delete request: %w", err) } - + // Add authorization header if available if u.getAuthHeader != nil { authHeader, err := u.getAuthHeader(deleteURL, "DELETE") if err != nil { return fmt.Errorf("failed to create auth header: %w", err) } - + req.Header.Set("Authorization", authHeader) } - + // Send the request client := &http.Client{ Timeout: 30 * time.Second, } - + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send delete request: %w", err) } defer resp.Body.Close() - + // Check response status if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes)) } - + return nil } // CreateBlossomAuthHeader creates a Blossom authentication header -// BUD-01 requires a kind 24242 authorization event -func CreateBlossomAuthHeader(url, method string, privkey string) (string, error) { - // Create the event - tags := []nostr.Tag{ - {"u", url}, - {"method", method}, +func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, error) { + // Parse the URL to extract the path for the endpoint + parsed, err := url.Parse(fullURL) + var endpoint string + if err == nil && parsed.Path != "" { + // Use the full path as the endpoint + endpoint = parsed.Path + // Remove leading slash if present + endpoint = strings.TrimPrefix(endpoint, "/") + } else { + // Fallback to the full URL if parsing fails + endpoint = fullURL + } + + // Set an expiration 5 minutes in the future + expiration := time.Now().Add(5 * time.Minute).Unix() + + // Set the operation type for BUD-02 compliance + operation := "upload" + if method == "DELETE" { + operation = "delete" + } else if strings.Contains(endpoint, "list") { + operation = "list" } + // Create the tags array with required Blossom tags + tags := []nostr.Tag{ + {"t", operation}, // Required tag for Blossom auth + {"u", endpoint}, // URL endpoint + {"method", method}, + {"expiration", fmt.Sprintf("%d", expiration)}, + } + // Create the auth event authEvent := nostr.Event{ - Kind: 24242, // Blossom Authorization event + Kind: 24242, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, - Content: "", // Empty content for auth events + Content: "", } - - // Get the public key + + // Get the public key from the private key pubkey, err := nostr.GetPublicKey(privkey) if err != nil { return "", fmt.Errorf("failed to get public key: %w", err) } - authEvent.PubKey = pubkey - + // Sign the event - err = authEvent.Sign(privkey) - if err != nil { + if err := authEvent.Sign(privkey); err != nil { return "", fmt.Errorf("failed to sign auth event: %w", err) } - + // Serialize the event eventJSON, err := json.Marshal(authEvent) if err != nil { return "", fmt.Errorf("failed to serialize auth event: %w", err) } - + // Encode as base64 encodedEvent := base64.StdEncoding.EncodeToString(eventJSON) - + // Return the authorization header return "Nostr " + encodedEvent, nil +} + +// WithCustomURL creates a new uploader instance with the specified custom URL. +func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { + return &Uploader{ + serverURL: customURL, + logger: u.logger, + getAuthHeader: u.getAuthHeader, + } } \ No newline at end of file diff --git a/internal/media/upload/nip94/upload.go b/internal/media/upload/nip94/upload.go index 68b44f0..f765281 100644 --- a/internal/media/upload/nip94/upload.go +++ b/internal/media/upload/nip94/upload.go @@ -18,6 +18,7 @@ import ( "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/utils" + "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "go.uber.org/zap" ) @@ -470,4 +471,16 @@ func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) ( // Return the authorization header return "Nostr " + encodedEvent, nil +} + +// WithCustomURL creates a new uploader instance with the specified custom URL +func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { + // Create a new uploader with the same configuration but a different URL + return &Uploader{ + serverURL: customURL, + downloadURL: u.downloadURL, + supportedTypes: u.supportedTypes, + logger: u.logger, + getAuthHeader: u.getAuthHeader, + } } \ No newline at end of file diff --git a/internal/models/bot.go b/internal/models/bot.go index e453124..4bcd6a9 100644 --- a/internal/models/bot.go +++ b/internal/models/bot.go @@ -9,7 +9,7 @@ import ( type Bot struct { ID int64 `db:"id" json:"id"` Pubkey string `db:"pubkey" json:"pubkey"` - EncryptedPrivkey string `db:"encrypted_privkey" json:"-"` // Encrypted, never sent to client + EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"` Name string `db:"name" json:"name"` DisplayName string `db:"display_name" json:"display_name"` Bio string `db:"bio" json:"bio"` diff --git a/internal/nostr/events/events.go b/internal/nostr/events/events.go index 8f85a54..c5fabbb 100644 --- a/internal/nostr/events/events.go +++ b/internal/nostr/events/events.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + "regexp" + "strings" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/models" @@ -136,7 +138,7 @@ func (em *EventManager) CreateAndSignMediaEvent( return &ev, nil } -// CreateAndSignPictureEvent creates and signs a kind 20 picture event (NIP-68) +// Updated CreateAndSignPictureEvent to properly format events according to NIP-68 func (em *EventManager) CreateAndSignPictureEvent( pubkey string, title string, @@ -147,31 +149,73 @@ func (em *EventManager) CreateAndSignPictureEvent( altText string, hashtags []string, ) (*nostr.Event, error) { - // Create the imeta tag for the media - imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} + // For picture events (kind 20), we need to follow NIP-68 format - // Add hash if available - if mediaHash != "" { - imeta = append(imeta, "x "+mediaHash) - } - - // Add alt text if available - if altText != "" { - imeta = append(imeta, "alt "+altText) - } - - // Create tags + // Start with the required title tag tags := []nostr.Tag{ {"title", title}, - imeta, } - // Add media type tag - tags = append(tags, nostr.Tag{"m", mediaType}) + // Process media URLs from the description + var contentWithoutURLs string - // Add media hash tag - if mediaHash != "" { - tags = append(tags, nostr.Tag{"x", mediaHash}) + // If no explicit mediaURL is provided, try to extract from description + if mediaURL == "" { + // Simple regex to extract URLs + re := regexp.MustCompile(`https?://[^\s]+`) + matches := re.FindAllString(description, -1) + + if len(matches) > 0 { + mediaURL = matches[0] + + // Remove the URL from the description for cleaner content + contentWithoutURLs = re.ReplaceAllString(description, "") + contentWithoutURLs = strings.TrimSpace(contentWithoutURLs) + } else { + // If no URL in description either, we'll just use the description as is + contentWithoutURLs = description + } + } else { + // We have an explicit mediaURL, make sure it's not in the description + contentWithoutURLs = strings.ReplaceAll(description, mediaURL, "") + contentWithoutURLs = strings.TrimSpace(contentWithoutURLs) + } + + // Create the imeta tag according to NIP-92 format + if mediaURL != "" { + // imeta tag needs to be an array of strings + imetaTag := []string{"imeta"} + + // Add URL (must be space-delimited key/value) + imetaTag = append(imetaTag, "url "+mediaURL) + + // Add media type (must be space-delimited key/value) + if mediaType != "" { + imetaTag = append(imetaTag, "m "+mediaType) + } + + // Add hash if available (must be space-delimited key/value) + if mediaHash != "" { + imetaTag = append(imetaTag, "x "+mediaHash) + } + + // Add alt text if available (must be space-delimited key/value) + if altText != "" { + imetaTag = append(imetaTag, "alt "+altText) + } + + // Add to tags - nostr.Tag is just a []string so this works + tags = append(tags, imetaTag) + + // Add the media type as a separate 'm' tag for filtering (required by NIP-68) + if mediaType != "" { + tags = append(tags, nostr.Tag{"m", mediaType}) + } + + // Add the hash as a separate 'x' tag for querying (required by NIP-68) + if mediaHash != "" { + tags = append(tags, nostr.Tag{"x", mediaHash}) + } } // Add hashtags @@ -184,12 +228,12 @@ func (em *EventManager) CreateAndSignPictureEvent( Kind: 20, // NIP-68 Picture Event CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, - Content: description, + Content: contentWithoutURLs, // Use the cleaned content without URLs } // Sign the event if err := em.SignEvent(&ev, pubkey); err != nil { - return nil, err + return nil, fmt.Errorf("failed to sign picture event: %w", err) } return &ev, nil diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index ca63aaa..c4e6c8a 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "encoding/json" + "github.com/robfig/cron/v3" "git.sovbit.dev/Enki/nostr-poster/internal/db" @@ -18,8 +19,9 @@ import ( // MediaUploader defines the interface for uploading media type MediaUploader interface { - UploadFile(filePath string, caption string, altText string) (string, string, error) - DeleteFile(fileHash string) error + UploadFile(filePath string, caption string, altText string) (string, string, error) + DeleteFile(fileHash string) error + WithCustomURL(customURL string) MediaUploader } // PostPublisher defines the interface for publishing posts @@ -334,6 +336,19 @@ func (s *Scheduler) UpdateBotSchedule(bot *models.Bot) error { return s.ScheduleBot(bot) } +// GetNIP94Uploader returns the NIP-94 uploader +func (s *Scheduler) GetNIP94Uploader() MediaUploader { + return s.nip94Uploader +} + +// GetBlossomUploader returns the Blossom uploader +func (s *Scheduler) GetBlossomUploader() MediaUploader { + return s.blossomUploader +} + +func (s *Scheduler) GetContentDir() string { + return s.contentDir +} // RunNow triggers an immediate post for a bot func (s *Scheduler) RunNow(botID int64) error { // Load the bot with its configurations @@ -380,4 +395,4 @@ func (s *Scheduler) RunNow(botID int64) error { entry.Job.Run() return nil -} \ No newline at end of file +} diff --git a/internal/utils/files.go b/internal/utils/files.go index 941931b..40de359 100644 --- a/internal/utils/files.go +++ b/internal/utils/files.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "time" + "bytes" ) func init() { @@ -142,29 +143,71 @@ func CalculateFileHash(filePath string) (string, error) { // GetFileContentType tries to determine the content type of a file func GetFileContentType(filePath string) (string, error) { - // Open the file - file, err := os.Open(filePath) - if err != nil { - return "", err - } - defer file.Close() + // Open the file + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() - // Read the first 512 bytes to determine the content type - buffer := make([]byte, 512) - _, err = file.Read(buffer) - if err != nil && err != io.EOF { - return "", err - } + // Read the first 512 bytes to determine the content type + buffer := make([]byte, 512) + bytesRead, err := file.Read(buffer) + if err != nil && err != io.EOF { + return "", err + } + + // If we read less than 512 bytes, resize the buffer + if bytesRead < 512 { + buffer = buffer[:bytesRead] + } - // Reset the file pointer - _, err = file.Seek(0, 0) - if err != nil { - return "", err - } + // Reset the file pointer + _, err = file.Seek(0, 0) + if err != nil { + return "", err + } - // Detect the content type - contentType := http.DetectContentType(buffer) - return contentType, nil + // Detect the content type + contentType := http.DetectContentType(buffer) + + // If the detection gave us a generic type, try to be more specific using the extension + if contentType == "application/octet-stream" || contentType == "text/plain" { + ext := strings.ToLower(filepath.Ext(filePath)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg", nil + case ".png": + return "image/png", nil + case ".gif": + return "image/gif", nil + case ".webp": + return "image/webp", nil + case ".mp4": + return "video/mp4", nil + case ".mov": + return "video/quicktime", nil + case ".webm": + return "video/webm", nil + case ".mp3": + return "audio/mpeg", nil + case ".wav": + return "audio/wav", nil + case ".pdf": + return "application/pdf", nil + } + } + + // Check for WebP signature, which is sometimes not detected correctly + if contentType == "application/octet-stream" || contentType == "image/webp" { + if bytesRead > 12 { + if bytes.HasPrefix(buffer, []byte("RIFF")) && bytes.Contains(buffer[8:12], []byte("WEBP")) { + return "image/webp", nil + } + } + } + + return contentType, nil } // GetSupportedImageExtensions returns a list of supported image extensions diff --git a/keys.json b/keys.json index 89ed294..3c81e17 100644 --- a/keys.json +++ b/keys.json @@ -1,5 +1,20 @@ { + "05480a388b72ca9c3a3b449f2d34d5088c243fcdf67f1dd836fcd0c0fa9fb369": "30e9655fab1f7898c30544770e148adb45c49af995bf0556c48f80526b94054a96529032c420f065232952e9e5ee35ecd2e4b9dfd3d769d284320445a9b77b1459129a138e023b7d01a1896f374f8ad9f0394be2be5a6a0150404945397c62ef5b86bf02ee84deba", "10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4", + "1b9ee4ecb3587bc6091d9c284bca486d4d42d9bbcb52a8c5c017b7ab891d5a1e": "b2fed85f916572965c7e0b096b4c35ca1e495f8d176fcea1e092a50b28b9d187dac7247110828f777c51dc6ab375a604f9f42913e46867097511cd6374c8bc089f536ebc8d2d14245ed80c95f5a0c3a7817e4982d1989a9164dba39b90b86ed1e823d404e46036fb", + "220b94f311cb0d2ae9550e0c3e9a201971da9a81fc95f806727ee1224860ffca": "4317b62cfd3693b9352051a64fe1f9af25d0d9f95524c984f6521830c8b5f99522abcc64afe0c7898da96f5d9d087e9606426c1dc99b77eacee68c99953fc1d5c4b9d12d4d840c727e218ef1f7a2b50402240fca603a37a9404bf4ac98ef9f8ec144ad4e25b3c42a", "2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504", - "67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a" + "407057c4bb1e2bdd0ee73720f453c61cd55b94abd73901de30e34dc15a605d14": "3133ab7d2ddc20730b7cc7246616c1a2d2dcf2219a93f98c7085113748f6b8467ea1b87e5e28ee96cc4cf6963acdafac894cc17574cf75300e041d973f4be4a2e50d94b7ba13e92f57e04e1d52ec5a03dd896ac809735722fac4954d47cd3b5fa8329c567c620195", + "5159fa4eedc1e31ba1fdcb0abcc9a5f36bb671eeeb36a2e1c1f9d7cb03374870": "555b7d94d8f59e8d5347677219027edcd04d1a51ffe30b1ccc50c2a39915662b9c66ce52d3ddc963a5a3ddc113a1e276ac2e6b51d884ef1a5e0bb61fc3f11718655174a35475c06c72b49ff7c2428f3fd30fc25ee9a77c33aba667cc26c985930b26ba9f70b5f1d6", + "67e92e467cf0e2d8f2075a0e7707f9ee56d00e4dc4851d9ba66ca9a9f5ad188a": "2d2ad8636edf99f55c90c93808f1609c16ad7a29f6810a5c733fc6590d2c37e296156cea9c62ee6c3d83b2619325c483c4b1fca6e05ec5fb2086a093b657a1134a4cfbc0a9e88e1651c2921e4395873984a9ecfb270fb2d80cc0abe81d34011d9a698a2ef010b296", + "67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a", + "78cf397476705026246ce615224cd1bf18794700548b057c7a90e78786df39c7": "f5e47fac464813aef68bd9661d7210bed4d0b54fe2d109231bab1a83c87eb488db60edd52b66e709ad9c3b0dd53560274bed9b58419f47dd84049160dfe09cf6b64482060a495de6ef428539eb644b7ee1083c1129f7123b3568379b60386807c08d04c54aa9a0f4", + "86f70be7eb60dc350e3de57e056f3af87276774d433c1e77ed24a8b1eeb7d715": "ab8af8ffc3f5e5137c37ca69bb8ae9671ed78bab330921d52176fca3b008a703158efc58c85fe13bde90018e5bf44e7d7725e926acf49d6049bf27ecf403671db670d10b1acc6bc576aa8115aabbd269f239cb9926068fc91ca6d877311b518d395240562441bfe7", + "91270f43597088394c9ca64411f181bbebd1a0d0b7f2d17e45c27ba1bac80a1d": "326215854b5c8a71c2080cdcc3a4ca9f29ef687c14e6f9b02765d90c5b12f0a9d0d41425f5d423bc37a4d8e6972b3c4f138cedb973ba044e5312d45f7304460f48326d695a99197814bbc9bac843beab4f32380490f70d103097648fe7d240592ea73427b6c3fbaa", + "a98be83e7246db94e58fe79d401e53c5ab277a21d2e22014315c731a81b80192": "5d5a6935055787936bc4c8196bfeb097a0e63ce6e87136f87370333bb13ec5eb46acd2362f406155fe548b4d2a4b9ff33973a2810cbaab9210637fced984a08a49a496b686adae36dc6c9428816782f0e19215bfe1b4c60180fcb96ffbcc8df8b76f50b9fe7cfb63", + "b3c711af3bf7a320e2ec55abe756b19cac49b95f42bb53ea678b0284c8004947": "4b7c7210adb026727773bd55af9e61dd0f25d54e816a0d9fd337f8300c2f1904409cb600e5f6d81980daea5a6e0415630668eb5d50a07d5533b5282eb740caa3cca923c00b0c61bc8fc62eaf66e7a34d975198ac36bb086608d71d947bfc3253be86eb9ad89c5276", + "bcd23e6a6aaed2208d424262d76618be4d5ab964638c64c56a72de40dae39d93": "1674f6d38326ee16d6f1cc1c2c168d0240f526b5043b1cf9fd2b8639c03fb3202c08270d05d5511f58302f72a207b1902c81084ce0078ad63cd6b6bad3d7198f362e8e1823091d7cb3ce569d9b34b1059e375d2fc1c0cedc2309f3b26164055eef84d0b1a8d4ac00", + "cb708142454b92baf8653df8dee9bd78a90b2f3a4b8a7f42e7395c1432d717cd": "1eaa8941ffe3f200b12f7e784513b3ccefcc66f9af30ba3f167141e9c63e381867e3c7236f81c2fbe260eb9f6c3bf45c7b9a4f1f39e36b41b8058f91bfe4ae491b91a7c0deac4f1cd1db75f490b815cce2bfdd90ac23208865a8b792808f2700e553700d568d61ef", + "e8c792004a815e03d201986c19bf7bb88a4e5d791b6fa21676ad5891c641f157": "29dcbcedb00ca30acd96ac0f69cc29e08e465f1bb554d1e3b558d118ea1aefbece3253ce09f46a33d4837d7d726a55d02b4a3f5eefb3d17d8651046f0fc0ff207fd3fa16186a201d9433bc4106748660853bb0eab4a8bf0bd09695590c2e54005ce71b3a7f14036c", + "ff68c63849bd293a5c55598c739911bcda6fa303465da9cd720ff0f45b557b17": "91dc4ece5bb67f798ef5a671f80201954743a4079282074068cc388c7e2de2207ad63919e6c8b29052c7d8e3a4ba7d355b213824d5d9729dcb56ecdd32deb1a0efbf4c59765ac282248016a98e03f69f1caff51b1a06071988ecc48d195fccfc8d2c60137581ac70" } \ No newline at end of file diff --git a/web/assets/css/style.css b/web/assets/css/style.css index 7e37827..d3cd539 100644 --- a/web/assets/css/style.css +++ b/web/assets/css/style.css @@ -1,4 +1,8 @@ -:root { +/* ============================================= + VARIABLES + ============================================= */ + :root { + /* Color Palette */ --primary-black: #121212; --secondary-black: #1e1e1e; --primary-gray: #2d2d2d; @@ -8,8 +12,13 @@ --secondary-purple: #7B68EE; /* Medium Slate Blue */ --dark-purple: #6A5ACD; /* Slate Blue */ --text-color: #e0e0e0; + --success-color: #4CAF50; + --border-color: #444; } +/* ============================================= + BASE ELEMENTS + ============================================= */ body { background: linear-gradient(135deg, var(--primary-black) 0%, var(--secondary-black) 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; @@ -17,38 +26,9 @@ body { min-height: 100vh; } -.card { - margin-bottom: 20px; - background-color: var(--primary-gray); - border: none; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); - color: var(--text-color); -} - -.card-title { - color: var(--primary-purple); -} - -.card-subtitle { - color: var(--light-gray) !important; -} - -.bot-card { - transition: all 0.3s ease-in-out; - border-left: 3px solid var(--primary-purple); -} - -.bot-card:hover { - transform: translateY(-5px); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); - border-left: 3px solid var(--secondary-purple); -} - -.card-footer { - background-color: var(--secondary-gray) !important; - border-top: 1px solid #444; -} - +/* ============================================= + NAVIGATION + ============================================= */ .navbar { background: linear-gradient(90deg, var(--primary-black) 0%, var(--dark-purple) 100%) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); @@ -69,19 +49,59 @@ body { color: var(--primary-purple) !important; } +/* ============================================= + CARD ELEMENTS + ============================================= */ +.card { + margin-bottom: 20px; + background-color: var(--primary-gray); + border: none; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + color: var(--text-color); + height: 100%; +} + +.card-title { + color: var(--primary-purple); +} + +.card-subtitle { + color: var(--light-gray) !important; +} + +.card-footer { + background-color: var(--secondary-gray) !important; + border-top: 1px solid var(--border-color); +} + +.bot-card { + transition: all 0.3s ease-in-out; + border-left: 3px solid var(--primary-purple); +} + +.bot-card:hover { + transform: translateY(-5px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); + border-left: 3px solid var(--secondary-purple); +} + +/* ============================================= + BUTTONS + ============================================= */ .btn-primary { background-color: var(--primary-purple) !important; border-color: var(--primary-purple) !important; } -.btn-primary:hover, .btn-primary:focus { +.btn-primary:hover, +.btn-primary:focus { background-color: var(--secondary-purple) !important; border-color: var(--secondary-purple) !important; } .btn-success { - background-color: #4CAF50 !important; - border-color: #4CAF50 !important; + background-color: var(--success-color) !important; + border-color: var(--success-color) !important; } .btn-secondary { @@ -89,6 +109,59 @@ body { border-color: var(--secondary-gray) !important; } +/* ============================================= + FORMS + ============================================= */ +.form-control, +.form-select { + background-color: var(--primary-black); + border: 1px solid var(--border-color); + color: var(--text-color); +} + +.form-control:focus, +.form-select:focus { + background-color: var(--primary-black); + border-color: var(--primary-purple); + box-shadow: 0 0 0 0.25rem rgba(147, 112, 219, 0.25); + color: var(--text-color); +} + +.form-text { + color: var(--light-gray); +} + +.form-check-input:checked { + background-color: var(--primary-purple); + border-color: var(--primary-purple); +} + +/* Manual post content area */ +#manualPostContent { + resize: vertical; + min-height: 120px; +} + +/* ============================================= + MODAL + ============================================= */ +.modal-content { + background-color: var(--secondary-gray); + color: var(--text-color); + border: 1px solid var(--border-color); +} + +.modal-header { + border-bottom: 1px solid var(--border-color); +} + +.modal-footer { + border-top: 1px solid var(--border-color); +} + +/* ============================================= + UTILITIES + ============================================= */ .truncate { white-space: nowrap; overflow: hidden; @@ -105,43 +178,9 @@ body { display: inline-block; } -.modal-content { - background-color: var(--secondary-gray); - color: var(--text-color); - border: 1px solid #444; -} - -.modal-header { - border-bottom: 1px solid #444; -} - -.modal-footer { - border-top: 1px solid #444; -} - -.form-control, .form-select { - background-color: var(--primary-black); - border: 1px solid #444; - color: var(--text-color); -} - -.form-control:focus, .form-select:focus { - background-color: var(--primary-black); - border-color: var(--primary-purple); - box-shadow: 0 0 0 0.25rem rgba(147, 112, 219, 0.25); - color: var(--text-color); -} - -.form-text { - color: var(--light-gray); -} - -.form-check-input:checked { - background-color: var(--primary-purple); - border-color: var(--primary-purple); -} - -/* Custom scrollbar */ +/* ============================================= + CUSTOM SCROLLBAR + ============================================= */ ::-webkit-scrollbar { width: 8px; } @@ -157,4 +196,99 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--secondary-purple); +} + +/* ============================================= + CONTENT MANAGEMENT PAGE + ============================================= */ +/* Bot selection container */ +.bot-selection-container { + background-color: var(--primary-gray); + border-radius: 12px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +/* Image preview container - FIXED VERSION */ +.upload-preview { + max-width: 100%; + max-height: 200px; + margin-top: 10px; + border-radius: 4px; + object-fit: contain; + border: 1px solid var(--secondary-gray); + background-color: var(--primary-black); + display: block; + margin-left: auto; + margin-right: auto; +} + +#uploadPreviewContainer, +#mediaPreviewContainer { + max-width: 100%; + max-height: 250px; + overflow: hidden; + margin-top: 10px; + margin-bottom: 10px; + border-radius: 4px; + background-color: var(--primary-black); + text-align: center; + padding: 5px; +} + +/* Improve the media link containers */ +.media-link { + word-break: break-all; + font-size: 0.9rem; + padding: 8px; + border-radius: 4px; + background-color: var(--secondary-black); + margin-top: 5px; +} + +/* Media upload container */ +.media-upload-container { + background-color: var(--primary-gray); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; +} + +/* Server URL input improvements */ +#primaryServerURL, +#fallbackServerURL { + margin-top: 5px; + font-size: 0.9rem; + background-color: var(--primary-black); + border-color: var(--secondary-gray); +} + +/* Better looking file list */ +.list-group-item { + background-color: var(--secondary-gray); + border-color: var(--primary-black); + transition: all 0.2s ease; +} + +.list-group-item:hover { + background-color: var(--primary-gray); +} + +/* ============================================= + RESPONSIVE STYLES + ============================================= */ +@media (max-width: 768px) { + .card { + margin-bottom: 20px; + } + + .bot-selection-container { + padding: 15px; + margin-bottom: 20px; + } + + .upload-preview { + max-height: 150px; + } } \ No newline at end of file diff --git a/web/assets/js/content.js b/web/assets/js/content.js new file mode 100644 index 0000000..7a93215 --- /dev/null +++ b/web/assets/js/content.js @@ -0,0 +1,513 @@ +// content.js - JavaScript for the content management page +document.addEventListener('DOMContentLoaded', () => { + // =================================================== + // Global Variables + // =================================================== + let currentBotId = null; + + // Media server configuration (global for all bots) + let globalMediaConfig = { + primaryService: 'nip94', + primaryURL: '', + fallbackService: 'none', + fallbackURL: '' + }; + + // =================================================== + // Element References + // =================================================== + const botSelect = document.getElementById('botSelect'); + const loadContentBtn = document.getElementById('loadContentBtn'); + const uploadFileInput = document.getElementById('uploadFileInput'); + const uploadPreviewContainer = document.getElementById('uploadPreviewContainer'); + const uploadPreview = document.getElementById('uploadPreview'); + const uploadButton = document.getElementById('uploadButton'); + const contentContainer = document.getElementById('contentContainer'); + + // Post creation elements + const postKindRadios = document.querySelectorAll('input[name="postKind"]'); + const titleField = document.getElementById('titleField'); + const postTitle = document.getElementById('postTitle'); + const manualPostContent = document.getElementById('manualPostContent'); + const postHashtags = document.getElementById('postHashtags'); + const postMediaInput = document.getElementById('postMediaInput'); + const quickUploadBtn = document.getElementById('quickUploadBtn'); + const mediaPreviewContainer = document.getElementById('mediaPreviewContainer'); + const mediaPreview = document.getElementById('mediaPreview'); + const mediaLinkContainer = document.getElementById('mediaLinkContainer'); + const submitPostBtn = document.getElementById('submitPostBtn'); + + // Media server settings + const primaryServer = document.getElementById('primaryServer'); + const fallbackServer = document.getElementById('fallbackServer'); + const saveMediaSettingsBtn = document.getElementById('saveMediaSettingsBtn'); + const primaryServerURL = document.getElementById('primaryServerURL'); + const fallbackServerURL = document.getElementById('fallbackServerURL'); + + // =================================================== + // Event Listeners + // =================================================== + + // Check auth and load bots first + checkAuth(); + + // Load bot choices after a short delay to ensure auth is checked + setTimeout(() => { + const token = localStorage.getItem('authToken'); + if (token) { + loadBotChoices(); + } + }, 500); + + // Button click handlers + if (loadContentBtn) loadContentBtn.addEventListener('click', handleLoadContent); + if (uploadButton) uploadButton.addEventListener('click', handleFileUpload); + if (quickUploadBtn) quickUploadBtn.addEventListener('click', handleQuickUpload); + if (submitPostBtn) submitPostBtn.addEventListener('click', handleSubmitPost); + if (saveMediaSettingsBtn) { + saveMediaSettingsBtn.addEventListener('click', handleSaveMediaSettings); + } + + // File input change handlers + if (uploadFileInput) { + uploadFileInput.addEventListener('change', (e) => { + previewFile(e.target.files[0], uploadPreview, uploadPreviewContainer); + uploadButton.disabled = !e.target.files.length; + }); + } + + if (postMediaInput) { + postMediaInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) { + mediaPreviewContainer.classList.add('d-none'); + return; + } + + previewFile(file, mediaPreview, mediaPreviewContainer); + + // Show filename if not an image + if (!file.type.startsWith('image/')) { + mediaPreview.style.display = 'none'; + mediaLinkContainer.innerHTML = + `
Selected: ${file.name}
+ Click "Upload" to get media URL`; + } else { + mediaPreview.style.display = 'block'; + mediaLinkContainer.innerHTML = + 'Click "Upload" to get media URL'; + } + }); + } + + // Post kind radio button change handler + if (postKindRadios.length) { + postKindRadios.forEach(radio => { + radio.addEventListener('change', () => { + titleField.classList.toggle('d-none', radio.value !== '20'); + + // Show/hide required media elements for kind 20 + const isKind20 = radio.value === '20'; + const kind20MediaRequired = document.getElementById('kind20MediaRequired'); + if (kind20MediaRequired) { + kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none'; + } + + // For kind 20, validate that media is provided before posting + if (submitPostBtn) { + submitPostBtn.title = isKind20 ? + 'Picture posts require a title and an image URL' : + 'Create a standard text post'; + } + }); + }); + } + + // =================================================== + // Event Handler Functions + // =================================================== + + // Load content when button is clicked + function handleLoadContent() { + if (!botSelect || !botSelect.value) { + alert('Please select a bot first!'); + return; + } + + currentBotId = botSelect.value; + contentContainer.classList.remove('d-none'); + + loadBotContent(currentBotId); + } + + // Handle file upload button click + function handleFileUpload() { + if (!currentBotId) { + alert('Please select a bot first'); + return; + } + + if (!uploadFileInput.files.length) { + alert('Please select a file to upload'); + return; + } + + uploadBotFile(currentBotId); + } + + // Handle quick upload for post creation + function handleQuickUpload() { + if (!currentBotId) { + alert('Please select a bot first'); + return; + } + + if (!postMediaInput.files.length) { + alert('Please select a file to upload'); + return; + } + + quickUploadMedia(currentBotId, postMediaInput.files[0]); + } + + // Handle post submission + function handleSubmitPost() { + if (!currentBotId) { + alert('Please select a bot first'); + return; + } + + createManualPost(currentBotId); + } + + // Handle saving media server settings + function handleSaveMediaSettings() { + globalMediaConfig.primaryService = primaryServer.value; + globalMediaConfig.primaryURL = primaryServerURL ? primaryServerURL.value.trim() : ''; + globalMediaConfig.fallbackService = fallbackServer.value === 'none' ? '' : fallbackServer.value; + globalMediaConfig.fallbackURL = fallbackServerURL ? fallbackServerURL.value.trim() : ''; + + // Save to localStorage + localStorage.setItem('mediaConfig', JSON.stringify(globalMediaConfig)); + + alert('Media server settings saved!'); + } + + // =================================================== + // Utility Functions + // =================================================== + + // Preview a file (image or video) + function previewFile(file, previewElement, containerElement) { + if (!file) { + containerElement.classList.add('d-none'); + return; + } + + containerElement.classList.remove('d-none'); + + // Show preview for images + if (file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = function (e) { + previewElement.src = e.target.result; + previewElement.style.display = 'block'; + }; + reader.readAsDataURL(file); + } else { + // For non-images (video, etc) + previewElement.style.display = 'none'; + } + } + + // Validates post data before submission + function validatePostData() { + const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value); + const content = manualPostContent.value.trim(); + + // Basic validation for all post types + if (!content) { + alert('Post content is required'); + return false; + } + + // Additional validation for kind 20 posts + if (kind === 20) { + const title = postTitle.value.trim(); + if (!title) { + alert('Title is required for Picture Posts (kind: 20)'); + return false; + } + + // Check if we have a media URL either in the content or in the mediaUrlInput + const mediaUrl = mediaUrlInput.value.trim(); + const urlRegex = /(https?:\/\/[^\s]+)/g; + const contentContainsUrl = urlRegex.test(content); + + if (!mediaUrl && !contentContainsUrl) { + alert('Picture posts require an image URL. Please upload an image or enter a URL in the Media URL field or in the content.'); + return false; + } + } + + return true; + } + + // =================================================== + // API Functions + // =================================================== + + // Load bot content files + function loadBotContent(botId) { + const token = localStorage.getItem('authToken'); + + // Load any saved media config from localStorage + const savedConfig = localStorage.getItem('mediaConfig'); + if (savedConfig) { + try { + globalMediaConfig = JSON.parse(savedConfig); + if (primaryServer) primaryServer.value = globalMediaConfig.primaryService || 'nip94'; + if (primaryServerURL) primaryServerURL.value = globalMediaConfig.primaryURL || ''; + if (fallbackServer) { + fallbackServer.value = globalMediaConfig.fallbackService || 'none'; + } + if (fallbackServerURL) { + fallbackServerURL.value = globalMediaConfig.fallbackURL || ''; + } + } catch (e) { + console.error('Error parsing saved media config:', e); + } + } + + // Fetch content files + fetch(`/api/content/${botId}`, { + headers: { 'Authorization': token } + }) + .then(res => { + if (!res.ok) throw new Error('Failed to list content'); + return res.json(); + }) + .then(files => { + renderContentFiles(botId, files); + }) + .catch(err => { + console.error('Error loading content:', err); + alert('Error loading content: ' + err.message); + }); + } + + // Render the list of content files + function renderContentFiles(botId, files) { + const contentArea = document.getElementById('contentArea'); + if (!contentArea) return; + + if (!files || files.length === 0) { + contentArea.innerHTML = 'No files found. Upload some content!
'; + return; + } + + let html = 'Media URL:
+${mediaUrl}
`;
+
+ // Insert into text area
+ textArea.value += (textArea.value ? '\n\n' : '') + mediaUrl;
+ })
+ .catch(err => {
+ console.error('Upload error:', err);
+ quickUploadBtn.disabled = false;
+ quickUploadBtn.textContent = 'Upload';
+ mediaLinkContainer.innerHTML =
+ `Upload error: ${err.message}
`; + alert('Upload error: ' + err.message); + }); + } + + // Create a manual post (improved for kind 20 posts) + function createManualPost(botId) { + // Validate the form data first + if (!validatePostData()) { + return; + } + + const content = manualPostContent.value.trim(); + const hashtagsValue = postHashtags.value.trim(); + const hashtags = hashtagsValue + ? hashtagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0) + : []; + + const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value); + let title = ''; + + if (kind === 20) { + title = postTitle.value.trim(); + } + + // Extract media URLs and alt text + const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : ''; + const altText = mediaAltText ? mediaAltText.value.trim() : ''; + + // Build the post data based on kind + const postData = { + kind: kind, + content: content, + hashtags: hashtags + }; + + if (kind === 20) { + postData.title = title; + + // For kind 20, we need to ensure we have a valid URL + // If we have a specific media URL field value, add it to the content if not already there + if (mediaUrl && !content.includes(mediaUrl)) { + postData.content = content + '\n\n' + mediaUrl; + } + + // Add alt text if provided + if (altText) { + postData.alt = altText; + } + } + + // Disable submit button during request + submitPostBtn.disabled = true; + submitPostBtn.innerHTML = ' Posting...'; + + const token = localStorage.getItem('authToken'); + fetch(`/api/bots/${botId}/post`, { + method: 'POST', + headers: { + 'Authorization': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(postData) + }) + .then(res => { + if (!res.ok) { + return res.json().then(data => { + throw new Error(data.error || 'Failed to create post'); + }); + } + return res.json(); + }) + .then(data => { + alert('Post created successfully!'); + console.log('Post success response:', data); + + // Reset form + manualPostContent.value = ''; + postHashtags.value = ''; + postTitle.value = ''; + postMediaInput.value = ''; + if (mediaUrlInput) mediaUrlInput.value = ''; + if (mediaAltText) mediaAltText.value = ''; + mediaPreviewContainer.classList.add('d-none'); + + // Reset button + submitPostBtn.disabled = false; + submitPostBtn.textContent = 'Post Now'; + }) + .catch(err => { + console.error('Error creating post:', err); + alert('Error creating post: ' + err.message); + + // Reset button + submitPostBtn.disabled = false; + submitPostBtn.textContent = 'Post Now'; + }); + } + + // =================================================== + // Initialize media config + // =================================================== + // Try to load media config from localStorage + const savedConfig = localStorage.getItem('mediaConfig'); + if (savedConfig) { + try { + globalMediaConfig = JSON.parse(savedConfig); + if (primaryServer) primaryServer.value = globalMediaConfig.primaryService || 'nip94'; + if (primaryServerURL) primaryServerURL.value = globalMediaConfig.primaryURL || ''; + if (fallbackServer) { + fallbackServer.value = globalMediaConfig.fallbackService || 'none'; + } + if (fallbackServerURL) { + fallbackServerURL.value = globalMediaConfig.fallbackURL || ''; + } + } catch (e) { + console.error('Error parsing saved media config:', e); + } + } +}); \ No newline at end of file diff --git a/web/assets/js/main.js b/web/assets/js/main.js index 6128277..81f0023 100644 --- a/web/assets/js/main.js +++ b/web/assets/js/main.js @@ -1,5 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { - // DOM Elements + /* ---------------------------------------------------- + * DOM Elements + * -------------------------------------------------- */ const loginButton = document.getElementById('loginButton'); const logoutButton = document.getElementById('logoutButton'); const userInfo = document.getElementById('userInfo'); @@ -9,30 +11,134 @@ document.addEventListener('DOMContentLoaded', () => { const botsList = document.getElementById('bots-list'); const createBotBtn = document.getElementById('create-bot-btn'); const saveBotBtn = document.getElementById('save-bot-btn'); - const generateKeypair = document.getElementById('generateKeypair'); - const keypairInput = document.getElementById('keypair-input'); - // Bootstrap Modal + // Modal elements for new bot creation + const createBotModalEl = document.getElementById('createBotModal'); + // New form fields for key import/generation + const keyOption = document.getElementById('keyOption'); + const nsecKeyInput = document.getElementById('nsecKeyInput'); + const toggleNsecKey = document.getElementById('toggleNsecKey'); + + // If you have a "bot settings" modal: + // (IDs must match the modals in your HTML) + const botSettingsModalEl = document.getElementById('botSettingsModal'); + + /* ---------------------------------------------------- + * Bootstrap Modal instance + * -------------------------------------------------- */ let createBotModal; - if (typeof bootstrap !== 'undefined') { - createBotModal = new bootstrap.Modal(document.getElementById('createBotModal')); + if (typeof bootstrap !== 'undefined' && createBotModalEl) { + createBotModal = new bootstrap.Modal(createBotModalEl); } - // State - let currentUser = null; - const API_ENDPOINT = ''; + // If you have a Bot Settings modal + let botSettingsModal; + if (typeof bootstrap !== 'undefined' && botSettingsModalEl) { + botSettingsModal = new bootstrap.Modal(botSettingsModalEl); + } - // Check if already logged in + /* ---------------------------------------------------- + * Global State + * -------------------------------------------------- */ + let currentUser = null; + const API_ENDPOINT = ''; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765' + + /* ---------------------------------------------------- + * On page load, check if already logged in + * -------------------------------------------------- */ checkAuth(); - // Event Listeners - loginButton.addEventListener('click', login); - logoutButton.addEventListener('click', logout); - createBotBtn.addEventListener('click', showCreateBotModal); - saveBotBtn.addEventListener('click', createBot); - generateKeypair.addEventListener('change', toggleKeypairInput); + /* ---------------------------------------------------- + * Event Listeners + * -------------------------------------------------- */ + if (loginButton) loginButton.addEventListener('click', login); + if (logoutButton) logoutButton.addEventListener('click', logout); - // Functions + if (createBotBtn) createBotBtn.addEventListener('click', showCreateBotModal); + if (saveBotBtn) saveBotBtn.addEventListener('click', createBot); + + document.body.addEventListener('click', (evt) => { + const viewBtn = evt.target.closest('.view-bot'); + if (viewBtn) { + const botId = viewBtn.dataset.id; + if (botId) { + openBotSettings(botId); + } + } + }); + + // Handle "keyOption" dropdown changes + if (keyOption) { + keyOption.addEventListener('change', function () { + if (this.value === 'import') { + nsecKeyInput.style.display = 'block'; + // Focus the NSEC input field (slight delay to ensure DOM is ready) + setTimeout(() => { + const nk = document.getElementById('botNsecKey'); + if (nk) nk.focus(); + }, 100); + } else { + nsecKeyInput.style.display = 'none'; + } + }); + } + + // Toggle NSEC key visibility + if (toggleNsecKey) { + toggleNsecKey.addEventListener('click', function () { + const nsecKeyField = document.getElementById('botNsecKey'); + if (!nsecKeyField) return; + + if (nsecKeyField.type === 'password') { + nsecKeyField.type = 'text'; + this.innerHTML = ` + + `; + } else { + nsecKeyField.type = 'password'; + this.innerHTML = ` + + `; + } + }); + } + + /* ---------------------------------------------------- + * Authentication / Login / Logout + * -------------------------------------------------- */ async function checkAuth() { const token = localStorage.getItem('authToken'); if (!token) { @@ -42,15 +148,22 @@ document.addEventListener('DOMContentLoaded', () => { try { const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, { - headers: { - 'Authorization': token - } + headers: { 'Authorization': token } }); if (response.ok) { const data = await response.json(); - currentUser = data.pubkey; + + // data.pubkey is currently hex + // Convert to npub using nip19 from nostr-tools + const { nip19 } = window.nostrTools; + const userNpub = nip19.npubEncode(data.pubkey); + + // Store npub as currentUser + currentUser = userNpub; + showMainContent(); + // Always load bots if on the main page fetchBots(); } else { // Token invalid @@ -63,6 +176,7 @@ document.addEventListener('DOMContentLoaded', () => { } } + async function login() { if (!window.nostr) { alert('Nostr extension not found. Please install a NIP-07 compatible extension like nos2x or Alby.'); @@ -72,7 +186,7 @@ document.addEventListener('DOMContentLoaded', () => { try { // Get user's public key const pubkey = await window.nostr.getPublicKey(); - + // Create challenge event for signing const event = { kind: 22242, @@ -83,13 +197,11 @@ document.addEventListener('DOMContentLoaded', () => { // Sign the event const signedEvent = await window.nostr.signEvent(event); - + // Send to server for verification const response = await fetch(`${API_ENDPOINT}/api/auth/login`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey, signature: signedEvent.sig, @@ -118,33 +230,44 @@ document.addEventListener('DOMContentLoaded', () => { showAuthSection(); } + /* ---------------------------------------------------- + * UI State Helpers + * -------------------------------------------------- */ function showAuthSection() { - authSection.classList.remove('d-none'); - mainContent.classList.add('d-none'); - loginButton.classList.remove('d-none'); - userInfo.classList.add('d-none'); - logoutButton.classList.add('d-none'); + if (authSection) authSection.classList.remove('d-none'); + if (mainContent) mainContent.classList.add('d-none'); + if (loginButton) loginButton.classList.remove('d-none'); + if (userInfo) userInfo.classList.add('d-none'); + if (logoutButton) logoutButton.classList.add('d-none'); } - + function showMainContent() { - authSection.classList.add('d-none'); - mainContent.classList.remove('d-none'); - loginButton.classList.add('d-none'); - userInfo.classList.remove('d-none'); - logoutButton.classList.remove('d-none'); - + if (authSection) authSection.classList.add('d-none'); + if (mainContent) mainContent.classList.remove('d-none'); + if (loginButton) loginButton.classList.add('d-none'); + if (userInfo) userInfo.classList.remove('d-none'); + if (logoutButton) logoutButton.classList.remove('d-none'); + // Truncate pubkey for display - const shortPubkey = currentUser.substring(0, 8) + '...' + currentUser.substring(currentUser.length - 4); - userPubkey.textContent = shortPubkey; + if (userPubkey && currentUser) { + const shortPubkey = currentUser.substring(0, 8) + '...' + + currentUser.substring(currentUser.length - 4); + userPubkey.textContent = shortPubkey; + } } + + /* ---------------------------------------------------- + * Bots List + * -------------------------------------------------- */ async function fetchBots() { + // If there's no #bots-list, skip + if (!botsList) return; + try { const token = localStorage.getItem('authToken'); const response = await fetch(`${API_ENDPOINT}/api/bots`, { - headers: { - 'Authorization': token - } + headers: { 'Authorization': token } }); if (response.ok) { @@ -159,21 +282,67 @@ document.addEventListener('DOMContentLoaded', () => { } function renderBots(bots) { - botsList.innerHTML = ''; + if (!botsList) return; - if (bots.length === 0) { + botsList.innerHTML = ''; + + if (!bots || bots.length === 0) { botsList.innerHTML = `