Content page mostly working

This commit is contained in:
Enki 2025-03-01 22:53:36 -08:00
parent 964f812897
commit 559982b525
24 changed files with 2978 additions and 611 deletions

Binary file not shown.

View File

@ -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.

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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"
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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"`

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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"
}

View File

@ -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
View 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);
}
}
});

View File

@ -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
View 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>

View File

@ -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>