Content page mostly working
This commit is contained in:
parent
964f812897
commit
559982b525
Binary file not shown.
@ -1,6 +1,7 @@
|
||||
app_name: "Nostr Poster"
|
||||
server_port: 8765
|
||||
log_level: "info"
|
||||
allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7"
|
||||
|
||||
bot:
|
||||
keys_file: "./keys.json"
|
||||
|
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
@ -1,6 +1,7 @@
|
||||
app_name: "Nostr Poster"
|
||||
server_port: 8765
|
||||
log_level: "info"
|
||||
allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7"
|
||||
|
||||
bot:
|
||||
keys_file: "./keys.json"
|
||||
|
1
go.mod
1
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
|
||||
|
89
go.sum
89
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=
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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"`
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
17
keys.json
17
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"
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
513
web/assets/js/content.js
Normal file
513
web/assets/js/content.js
Normal file
@ -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 =
|
||||
`<p class="mb-0">Selected: ${file.name}</p>
|
||||
<small class="text-muted">Click "Upload" to get media URL</small>`;
|
||||
} else {
|
||||
mediaPreview.style.display = 'block';
|
||||
mediaLinkContainer.innerHTML =
|
||||
'<small class="text-muted">Click "Upload" to get media URL</small>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 = '<p>No files found. Upload some content!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<ul class="list-group">';
|
||||
for (const file of files) {
|
||||
html += `
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${file}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteBotFile('${botId}', '${file}')">Delete</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
contentArea.innerHTML = html;
|
||||
}
|
||||
|
||||
// Upload media for the manual post
|
||||
function quickUploadMedia(botId, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
quickUploadBtn.disabled = true;
|
||||
quickUploadBtn.textContent = 'Uploading...';
|
||||
|
||||
// Show loading state in preview
|
||||
mediaLinkContainer.innerHTML =
|
||||
'<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Uploading...';
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
// First upload the file to our server
|
||||
fetch(`/api/content/${botId}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': token },
|
||||
body: formData
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Now upload to the media server
|
||||
return fetch(`/api/content/${botId}/uploadToMediaServer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: data.filename,
|
||||
service: globalMediaConfig.primaryService,
|
||||
serverURL: globalMediaConfig.primaryURL
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Media server upload failed');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Reset button state
|
||||
quickUploadBtn.disabled = false;
|
||||
quickUploadBtn.textContent = 'Upload';
|
||||
|
||||
// Insert the media URL into the post content
|
||||
const textArea = document.getElementById('manualPostContent');
|
||||
let mediaUrl = data.url;
|
||||
|
||||
// Also update the media URL input field
|
||||
const mediaUrlInput = document.getElementById('mediaUrlInput');
|
||||
if (mediaUrlInput) {
|
||||
mediaUrlInput.value = mediaUrl;
|
||||
}
|
||||
|
||||
// Update preview with media info
|
||||
mediaLinkContainer.innerHTML =
|
||||
`<p class="mb-0">Media URL:</p>
|
||||
<code class="text-info">${mediaUrl}</code>`;
|
||||
|
||||
// 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 =
|
||||
`<p class="text-danger">Upload error: ${err.message}</p>`;
|
||||
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 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);
|
||||
}
|
||||
}
|
||||
});
|
@ -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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-eye-slash" viewBox="0 0 16 16">
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028
|
||||
7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0
|
||||
3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83
|
||||
1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0
|
||||
0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829
|
||||
2.829l.822.822zm-2.943 1.299.822.822a3.5
|
||||
3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0
|
||||
0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134
|
||||
13.134 0 0 0 1.172 8l.195.288c.335.48.83
|
||||
1.12 1.465 1.755C4.121 11.332 5.881 12.5 8
|
||||
12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029
|
||||
7.029 0 0 1 8 13.5C3 13.5 0 8 0
|
||||
8s.939-1.721 2.641-3.238l.708.709zm10.296
|
||||
8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
nsecKeyField.type = 'password';
|
||||
this.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-eye" viewBox="0 0 16 16">
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0
|
||||
8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133
|
||||
13.133 0 0 1 1.66-2.043C4.12 4.668 5.88
|
||||
3.5 8 3.5c2.12 0 3.879 1.168 5.168
|
||||
2.457A13.133 13.133 0 0 1 14.828
|
||||
8c-.058.087-.122.183-.195.288-.335.48-.83
|
||||
1.12-1.465 1.755C11.879 11.332 10.119
|
||||
12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134
|
||||
13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0
|
||||
5 2.5 2.5 0 0 0 0-5zM4.5
|
||||
8a3.5 3.5 0 1 1 7 0 3.5 3.5
|
||||
0 0 1-7 0z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* 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 = `
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#9370DB" class="bi bi-robot mb-3" viewBox="0 0 16 16">
|
||||
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z"/>
|
||||
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z"/>
|
||||
<!-- Robot Icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"
|
||||
fill="#9370DB" class="bi bi-robot mb-3" viewBox="0 0 16 16">
|
||||
<path d="M6 12.5a.5.5 0 0 1
|
||||
.5-.5h3a.5.5 0 0 1 0
|
||||
1h-3a.5.5 0 0 1-.5-.5ZM3
|
||||
8.062C3 6.76 4.235 5.765
|
||||
5.53 5.886a26.58 26.58
|
||||
0 0 0 4.94 0C11.765
|
||||
5.765 13 6.76 13
|
||||
8.062v1.157a.933.933 0
|
||||
0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895
|
||||
0-3.39-.2-4.235-.346A.933.933 0 0 1
|
||||
3 9.219V8.062Zm4.542-.827a.25.25
|
||||
0 0 0-.217.068l-.92.9a24.767
|
||||
24.767 0 0 1-1.871-.183.25.25
|
||||
0 0 0-.068.495c.55.076
|
||||
1.232.149 2.02.193a.25.25
|
||||
0 0 0 .189-.071l.754-.736.847
|
||||
1.71a.25.25 0 0 0
|
||||
.404.062l.932-.97a25.286
|
||||
25.286 0 0 0 1.922-.188.25.25
|
||||
0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25
|
||||
0 0 0-.166.076l-.754.785-.842-1.7a.25.25
|
||||
0 0 0-.182-.135Z"/>
|
||||
<path d="M8.5 1.866a1
|
||||
1 0 1 0-1
|
||||
0V3h-2A4.5 4.5 0 0 0 1
|
||||
7.5V8a1 1 0 0 0-1
|
||||
1v2a1 1 0 0 0 1
|
||||
1v1a2 2 0 0 0 2
|
||||
2h10a2 2 0 0 0
|
||||
2-2v-1a1 1 0 0 0 1-1V9a1
|
||||
1 0 0 0-1-1v-.5A4.5 4.5
|
||||
0 0 0 10.5 3h-2V1.866ZM14
|
||||
7.5V13a1 1 0 0 1-1
|
||||
1H3a1 1 0 0 1-1-1V7.5A3.5
|
||||
3.5 0 0 1 5.5 4h5A3.5
|
||||
3.5 0 0 1 14 7.5Z"/>
|
||||
</svg>
|
||||
<p class="lead">You don't have any bots yet.</p>
|
||||
<button class="btn btn-primary mt-3" onclick="document.getElementById('create-bot-btn').click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
<button class="btn btn-primary mt-3"
|
||||
onclick="document.getElementById('create-bot-btn').click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-plus-lg me-1"
|
||||
viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 2a.5.5 0 0 1
|
||||
.5.5v5h5a.5.5 0 0 1 0
|
||||
1h-5v5a.5.5 0 0 1-1
|
||||
0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5
|
||||
0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create Your First Bot
|
||||
</button>
|
||||
@ -185,15 +354,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
bots.forEach(bot => {
|
||||
// Create a status badge
|
||||
let statusBadge = '';
|
||||
if (bot.post_config?.enabled) {
|
||||
statusBadge = '<span class="badge bg-success">Active</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge bg-secondary">Inactive</span>';
|
||||
}
|
||||
|
||||
// Generate a profile image based on the bot's pubkey (just a colored square)
|
||||
|
||||
const profileColor = generateColorFromString(bot.pubkey);
|
||||
const initials = (bot.name || 'Bot').substring(0, 2).toUpperCase();
|
||||
|
||||
@ -203,7 +370,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="card bot-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3" style="width: 50px; height: 50px; background-color: ${profileColor}; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">
|
||||
<div class="me-3"
|
||||
style="width: 50px; height: 50px; background-color: ${profileColor};
|
||||
border-radius: 8px; display: flex; align-items: center;
|
||||
justify-content: center; color: white; font-weight: bold;">
|
||||
${initials}
|
||||
</div>
|
||||
<div>
|
||||
@ -220,16 +390,72 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-sm btn-primary view-bot" data-id="${bot.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
||||
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-eye-fill me-1"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M10.5 8a2.5 2.5 0 1
|
||||
1-5 0 2.5 2.5 0 0
|
||||
1 5 0z"/>
|
||||
<path d="M0 8s3-5.5 8-5.5S16
|
||||
8 16 8s-3 5.5-8 5.5S0
|
||||
8 0 8zm8 3.5a3.5 3.5 0
|
||||
1 0 0-7 3.5 3.5 0 0 0
|
||||
0 7z"/>
|
||||
</svg>
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-sm ${bot.post_config?.enabled ? 'btn-outline-danger' : 'btn-outline-success'}">
|
||||
${bot.post_config?.enabled ?
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill me-1" viewBox="0 0 16 16"><path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/></svg>Pause' :
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill me-1" viewBox="0 0 16 16"><path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg>Enable'}
|
||||
|
||||
<!-- For enabling/disabling -->
|
||||
${bot.post_config?.enabled
|
||||
? `<button class="btn btn-sm btn-outline-danger" onclick="disableBot(${bot.id})">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-pause-fill me-1"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M5.5 3.5A1.5 1.5 0 0 1
|
||||
7 5v6a1.5 1.5 0 0 1-3
|
||||
0V5a1.5 1.5 0 0 1
|
||||
1.5-1.5zm5 0A1.5 1.5
|
||||
0 0 1 12 5v6a1.5 1.5
|
||||
0 0 1-3 0V5a1.5 1.5
|
||||
0 0 1 1.5-1.5z"/>
|
||||
</svg>
|
||||
Pause
|
||||
</button>`
|
||||
: `<button class="btn btn-sm btn-outline-success" onclick="enableBot(${bot.id})">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-play-fill me-1"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="m11.596 8.697-6.363
|
||||
3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01
|
||||
1.233-.696l6.363 3.692a.802.802 0 0 1 0
|
||||
1.393z"/>
|
||||
</svg>
|
||||
Enable
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ADD DELETE BUTTON BELOW, for example: -->
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteBot(${bot.id})">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-trash me-1"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6
|
||||
5h4a.5.5 0 0 1 0 1H6a.5.5 0
|
||||
0 1-.5-.5zm2 3a.5.5 0 0 1
|
||||
.5-.5h1a.5.5 0 0 1 0 1h-1a.5.5
|
||||
0 0 1-.5-.5z"/>
|
||||
<path d="M14 3a1 1 0 0 1-1
|
||||
1H3a1 1 0 0 1-1-1H0v1a2
|
||||
2 0 0 0 2 2v9a2 2 0 0 0
|
||||
2 2h8a2 2 0 0 0 2-2V6a2
|
||||
2 0 0 0 2-2V3h-2zM2.5
|
||||
5h11v9a1 1 0 0 1-1
|
||||
1h-9a1 1 0 0
|
||||
1-1-1V5z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -244,55 +470,78 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
// Use the hash to create a hue in the purple range (260-290)
|
||||
const hue = ((hash % 30) + 260) % 360;
|
||||
return `hsl(${hue}, 70%, 60%)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Show Create Bot Modal
|
||||
* -------------------------------------------------- */
|
||||
function showCreateBotModal() {
|
||||
// Reset form
|
||||
document.getElementById('create-bot-form').reset();
|
||||
generateKeypair.checked = true;
|
||||
keypairInput.classList.add('d-none');
|
||||
|
||||
// Show modal
|
||||
createBotModal.show();
|
||||
}
|
||||
const createBotForm = document.getElementById('create-bot-form');
|
||||
if (createBotForm) createBotForm.reset();
|
||||
|
||||
function toggleKeypairInput() {
|
||||
if (generateKeypair.checked) {
|
||||
keypairInput.classList.add('d-none');
|
||||
} else {
|
||||
keypairInput.classList.remove('d-none');
|
||||
// Default: keyOption = "generate" → hide nsecKeyInput
|
||||
if (keyOption) {
|
||||
keyOption.value = 'generate';
|
||||
}
|
||||
if (nsecKeyInput) {
|
||||
nsecKeyInput.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show modal
|
||||
if (createBotModal) {
|
||||
createBotModal.show();
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Create Bot (merged logic)
|
||||
* -------------------------------------------------- */
|
||||
async function createBot() {
|
||||
const name = document.getElementById('botName').value;
|
||||
const displayName = document.getElementById('botDisplayName').value;
|
||||
const bio = document.getElementById('botBio').value;
|
||||
const nip05 = document.getElementById('botNip05').value;
|
||||
|
||||
console.clear();
|
||||
console.log('Creating new bot...');
|
||||
|
||||
// Get form values
|
||||
const name = document.getElementById('botName').value.trim();
|
||||
const displayName = document.getElementById('botDisplayName').value.trim();
|
||||
const bio = document.getElementById('botBio').value.trim();
|
||||
const nip05 = document.getElementById('botNip05').value.trim();
|
||||
const keyChoice = keyOption ? keyOption.value : 'generate'; // fallback
|
||||
|
||||
// Validate form
|
||||
if (!name) {
|
||||
alert('Bot name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
let pubkey = '';
|
||||
let privkey = '';
|
||||
// Build request data
|
||||
const requestData = {
|
||||
name: name,
|
||||
display_name: displayName,
|
||||
bio: bio,
|
||||
nip05: nip05
|
||||
};
|
||||
|
||||
if (!generateKeypair.checked) {
|
||||
pubkey = document.getElementById('botPubkey').value;
|
||||
privkey = document.getElementById('botPrivkey').value;
|
||||
|
||||
if (!pubkey || !privkey) {
|
||||
alert('Both public and private keys are required when not generating a keypair.');
|
||||
// If user selected "import", grab the NSEC key
|
||||
if (keyChoice === 'import') {
|
||||
const nsecKey = document.getElementById('botNsecKey').value.trim();
|
||||
if (!nsecKey) {
|
||||
alert('NSEC key is required when importing an existing key.');
|
||||
return;
|
||||
}
|
||||
requestData.encrypted_privkey = nsecKey;
|
||||
console.log('Using imported NSEC key (starts with):', nsecKey.substring(0, 4) + '...');
|
||||
} else {
|
||||
console.log('Using auto-generated keypair');
|
||||
}
|
||||
|
||||
console.log('Sending request to create bot...');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||
@ -301,22 +550,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
display_name: displayName,
|
||||
bio,
|
||||
nip05,
|
||||
pubkey,
|
||||
encrypted_privkey: privkey
|
||||
})
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
const rawResponse = await response.text();
|
||||
console.log('Response text:', rawResponse.substring(0, 200) + '...');
|
||||
|
||||
let jsonResponse;
|
||||
try {
|
||||
jsonResponse = JSON.parse(rawResponse);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON response:', e);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
createBotModal.hide();
|
||||
console.log('Bot created successfully!');
|
||||
// Hide modal
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
const modal = bootstrap.Modal.getInstance(createBotModalEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
// Refresh bot list
|
||||
fetchBots();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create bot: ${error.error}`);
|
||||
const errorMsg = jsonResponse && jsonResponse.error
|
||||
? jsonResponse.error
|
||||
: `Failed with status ${response.status}`;
|
||||
alert('Failed to create bot: ' + errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating bot:', error);
|
||||
@ -324,28 +585,359 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const togglePrivkeyBtn = document.getElementById('togglePrivkey');
|
||||
if (togglePrivkeyBtn) {
|
||||
togglePrivkeyBtn.addEventListener('click', function() {
|
||||
const privkeyInput = document.getElementById('botPrivkey');
|
||||
const eyeIcon = this.querySelector('svg');
|
||||
|
||||
if (privkeyInput.type === 'password') {
|
||||
privkeyInput.type = 'text';
|
||||
eyeIcon.innerHTML = `
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
`;
|
||||
} else {
|
||||
privkeyInput.type = 'password';
|
||||
eyeIcon.innerHTML = `
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||
`;
|
||||
/* ----------------------------------------------------
|
||||
* Enable Bot
|
||||
* -------------------------------------------------- */
|
||||
window.enableBot = async function (botId) {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/enable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to enable bot');
|
||||
}
|
||||
});
|
||||
alert('Bot enabled successfully!');
|
||||
// Reload bots
|
||||
fetchBots();
|
||||
} catch (err) {
|
||||
console.error('Error enabling bot:', err);
|
||||
alert(`Error enabling bot: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Disable Bot
|
||||
* -------------------------------------------------- */
|
||||
window.disableBot = async function (botId) {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/disable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to disable bot');
|
||||
}
|
||||
alert('Bot paused/stopped successfully!');
|
||||
// Refresh the list so it shows "Inactive"
|
||||
fetchBots();
|
||||
} catch (err) {
|
||||
console.error('Error disabling bot:', err);
|
||||
alert(`Error disabling bot: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Settings Window
|
||||
* -------------------------------------------------- */
|
||||
window.openBotSettings = async function (botId) {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||
headers: { 'Authorization': token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load bot data');
|
||||
}
|
||||
const bot = await response.json();
|
||||
|
||||
// Fill form fields
|
||||
document.getElementById('botSettingsName').value = bot.name || '';
|
||||
document.getElementById('botSettingsDisplayName').value = bot.display_name || '';
|
||||
document.getElementById('botSettingsBio').value = bot.bio || '';
|
||||
document.getElementById('botSettingsNip05').value = bot.nip05 || '';
|
||||
document.getElementById('botSettingsZap').value = bot.zap_address || '';
|
||||
|
||||
// If post_config is present
|
||||
if (bot.post_config) {
|
||||
document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60;
|
||||
|
||||
// hashtags is stored as a JSON string
|
||||
const hashtagsJson = bot.post_config.hashtags || '[]';
|
||||
const tagsArr = JSON.parse(hashtagsJson);
|
||||
document.getElementById('botSettingsHashtags').value = tagsArr.join(', ');
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
const modalEl = document.getElementById('botSettingsModal');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
|
||||
// Store bot ID so we know which bot to save
|
||||
document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading bot data:', err);
|
||||
alert('Error loading bot: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.saveBotSettings = async function () {
|
||||
const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id');
|
||||
if (!botId) {
|
||||
return alert('No bot ID found');
|
||||
}
|
||||
|
||||
// Basic info
|
||||
const name = document.getElementById('botSettingsName').value.trim();
|
||||
const displayName = document.getElementById('botSettingsDisplayName').value.trim();
|
||||
const bio = document.getElementById('botSettingsBio').value.trim();
|
||||
const nip05 = document.getElementById('botSettingsNip05').value.trim();
|
||||
const zap = document.getElementById('botSettingsZap').value.trim();
|
||||
|
||||
// 1) Update the basic fields (PUT /api/bots/:id)
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const updateResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
display_name: displayName,
|
||||
bio,
|
||||
nip05,
|
||||
zap_address: zap
|
||||
})
|
||||
});
|
||||
if (!updateResp.ok) {
|
||||
const errData = await updateResp.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to update bot info');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update bot info:', err);
|
||||
alert('Error updating bot info: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Update the post config (PUT /api/bots/:id/config)
|
||||
const intervalValue = parseInt(document.getElementById('botSettingsInterval').value, 10) || 60;
|
||||
const hashtagsStr = document.getElementById('botSettingsHashtags').value.trim();
|
||||
const hashtagsArr = hashtagsStr.length ? hashtagsStr.split(',').map(s => s.trim()) : [];
|
||||
|
||||
const configPayload = {
|
||||
post_config: {
|
||||
interval_minutes: intervalValue,
|
||||
hashtags: JSON.stringify(hashtagsArr)
|
||||
// We do not override 'enabled' here, so it remains as is
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const configResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
body: JSON.stringify(configPayload)
|
||||
});
|
||||
if (!configResp.ok) {
|
||||
const errData = await configResp.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to update post config');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update post config:', err);
|
||||
alert('Error updating post config: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Bot settings updated!');
|
||||
|
||||
// Hide modal
|
||||
const modalEl = document.getElementById('botSettingsModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
|
||||
// Reload bots
|
||||
fetchBots();
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Nuke bot
|
||||
* -------------------------------------------------- */
|
||||
window.deleteBot = async function (botId) {
|
||||
// Safety check: prompt user to confirm
|
||||
if (!confirm('Are you sure you want to delete this bot? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.error || `Failed to delete bot (status: ${response.status})`);
|
||||
}
|
||||
|
||||
alert('Bot deleted successfully!');
|
||||
// Reload the bots so it disappears from the list
|
||||
fetchBots();
|
||||
} catch (err) {
|
||||
console.error('Error deleting bot:', err);
|
||||
alert(`Error deleting bot: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* Content managment - Exposed as global functions
|
||||
* -------------------------------------------------- */
|
||||
// Called by content.html after we confirm the user is logged in
|
||||
window.loadBotChoices = async function() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) return;
|
||||
const res = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||
headers: { 'Authorization': token }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch bots');
|
||||
}
|
||||
const bots = await res.json();
|
||||
const botSelect = document.getElementById('botSelect');
|
||||
if (!botSelect) return;
|
||||
|
||||
botSelect.innerHTML = `<option value="">-- Select a Bot --</option>`;
|
||||
bots.forEach(bot => {
|
||||
botSelect.innerHTML += `<option value="${bot.id}">${bot.name} (ID: ${bot.id})</option>`;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading bot choices:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Called when user picks a bot from the dropdown and clicks "Load Content"
|
||||
window.loadBotContent = async function() {
|
||||
const botSelect = document.getElementById('botSelect');
|
||||
if (!botSelect) return;
|
||||
const botId = botSelect.value;
|
||||
if (!botId) {
|
||||
alert('Please select a bot first!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}`, {
|
||||
headers: { 'Authorization': token }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to list content');
|
||||
}
|
||||
const files = await res.json();
|
||||
renderBotContent(botId, files);
|
||||
} catch (err) {
|
||||
console.error('Error loading content:', err);
|
||||
alert('Error loading content: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function renderBotContent(botId, files) {
|
||||
const container = document.getElementById('contentArea');
|
||||
if (!container) return;
|
||||
|
||||
// Clear existing
|
||||
container.innerHTML = `
|
||||
<h3>Content for Bot #${botId}</h3>
|
||||
<div class="mb-3">
|
||||
<input type="file" id="uploadFileInput" />
|
||||
<button class="btn btn-primary" onclick="uploadBotFile(${botId})">Upload</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
container.innerHTML += `<p>No files.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show a simple list
|
||||
let listHtml = `<ul class="list-group">`;
|
||||
for (const f of files) {
|
||||
listHtml += `
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${f}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteBotFile(${botId}, '${f}')">Delete</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
listHtml += `</ul>`;
|
||||
|
||||
container.innerHTML += listHtml;
|
||||
}
|
||||
|
||||
|
||||
// Upload a file
|
||||
window.uploadBotFile = async function(botId) {
|
||||
const fileInput = document.getElementById('uploadFileInput');
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
return alert('No file selected');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': token },
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
await res.json(); // read the body
|
||||
|
||||
alert('File uploaded!');
|
||||
window.loadBotContent(botId); // refresh the list
|
||||
} catch (err) {
|
||||
console.error('Error uploading file:', err);
|
||||
alert('Upload error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete
|
||||
window.deleteBotFile = async function(botId, filename) {
|
||||
if (!confirm(`Delete file "${filename}"?`)) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}/${filename}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': token }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to delete file');
|
||||
}
|
||||
alert('Deleted file');
|
||||
window.loadBotContent(botId); // refresh
|
||||
} catch (err) {
|
||||
console.error('Error deleting file:', err);
|
||||
alert('Delete error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// If #logoutButton2 exists (used on content.html), attach event listener
|
||||
const logoutButton2 = document.getElementById('logoutButton2');
|
||||
if (logoutButton2) {
|
||||
logoutButton2.addEventListener('click', logout);
|
||||
}
|
||||
|
||||
// Expose checkAuth to global context for content.html
|
||||
window.checkAuth = checkAuth;
|
||||
});
|
253
web/content.html
Normal file
253
web/content.html
Normal file
@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nostr Poster - Content Manager</title>
|
||||
|
||||
<!-- Bootstrap & CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
|
||||
<!-- nostr-tools ESM (attaches nip19 to window.nostrTools) -->
|
||||
<script type="module">
|
||||
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
||||
window.nostrTools = { nip19 };
|
||||
</script>
|
||||
|
||||
<!-- Main JS -->
|
||||
<script src="/assets/js/main.js"></script>
|
||||
|
||||
<!-- Content Page JS -->
|
||||
<script src="/assets/js/content.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-send-fill me-2"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18
|
||||
a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41
|
||||
a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0
|
||||
0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z" />
|
||||
</svg>
|
||||
Nostr Poster - Content
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-robot me-1" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Bots
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item" id="userInfoRow">
|
||||
<span class="me-3">Logged in as: <span id="userPubkey"></span></span>
|
||||
<button class="btn btn-sm btn-outline-danger" id="logoutButton2">Logout</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Bot Selection Section -->
|
||||
<div class="bot-selection-container text-center">
|
||||
<h3 class="mb-4">Select a Bot to Manage Content</h3>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<select id="botSelect" class="form-select">
|
||||
<!-- Populated by loadBotChoices() in main.js -->
|
||||
<option value="">-- Select a Bot --</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="loadContentBtn">Load Content</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Media Server Settings -->
|
||||
<div class="row justify-content-center mt-3" id="mediaServerSettings">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Media Server Settings</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label for="primaryServer" class="form-label">Primary Media Server</label>
|
||||
<select id="primaryServer" class="form-select mb-2">
|
||||
<option value="nip94">NIP-94 Server</option>
|
||||
<option value="blossom">Blossom Server</option>
|
||||
</select>
|
||||
<div class="mb-2">
|
||||
<label for="primaryServerURL" class="form-label">Server URL</label>
|
||||
<input type="text" id="primaryServerURL" class="form-control"
|
||||
placeholder="https://your-nip94-server.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<label for="fallbackServer" class="form-label">Fallback Server</label>
|
||||
<select id="fallbackServer" class="form-select mb-2">
|
||||
<option value="none">None</option>
|
||||
<option value="nip94">NIP-94 Server</option>
|
||||
<option value="blossom">Blossom Server</option>
|
||||
</select>
|
||||
<div class="mb-2">
|
||||
<label for="fallbackServerURL" class="form-label">Fallback URL</label>
|
||||
<input type="text" id="fallbackServerURL" class="form-control"
|
||||
placeholder="https://your-fallback-server.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary mt-2" id="saveMediaSettingsBtn">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content areas (only visible after bot selection) -->
|
||||
<div id="contentContainer" class="d-none">
|
||||
<div class="row">
|
||||
<!-- Content Upload Section (Left) -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Content Files</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="contentArea">
|
||||
<!-- Files will be listed here -->
|
||||
<p>Select a bot and click "Load Content" to see files.</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5>Upload New Content</h5>
|
||||
<div class="mb-3">
|
||||
<input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*">
|
||||
<div id="uploadPreviewContainer" class="d-none">
|
||||
<img id="uploadPreview" class="upload-preview img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="uploadButton" disabled>Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Post Section (Right) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Create Manual Post</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Post Type Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Post Type</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked>
|
||||
<label class="form-check-label" for="postKind1">
|
||||
Standard Post (kind: 1)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20">
|
||||
<label class="form-check-label" for="postKind20">
|
||||
<strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title Field (only for kind 20) -->
|
||||
<div class="mb-3 d-none" id="titleField">
|
||||
<label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)">
|
||||
<small class="form-text text-muted">Required for picture posts (kind: 20)</small>
|
||||
</div>
|
||||
|
||||
<!-- Content Field -->
|
||||
<div class="mb-3">
|
||||
<label for="manualPostContent" class="form-label">Content</label>
|
||||
<textarea class="form-control" id="manualPostContent" rows="4"
|
||||
placeholder="Write your post content here..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Hashtags Field -->
|
||||
<div class="mb-3">
|
||||
<label for="postHashtags" class="form-label">Hashtags</label>
|
||||
<input type="text" class="form-control" id="postHashtags"
|
||||
placeholder="comma separated: art, photography, etc">
|
||||
</div>
|
||||
|
||||
<!-- Media Section - Enhanced for Kind 20 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Media Attachment</h5>
|
||||
<span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Upload Section -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Upload Image</label>
|
||||
<div class="input-group">
|
||||
<input type="file" id="postMediaInput" class="form-control" accept="image/*">
|
||||
<button class="btn btn-secondary" type="button" id="quickUploadBtn">Upload</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Upload to attach an image to your post</small>
|
||||
</div>
|
||||
|
||||
<!-- Media URL Field (manually entered URL or populated after upload) -->
|
||||
<div class="mb-3">
|
||||
<label for="mediaUrlInput" class="form-label">Media URL</label>
|
||||
<input type="text" class="form-control" id="mediaUrlInput"
|
||||
placeholder="Enter media URL or upload an image">
|
||||
<small class="form-text text-muted">For kind: 20 posts, an image URL is required (either here or in
|
||||
the content)</small>
|
||||
</div>
|
||||
|
||||
<!-- Alt Text Field (for accessibility) -->
|
||||
<div class="mb-3">
|
||||
<label for="mediaAltText" class="form-label">Alt Text</label>
|
||||
<input type="text" class="form-control" id="mediaAltText"
|
||||
placeholder="Describe the image for accessibility">
|
||||
<small class="form-text text-muted">Recommended for image description (improves accessibility)</small>
|
||||
</div>
|
||||
|
||||
<!-- Media Preview -->
|
||||
<div id="mediaPreviewContainer" class="d-none mt-2">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<img id="mediaPreview" class="upload-preview img-fluid mb-2">
|
||||
<div id="mediaLinkContainer" class="media-link">
|
||||
<small class="text-muted">Media will appear here after upload</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
118
web/index.html
118
web/index.html
@ -5,11 +5,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nostr Poster</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<script type="module">
|
||||
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
||||
window.nostrTools = { nip19 };
|
||||
</script>
|
||||
<script src="/assets/js/main.js"></script>
|
||||
</head>
|
||||
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
@ -38,7 +47,7 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/#content">
|
||||
<a class="nav-link" href="/content.html">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-images me-1" viewBox="0 0 16 16">
|
||||
<path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
|
||||
@ -172,37 +181,31 @@
|
||||
<div class="form-text">Optional. Used for verification.</div>
|
||||
</div>
|
||||
<hr class="border-secondary">
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="generateKeypair" checked>
|
||||
<label class="form-check-label" for="generateKeypair">
|
||||
Generate new keypair
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Recommended. A unique Nostr identity will be created for your bot.
|
||||
</div>
|
||||
<label for="keyOption" class="form-label">Key Option</label>
|
||||
<select class="form-select" id="keyOption">
|
||||
<option value="generate" selected>Generate new keypair</option>
|
||||
<option value="import">Import existing NSEC key</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="keypair-input" class="d-none">
|
||||
<div class="mb-3">
|
||||
<label for="botPubkey" class="form-label">Public Key (hex)</label>
|
||||
<input type="text" class="form-control" id="botPubkey">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botPrivkey" class="form-label">Private Key (hex)</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="botPrivkey">
|
||||
<button class="btn btn-secondary" type="button" id="togglePrivkey">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||
<path
|
||||
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Your private key will be encrypted on the server.</div>
|
||||
|
||||
<div id="nsecKeyInput" class="mb-3" style="display: none;">
|
||||
<label for="botNsecKey" class="form-label">NSEC Private Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="botNsecKey" placeholder="nsec1...">
|
||||
<button class="btn btn-secondary" type="button" id="toggleNsecKey">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-eye" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||
<path
|
||||
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Enter your private key in nsec format. Your public key will be
|
||||
derived automatically.</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -221,8 +224,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/main.js"></script>
|
||||
<!-- Bot Settings Modal -->
|
||||
<div class="modal fade" id="botSettingsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Bot Settings</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="bot-settings-form">
|
||||
<!-- Basic fields -->
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="botSettingsName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsDisplayName" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="botSettingsDisplayName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsBio" class="form-label">Bio</label>
|
||||
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsNip05" class="form-label">NIP-05</label>
|
||||
<input type="text" class="form-control" id="botSettingsNip05">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsZap" class="form-label">Zap Address</label>
|
||||
<input type="text" class="form-control" id="botSettingsZap">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Post config fields -->
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>
|
||||
<input type="number" class="form-control" id="botSettingsInterval" value="60">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botSettingsHashtags" class="form-label">Hashtags (comma-separated)</label>
|
||||
<input type="text" class="form-control" id="botSettingsHashtags">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-primary" id="botSettingsSaveBtn" onclick="saveBotSettings()">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user