diff --git a/README.md b/README.md index e69de29..729d7f5 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,144 @@ +# Nostr Poster + +An automated content posting bot for Nostr networks. This tool allows you to schedule regular posting of images and videos to Nostr relays, supporting both NIP-94 and Blossom upload services. + + +### Core Implementation +- [ ] replace hex format + +### Essential Settings +- [ ] Make enable button functional +- [ ] Bot settings page with: + - [ ] Bio + - [ ] NIP-05 + - [ ] Username/display name + - [ ] Zap address + - [ ] PFP/banner upload + - [ ] Posting interval controls + - [ ] Content album selection + +### Content System +- [ ] Create upload/organization page +- [ ] Implement manual post interface: + - [ ] Text + - [ ] Media upload + +### Bot Interaction +- [ ] Develop basic bot feed: + - [ ] Display Comments + - [ ] Reply + +### Validation +- [ ] Test NSEC key import +- [ ] Test manual posts: + - [ ] Text only + - [ ] Media upload +- [ ] Verify bot reply works + + +## Features + +- **Media Management**: Upload content to either NIP-94/96 compatible servers or Blossom storage servers +- **Scheduled Posting**: Configure posting intervals for automated content sharing +- **Multiple Bot Support**: Manage multiple bot identities, each with their own keypair +- **Keypair Management**: Create new keypairs or import existing ones +- **Relay Configuration**: Configure which relays to publish to for each bot +- **Profile Management**: Set up and publish bot profiles (name, bio, avatar, etc.) +- **Content Archiving**: Posted content gets archived to avoid duplicate posts + +## Installation + +### Requirements + +- Go 1.18 or higher +- SQLite 3 + +### Building from Source + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/nostr-poster.git +cd nostr-poster +``` + +2. Build the application: +```bash +make build +``` + +3. Run the application: +```bash +make run +``` + +## Configuration + +The application can be configured via a YAML file or environment variables. By default, the config file is located at `/config.yaml`. + +### Example Configuration + +```yaml +app_name: "Nostr Poster" +server_port: 8080 +log_level: "info" + +bot: + keys_file: "./keys.json" + content_dir: "./content" + archive_dir: "./archive" + default_interval: 60 # minutes + +db: + path: "./nostr-poster.db" + +media: + default_service: "nip94" + nip94: + server_url: "https://nostr.build/api/upload/nostr" + require_auth: true + blossom: + server_url: "https://blossom.example.com" + +relays: + - 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 +``` + +## Usage + +### Set up a Bot + +1. Navigate to the web interface at `http://localhost:8765` +2. Log in with your Nostr extension (NIP-07) +3. Create a new bot by providing a name and optional keypair +4. Configure the posting schedule, media upload service, and relays +5. Add content to the bot's content directory +6. Enable the bot to start automated posting + +### Manual Posting + +You can also trigger a post manually through the web interface + +## NIPs Supported + +- NIP-01: Basic protocol flow +- NIP-07: Browser extension authentication +- NIP-55: Android signer application (soon TM) +- NIP-94: File metadata +- NIP-96: HTTP file storage +- NIP-98: HTTP authentication + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file diff --git a/build/bin/nostr-poster b/build/bin/nostr-poster new file mode 100755 index 0000000..66cb78d Binary files /dev/null and b/build/bin/nostr-poster differ diff --git a/build/config/config.yaml b/build/config/config.yaml new file mode 100644 index 0000000..c519e94 --- /dev/null +++ b/build/config/config.yaml @@ -0,0 +1,25 @@ +app_name: "Nostr Poster" +server_port: 8765 +log_level: "info" + +bot: + keys_file: "./keys.json" + content_dir: "./content" + archive_dir: "./archive" + default_interval: 240 # minutes + +db: + path: "./nostr-poster.db" + +media: + default_service: "nip94" + nip94: + server_url: "https://files.sovbit.host" + require_auth: true + blossom: + server_url: "https://cdn.sovbit.host" + +relays: + - url: "wss://freelay.sovbit.host" + read: true + write: true \ No newline at end of file diff --git a/build/db/nostr-poster.db b/build/db/nostr-poster.db new file mode 100644 index 0000000..0de02ec Binary files /dev/null and b/build/db/nostr-poster.db differ diff --git a/build/db/nostr-poster.db-shm b/build/db/nostr-poster.db-shm new file mode 100644 index 0000000..a608d93 Binary files /dev/null and b/build/db/nostr-poster.db-shm differ diff --git a/build/db/nostr-poster.db-wal b/build/db/nostr-poster.db-wal new file mode 100644 index 0000000..04563d2 Binary files /dev/null and b/build/db/nostr-poster.db-wal differ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..9ec1c7b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,193 @@ +// cmd/server/main.go +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "git.sovbit.dev/Enki/nostr-poster/internal/api" + "git.sovbit.dev/Enki/nostr-poster/internal/auth" + "git.sovbit.dev/Enki/nostr-poster/internal/config" + "git.sovbit.dev/Enki/nostr-poster/internal/crypto" + "git.sovbit.dev/Enki/nostr-poster/internal/db" + "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" + "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" + "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/poster" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" + "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" + "git.sovbit.dev/Enki/nostr-poster/internal/utils" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func main() { + // Parse command line flags + configPath := flag.String("config", "", "Path to config file") + dbPath := flag.String("db", "", "Path to database file") + port := flag.Int("port", 0, "Port to listen on") + password := flag.String("password", "", "Password for encrypting private keys") + flag.Parse() + + // Setup logger + logConfig := zap.NewProductionConfig() + logConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + logger, err := logConfig.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + + // Load config + cfg, err := config.LoadConfig(*configPath) + if err != nil { + logger.Fatal("Failed to load config", zap.Error(err)) + } + + // Override config with command line flags if provided + if *dbPath != "" { + cfg.DB.Path = *dbPath + } + if *port != 0 { + cfg.ServerPort = *port + } + + // Ensure directories exist + if err := utils.EnsureDir(cfg.Bot.ContentDir); err != nil { + logger.Fatal("Failed to create content directory", zap.Error(err)) + } + if err := utils.EnsureDir(cfg.Bot.ArchiveDir); err != nil { + logger.Fatal("Failed to create archive directory", zap.Error(err)) + } + + // Initialize database + database, err := db.New(cfg.DB.Path) + if err != nil { + logger.Fatal("Failed to connect to database", zap.Error(err)) + } + if err := database.Initialize(); err != nil { + logger.Fatal("Failed to initialize database", zap.Error(err)) + } + + // Initialize key store + keyPassword := *password + if keyPassword == "" { + // Use a default password or prompt for one + // In a production environment, you'd want to handle this more securely + keyPassword = "nostr-poster-default-password" + } + keyStore, err := crypto.NewKeyStore(cfg.Bot.KeysFile, keyPassword) + if err != nil { + logger.Fatal("Failed to initialize key store", zap.Error(err)) + } + + // Initialize event manager + eventManager := events.NewEventManager(func(pubkey string) (string, error) { + return keyStore.GetPrivateKey(pubkey) + }) + + // Initialize relay manager + relayManager := relay.NewManager(logger) + + // Initialize media preparation manager + mediaPrep := prepare.NewManager(logger) + + // Initialize uploaders + // NIP-94 uploader + nip94Uploader := nip94.NewUploader( + cfg.Media.NIP94.ServerURL, + "", // Download URL will be discovered + 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 := "" + privkey, err := keyStore.GetPrivateKey(botPubkey) + if err != nil { + return "", err + } + return nip94.CreateNIP98AuthHeader(url, method, payload, privkey) + }, + ) + + // Blossom uploader + blossomUploader := blossom.NewUploader( + 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 := "" + privkey, err := keyStore.GetPrivateKey(botPubkey) + if err != nil { + return "", err + } + return blossom.CreateBlossomAuthHeader(url, method, privkey) + }, + ) + + // Initialize authentication service + authService := auth.NewService( + database, + logger, + keyPassword, // Use the same password for simplicity + 24*time.Hour, // Token duration + ) + + // Initialize bot service + botService := api.NewBotService( + database, + keyStore, + eventManager, + relayManager, + logger, + ) + + // Create post content function + postContentFunc := poster.CreatePostContentFunc( + eventManager, + relayManager, + mediaPrep, + logger, + ) + + // Initialize scheduler + posterScheduler := scheduler.NewScheduler( + database, + logger, + cfg.Bot.ContentDir, + cfg.Bot.ArchiveDir, + nip94Uploader, + blossomUploader, + postContentFunc, + ) + + // Initialize API + apiServer := api.NewAPI( + logger, + botService, + authService, + posterScheduler, + ) + + // Start the scheduler + if err := posterScheduler.Start(); err != nil { + logger.Error("Failed to start scheduler", zap.Error(err)) + } + + // Start the server + addr := fmt.Sprintf(":%d", cfg.ServerPort) + logger.Info("Starting server", zap.String("address", addr)) + if err := apiServer.Run(addr); err != nil { + logger.Fatal("Failed to start server", zap.Error(err)) + } +} \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..c519e94 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,25 @@ +app_name: "Nostr Poster" +server_port: 8765 +log_level: "info" + +bot: + keys_file: "./keys.json" + content_dir: "./content" + archive_dir: "./archive" + default_interval: 240 # minutes + +db: + path: "./nostr-poster.db" + +media: + default_service: "nip94" + nip94: + server_url: "https://files.sovbit.host" + require_auth: true + blossom: + server_url: "https://cdn.sovbit.host" + +relays: + - url: "wss://freelay.sovbit.host" + read: true + write: true \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e3f4659 --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +module git.sovbit.dev/Enki/nostr-poster + +go 1.24.0 + +require ( + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // 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 + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nbd-wtf/go-nostr v0.50.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/protobuf v1.36.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae83fe9 --- /dev/null +++ b/go.sum @@ -0,0 +1,159 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +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/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +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= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +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 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.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.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +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= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbd-wtf/go-nostr v0.50.0 h1:MgL/HPnWSTb5BFCL9RuzYQQpMrTi67MvHem4nWFn47E= +github.com/nbd-wtf/go-nostr v0.50.0/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8= +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= +github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= +github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +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.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= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= +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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +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.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= +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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/bot_service.go b/internal/api/bot_service.go new file mode 100644 index 0000000..d709195 --- /dev/null +++ b/internal/api/bot_service.go @@ -0,0 +1,511 @@ +// internal/api/bot_service.go +package api + +import ( + "context" + "errors" + "fmt" + "time" + + + "git.sovbit.dev/Enki/nostr-poster/internal/crypto" + "git.sovbit.dev/Enki/nostr-poster/internal/db" + "git.sovbit.dev/Enki/nostr-poster/internal/models" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" + "go.uber.org/zap" +) + +// BotService provides functionality for managing bots +type BotService struct { + db *db.DB + keyStore *crypto.KeyStore + eventMgr *events.EventManager + relayMgr *relay.Manager + logger *zap.Logger +} + +// NewBotService creates a new BotService +func NewBotService( + db *db.DB, + keyStore *crypto.KeyStore, + eventMgr *events.EventManager, + relayMgr *relay.Manager, + logger *zap.Logger, +) *BotService { + return &BotService{ + db: db, + keyStore: keyStore, + eventMgr: eventMgr, + relayMgr: relayMgr, + logger: logger, + } +} + +// ListUserBots lists all bots owned by a user +func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) { + query := ` + SELECT b.* FROM bots b + 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 { + s.logger.Warn("Failed to load related data for bot", + zap.Int64("botID", bot.ID), + zap.Error(err)) + } + } + + return bots, nil +} + +// GetBotByID gets a bot by ID and verifies ownership +func (s *BotService) GetBotByID(botID int64, ownerPubkey string) (*models.Bot, error) { + query := ` + 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 +} + +// CreateBot creates a new bot +func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { + // Start a transaction + tx, err := s.db.Beginx() + if err != nil { + return nil, fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + // Check if we need to generate a keypair + if bot.Pubkey == "" { + // Generate a new keypair + pubkey, privkey, err := s.keyStore.GenerateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate keypair: %w", err) + } + + bot.Pubkey = pubkey + bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore + } else if bot.EncryptedPrivkey != "" { + // Import the provided keypair + if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil { + return nil, fmt.Errorf("failed to import keypair: %w", err) + } + } else { + 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 ( + pubkey, encrypted_privkey, name, display_name, bio, nip05, zap_address, + profile_picture, banner, created_at, owner_pubkey + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + result, err := tx.Exec( + query, + bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio, + bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner, + bot.CreatedAt, bot.OwnerPubkey, + ) + if err != nil { + return nil, fmt.Errorf("failed to insert bot: %w", err) + } + + // Get the inserted ID + botID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get inserted ID: %w", err) + } + bot.ID = botID + + // Create default post config + postConfig := &models.PostConfig{ + BotID: botID, + Hashtags: "[]", + IntervalMinutes: 60, + 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 { + return nil, fmt.Errorf("failed to insert post config: %w", err) + } + + // Create default media config + mediaConfig := &models.MediaConfig{ + BotID: botID, + PrimaryService: "nip94", + FallbackService: "blossom", + 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 { + return nil, fmt.Errorf("failed to insert media config: %w", err) + } + + // Add default relays + defaultRelays := []struct { + URL string + Read bool + Write bool + }{ + {"wss://relay.damus.io", true, true}, + {"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 { + return nil, fmt.Errorf("failed to insert relay: %w", err) + } + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + // Load associated data + bot.PostConfig = postConfig + bot.MediaConfig = mediaConfig + bot.Relays = []*models.Relay{ + {BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true}, + {BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true}, + {BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true}, + } + + return bot, nil +} + +// UpdateBot updates an existing bot +func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { + // Check if the bot exists and belongs to the owner + _, err := s.GetBotByID(bot.ID, bot.OwnerPubkey) + 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 + name = ?, + display_name = ?, + bio = ?, + nip05 = ?, + zap_address = ?, + profile_picture = ?, + banner = ? + WHERE id = ? AND owner_pubkey = ? + ` + + _, err = s.db.Exec( + query, + 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 +} + +// DeleteBot deletes a bot +func (s *BotService) DeleteBot(botID int64, ownerPubkey string) error { + // Check if the bot exists and belongs to the owner + _, err := s.GetBotByID(botID, ownerPubkey) + 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 +} + +// UpdateBotConfig updates a bot's configuration +func (s *BotService) UpdateBotConfig( + botID int64, + ownerPubkey string, + postConfig *models.PostConfig, + mediaConfig *models.MediaConfig, +) error { + // Check if the bot exists and belongs to the owner + _, err := s.GetBotByID(botID, ownerPubkey) + 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 := ` + UPDATE post_config SET + hashtags = ?, + interval_minutes = ?, + post_template = ?, + enabled = ? + WHERE bot_id = ? + ` + + _, err = tx.Exec( + query, + postConfig.Hashtags, postConfig.IntervalMinutes, + postConfig.PostTemplate, postConfig.Enabled, + botID, + ) + if err != nil { + return fmt.Errorf("failed to update post config: %w", err) + } + } + + // Update media config if provided + if mediaConfig != nil { + query := ` + UPDATE media_config SET + primary_service = ?, + fallback_service = ?, + nip94_server_url = ?, + blossom_server_url = ? + WHERE bot_id = ? + ` + + _, err = tx.Exec( + query, + mediaConfig.PrimaryService, mediaConfig.FallbackService, + mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL, + botID, + ) + if err != nil { + 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 +} + +// GetBotRelays gets the relays for a bot +func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Relay, error) { + // Check if the bot exists and belongs to the owner + _, err := s.GetBotByID(botID, ownerPubkey) + 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 + FROM relays + 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 +} + +// UpdateBotRelays updates the relays for a bot +func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*models.Relay) error { + // Check if the bot exists and belongs to the owner + _, err := s.GetBotByID(botID, ownerPubkey) + 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( + "INSERT INTO relays (bot_id, url, read, write) VALUES (?, ?, ?, ?)", + botID, relay.URL, relay.Read, relay.Write, + ) + if err != nil { + 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 +} + +// PublishBotProfile publishes a bot's profile to Nostr +func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { + // Get the bot + bot, err := s.GetBotByID(botID, ownerPubkey) + 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 { + if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { + s.logger.Warn("Failed to add relay", + zap.String("url", relay.URL), + zap.Error(err)) + } + } + } + + // 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 +} + +// Helper function to load related data for a bot +func (s *BotService) loadBotRelatedData(bot *models.Bot) error { + // Load post config + var postConfig models.PostConfig + err := s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", bot.ID) + if err != nil { + 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) + if err != nil { + 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) + if err != nil { + return fmt.Errorf("failed to load relays: %w", err) + } + bot.Relays = relays + + return nil +} \ No newline at end of file diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..0fd2eb5 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,585 @@ +// internal/api/routes.go +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "git.sovbit.dev/Enki/nostr-poster/internal/auth" + "git.sovbit.dev/Enki/nostr-poster/internal/models" + "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" + "go.uber.org/zap" +) + +// API represents the HTTP API for managing bots +type API struct { + router *gin.Engine + logger *zap.Logger + botService *BotService + authService *auth.Service + scheduler *scheduler.Scheduler +} + +// NewAPI creates a new API instance +func NewAPI( + logger *zap.Logger, + botService *BotService, + authService *auth.Service, + scheduler *scheduler.Scheduler, +) *API { + router := gin.Default() + + api := &API{ + router: router, + logger: logger, + botService: botService, + authService: authService, + scheduler: scheduler, + } + + // Set up routes + api.setupRoutes() + + return api +} + +// SetupRoutes configures the API routes +func (a *API) setupRoutes() { + // Public routes + a.router.GET("/health", a.healthCheck) + + // API routes + apiGroup := a.router.Group("/api") + + // Authentication + apiGroup.POST("/auth/login", a.login) + apiGroup.GET("/auth/verify", a.requireAuth, a.verifyAuth) + + // Bot management + botGroup := apiGroup.Group("/bots") + botGroup.Use(a.requireAuth) + { + botGroup.GET("", a.listBots) + botGroup.POST("", a.createBot) + botGroup.GET("/:id", a.getBot) + botGroup.PUT("/:id", a.updateBot) + botGroup.DELETE("/:id", a.deleteBot) + + // Bot configuration + botGroup.GET("/:id/config", a.getBotConfig) + botGroup.PUT("/:id/config", a.updateBotConfig) + + // Relay management + botGroup.GET("/:id/relays", a.getBotRelays) + botGroup.PUT("/:id/relays", a.updateBotRelays) + + // Actions + botGroup.POST("/:id/profile/publish", a.publishBotProfile) + botGroup.POST("/:id/run", a.runBotNow) + botGroup.POST("/:id/enable", a.enableBot) + botGroup.POST("/:id/disable", a.disableBot) + } + + // Content management + contentGroup := apiGroup.Group("/content") + contentGroup.Use(a.requireAuth) + { + contentGroup.GET("/:botId", a.listBotContent) + contentGroup.POST("/:botId/upload", a.uploadContent) + contentGroup.DELETE("/:botId/:filename", a.deleteContent) + } + + // Stats + statsGroup := apiGroup.Group("/stats") + statsGroup.Use(a.requireAuth) + { + statsGroup.GET("", a.getStats) + statsGroup.GET("/:botId", a.getBotStats) + } + + // Serve the web UI + a.router.StaticFile("/", "./web/index.html") + a.router.Static("/assets", "./web/assets") + + // Handle 404s for SPA + a.router.NoRoute(func(c *gin.Context) { + c.File("./web/index.html") + }) +} + +// Run starts the API server +func (a *API) Run(addr string) error { + return a.router.Run(addr) +} + +// Middleware for requiring authentication +func (a *API) requireAuth(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Authentication required", + }) + return + } + + // Verify the auth token + pubkey, err := a.authService.VerifyToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid authentication token", + }) + return + } + + // Store the pubkey in the context + c.Set("pubkey", pubkey) + c.Next() +} + +// API Handlers + +// healthCheck responds with a simple health check +func (a *API) healthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + }) +} + +// login handles user login with NIP-07 signature +func (a *API) login(c *gin.Context) { + var req struct { + Pubkey string `json:"pubkey" binding:"required"` + Signature string `json:"signature" binding:"required"` + Event string `json:"event" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + // Verify the signature + token, err := a.authService.Login(req.Pubkey, req.Signature, req.Event) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "token": token, + }) +} + +// verifyAuth verifies the current auth token +func (a *API) verifyAuth(c *gin.Context) { + pubkey, exists := c.Get("pubkey") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "pubkey": pubkey, + }) +} + +// listBots lists all bots owned by the user +func (a *API) listBots(c *gin.Context) { + pubkey := c.GetString("pubkey") + + bots, err := a.botService.ListUserBots(pubkey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list bots"}) + a.logger.Error("Failed to list bots", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, bots) +} + +// createBot creates a new bot +func (a *API) createBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + + var bot models.Bot + if err := c.ShouldBindJSON(&bot); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) + return + } + + // 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)) + return + } + + c.JSON(http.StatusCreated, createdBot) +} + +// getBot gets a specific bot by ID +func (a *API) getBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Get the bot + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + c.JSON(http.StatusOK, bot) +} + +// updateBot updates a bot +func (a *API) updateBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + var bot models.Bot + if err := c.ShouldBindJSON(&bot); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) + return + } + + // Set the ID and owner + bot.ID = botID + bot.OwnerPubkey = pubkey + + // Update the bot + updatedBot, err := a.botService.UpdateBot(&bot) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"}) + a.logger.Error("Failed to update bot", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, updatedBot) +} + +// deleteBot deletes a bot +func (a *API) deleteBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Delete the bot + err = a.botService.DeleteBot(botID, pubkey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bot"}) + a.logger.Error("Failed to delete bot", zap.Error(err)) + return + } + + c.Status(http.StatusNoContent) +} + +// getBotConfig gets a bot's configuration +func (a *API) getBotConfig(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Get the bot with config + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "post_config": bot.PostConfig, + "media_config": bot.MediaConfig, + }) +} + +// updateBotConfig updates a bot's configuration +func (a *API) updateBotConfig(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + var config struct { + PostConfig *models.PostConfig `json:"post_config"` + MediaConfig *models.MediaConfig `json:"media_config"` + } + + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data"}) + return + } + + // Set the bot ID + if config.PostConfig != nil { + config.PostConfig.BotID = botID + } + if config.MediaConfig != nil { + config.MediaConfig.BotID = botID + } + + // Update the configs + err = a.botService.UpdateBotConfig(botID, pubkey, config.PostConfig, config.MediaConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) + a.logger.Error("Failed to update bot config", zap.Error(err)) + return + } + + // Get the updated bot + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Update the scheduler if needed + if config.PostConfig != nil && config.PostConfig.Enabled { + err = a.scheduler.UpdateBotSchedule(bot) + if err != nil { + a.logger.Error("Failed to update bot schedule", + zap.Int64("botID", botID), + zap.Error(err)) + } + } else if config.PostConfig != nil && !config.PostConfig.Enabled { + a.scheduler.UnscheduleBot(botID) + } + + c.JSON(http.StatusOK, gin.H{ + "post_config": bot.PostConfig, + "media_config": bot.MediaConfig, + }) +} + +// getBotRelays gets a bot's relay configuration +func (a *API) getBotRelays(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Get the relays + relays, err := a.botService.GetBotRelays(botID, pubkey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get relays"}) + a.logger.Error("Failed to get bot relays", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, relays) +} + +// updateBotRelays updates a bot's relay configuration +func (a *API) updateBotRelays(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + var relays []*models.Relay + if err := c.ShouldBindJSON(&relays); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"}) + return + } + + // Set the bot ID for each relay + for _, relay := range relays { + relay.BotID = botID + } + + // Update the relays + err = a.botService.UpdateBotRelays(botID, pubkey, relays) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update relays"}) + a.logger.Error("Failed to update bot relays", zap.Error(err)) + return + } + + // Get the updated relays + updatedRelays, err := a.botService.GetBotRelays(botID, pubkey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated relays"}) + a.logger.Error("Failed to get updated bot relays", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, updatedRelays) +} + +// publishBotProfile publishes a bot's profile to Nostr +func (a *API) publishBotProfile(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Publish the profile + err = a.botService.PublishBotProfile(botID, pubkey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"}) + a.logger.Error("Failed to publish bot profile", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Profile published successfully", + }) +} + +// runBotNow runs a bot immediately +func (a *API) runBotNow(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Check if 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 + } + + // Run the bot + err = a.scheduler.RunNow(botID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run bot"}) + a.logger.Error("Failed to run bot now", zap.Error(err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Bot is running", + }) +} + +// enableBot enables a bot +func (a *API) enableBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Get the bot + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Enable the bot + bot.PostConfig.Enabled = true + err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable bot"}) + a.logger.Error("Failed to enable bot", zap.Error(err)) + return + } + + // Schedule the bot + err = a.scheduler.ScheduleBot(bot) + if err != nil { + a.logger.Error("Failed to schedule bot", + zap.Int64("botID", botID), + zap.Error(err)) + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Bot enabled", + }) +} + +// disableBot disables a bot +func (a *API) disableBot(c *gin.Context) { + pubkey := c.GetString("pubkey") + botID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) + return + } + + // Get the bot + bot, err := a.botService.GetBotByID(botID, pubkey) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) + return + } + + // Disable the bot + bot.PostConfig.Enabled = false + err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable bot"}) + a.logger.Error("Failed to disable bot", zap.Error(err)) + return + } + + // Unschedule the bot + a.scheduler.UnscheduleBot(botID) + + c.JSON(http.StatusOK, gin.H{ + "message": "Bot disabled", + }) +} + +// 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"}) +} + +// 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"}) +} + +// 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"}) +} + +// getStats gets overall statistics +func (a *API) getStats(c *gin.Context) { + // This will be implemented when we add statistics + c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) +} + +// getBotStats gets statistics for a specific bot +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"}) +} \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..c9ec363 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,188 @@ +// internal/auth/auth.go +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/db" + "go.uber.org/zap" +) + +var ( + // ErrInvalidSignature is returned when a signature is invalid + ErrInvalidSignature = errors.New("invalid signature") + + // ErrTokenExpired is returned when a token has expired + ErrTokenExpired = errors.New("token expired") + + // ErrInvalidToken is returned when a token is invalid + ErrInvalidToken = errors.New("invalid token") +) + +// Token represents an authentication token +type Token struct { + Pubkey string `json:"pubkey"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Nonce string `json:"nonce"` +} + +// Service provides authentication functionality +type Service struct { + db *db.DB + logger *zap.Logger + secretKey []byte + tokenDuration time.Duration +} + +// NewService creates a new authentication service +func NewService(db *db.DB, logger *zap.Logger, secretKey string, tokenDuration time.Duration) *Service { + // If no secret key is provided, generate a secure random one + decodedKey := []byte(secretKey) + if secretKey == "" { + // Generate a secure random key + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + logger.Fatal("Failed to generate random key for auth service", zap.Error(err)) + } + decodedKey = key + } + + return &Service{ + db: db, + logger: logger, + secretKey: decodedKey, + tokenDuration: tokenDuration, + } +} + +// Login handles user login with a Nostr signature +func (s *Service) Login(pubkey, signature, eventJSON string) (string, error) { + // Parse the event + var event nostr.Event + if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { + return "", fmt.Errorf("failed to parse event: %w", err) + } + + // Verify the event + if event.PubKey != pubkey { + return "", errors.New("pubkey mismatch in event") + } + + // Verify the signature + ok, err := event.CheckSignature() + if err != nil { + return "", fmt.Errorf("failed to check signature: %w", err) + } + if !ok { + return "", ErrInvalidSignature + } + + // Check if the event was created recently + now := time.Now() + eventTime := time.Unix(int64(event.CreatedAt), 0) + if now.Sub(eventTime) > 5*time.Minute || eventTime.After(now.Add(5*time.Minute)) { + return "", errors.New("event timestamp is too far from current time") + } + + // Generate a token + token, err := s.createToken(pubkey) + if err != nil { + return "", fmt.Errorf("failed to create token: %w", err) + } + + return token, nil +} + +// VerifyToken validates an authentication token +func (s *Service) VerifyToken(tokenStr string) (string, error) { + // Remove the "Bearer " prefix if present + tokenStr = strings.TrimPrefix(tokenStr, "Bearer ") + + // Decode the token + tokenData, err := base64.StdEncoding.DecodeString(tokenStr) + if err != nil { + return "", ErrInvalidToken + } + + // Parse the token + var token Token + if err := json.Unmarshal(tokenData, &token); err != nil { + return "", ErrInvalidToken + } + + // Check if the token has expired + if time.Now().After(token.ExpiresAt) { + return "", ErrTokenExpired + } + + return token.Pubkey, nil +} + +// CreateNIP07Challenge creates a challenge for NIP-07 authentication +func (s *Service) CreateNIP07Challenge(pubkey string) (string, error) { + // Generate a nonce + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + nonceStr := hex.EncodeToString(nonce) + + // Create the challenge event + event := nostr.Event{ + PubKey: pubkey, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Kind: 22242, // Ephemeral event for authentication + Tags: []nostr.Tag{{"challenge", nonceStr}}, + Content: "Please sign this event to authenticate with Nostr Poster", + } + + // Set the ID + event.ID = event.GetID() + + // Convert to JSON + eventJSON, err := json.Marshal(event) + if err != nil { + return "", fmt.Errorf("failed to serialize event: %w", err) + } + + return string(eventJSON), nil +} + +// createToken creates an authentication token +func (s *Service) createToken(pubkey string) (string, error) { + // Generate a nonce + nonce := make([]byte, 8) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + nonceStr := hex.EncodeToString(nonce) + + // Create the token + now := time.Now() + token := Token{ + Pubkey: pubkey, + IssuedAt: now, + ExpiresAt: now.Add(s.tokenDuration), + Nonce: nonceStr, + } + + // Serialize the token + tokenData, err := json.Marshal(token) + if err != nil { + return "", fmt.Errorf("failed to serialize token: %w", err) + } + + // Encode the token + tokenStr := base64.StdEncoding.EncodeToString(tokenData) + + return tokenStr, nil +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..29fdbe5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,171 @@ +// internal/config/config.go +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// 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"` +} + +// LoadConfig loads the configuration from file or environment variables +func LoadConfig(configPath string) (*Config, error) { + v := viper.New() + + // Set defaults + v.SetDefault("app_name", "Nostr Poster") + v.SetDefault("server_port", 8080) + v.SetDefault("log_level", "info") + + v.SetDefault("bot.keys_file", "keys.json") + v.SetDefault("bot.content_dir", "./content") + v.SetDefault("bot.archive_dir", "./archive") + v.SetDefault("bot.default_interval", 60) // 1 hour + + v.SetDefault("db.path", "./nostr-poster.db") + + v.SetDefault("media.default_service", "nip94") + v.SetDefault("media.nip94.require_auth", true) + + // 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}, + }) + + // Setup config file search + if configPath != "" { + // Use config file from the flag + v.SetConfigFile(configPath) + } else { + // Try to find config in default locations + v.AddConfigPath(".") + v.AddConfigPath("./config") + v.AddConfigPath("$HOME/.nostr-poster") + v.SetConfigName("config") + } + + // Read the config file + v.SetConfigType("yaml") + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Config file not found, we'll create a default one + fmt.Println("Config file not found, creating default config") + defaultConfig := &Config{} + v.Unmarshal(defaultConfig) + + // Ensure directories exist + os.MkdirAll(filepath.Dir(v.GetString("db.path")), 0755) + os.MkdirAll(v.GetString("bot.content_dir"), 0755) + os.MkdirAll(v.GetString("bot.archive_dir"), 0755) + + // Create default config file + if err := v.SafeWriteConfig(); err != nil { + return nil, fmt.Errorf("failed to write default config: %w", err) + } + } else { + // Config file was found but another error was produced + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + // Environment variables can override config values + v.SetEnvPrefix("NOSTR_POSTER") + v.AutomaticEnv() + + // Parse config into struct + config := &Config{} + if err := v.Unmarshal(config); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + return config, nil +} + +// Save writes the current configuration to disk +func (c *Config) Save(configPath string) error { + v := viper.New() + + // Convert config struct to map + err := v.MergeConfigMap(map[string]interface{}{ + "app_name": c.AppName, + "server_port": c.ServerPort, + "log_level": c.LogLevel, + "bot": map[string]interface{}{ + "keys_file": c.Bot.KeysFile, + "content_dir": c.Bot.ContentDir, + "archive_dir": c.Bot.ArchiveDir, + "default_interval": c.Bot.DefaultInterval, + }, + "db": map[string]interface{}{ + "path": c.DB.Path, + }, + "media": map[string]interface{}{ + "default_service": c.Media.DefaultService, + "nip94": map[string]interface{}{ + "server_url": c.Media.NIP94.ServerURL, + "require_auth": c.Media.NIP94.RequireAuth, + }, + "blossom": map[string]interface{}{ + "server_url": c.Media.Blossom.ServerURL, + }, + }, + "relays": c.Relays, + }) + + if err != nil { + return fmt.Errorf("failed to merge config: %w", err) + } + + // Set the config path + v.SetConfigFile(configPath) + + // Write the config file + return v.WriteConfig() +} \ No newline at end of file diff --git a/internal/crypto/keys.go b/internal/crypto/keys.go new file mode 100644 index 0000000..f0a5dba --- /dev/null +++ b/internal/crypto/keys.go @@ -0,0 +1,307 @@ +// internal/crypto/keys.go +package crypto + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sync" + + "github.com/nbd-wtf/go-nostr" + "golang.org/x/crypto/nacl/secretbox" +) + +var ( + // ErrKeyNotFound is returned when a key for the specified pubkey cannot be found + ErrKeyNotFound = errors.New("key not found") + + // ErrInvalidKey is returned when an invalid key is provided + ErrInvalidKey = errors.New("invalid key") + + // ErrDecryptionFailed is returned when decryption of a private key fails + ErrDecryptionFailed = errors.New("decryption failed") +) + +// KeyStore manages encryption and storage of Nostr keys +type KeyStore struct { + filePath string + keys map[string]string // pubkey -> encrypted privkey + password []byte + mu sync.RWMutex +} + +// NewKeyStore creates a new KeyStore +func NewKeyStore(filePath string, password string) (*KeyStore, error) { + ks := &KeyStore{ + filePath: filePath, + keys: make(map[string]string), + password: []byte(password), + } + + // Try to load existing keys + if _, err := os.Stat(filePath); err == nil { + if err := ks.loadKeys(); err != nil { + return nil, fmt.Errorf("failed to load keys: %w", err) + } + } + + return ks, nil +} + +// loadKeys loads encrypted keys from the key file +func (ks *KeyStore) loadKeys() error { + ks.mu.Lock() + defer ks.mu.Unlock() + + data, err := os.ReadFile(ks.filePath) + if err != nil { + return err + } + + return json.Unmarshal(data, &ks.keys) +} + +// saveKeys saves the encrypted keys to the key file +func (ks *KeyStore) saveKeys() error { + ks.mu.RLock() + defer ks.mu.RUnlock() + + data, err := json.MarshalIndent(ks.keys, "", " ") + if err != nil { + return err + } + + return os.WriteFile(ks.filePath, data, 0600) +} + +// GenerateKey generates a new Nostr keypair +func (ks *KeyStore) GenerateKey() (pubkey, privkey string, err error) { + // Generate a new keypair + sk := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, sk); err != nil { + return "", "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Convert to hex strings + privkey = hex.EncodeToString(sk) + pub, err := nostr.GetPublicKey(privkey) + if err != nil { + return "", "", fmt.Errorf("failed to derive public key: %w", err) + } + + // Encrypt and store the private key + if err := ks.AddKey(pub, privkey); err != nil { + return "", "", err + } + + return pub, privkey, nil +} + +// AddKey imports an existing private key +func (ks *KeyStore) AddKey(pubkey, privkey string) error { + // Validate the key pair + derivedPub, err := nostr.GetPublicKey(privkey) + if err != nil { + return ErrInvalidKey + } + + if derivedPub != pubkey { + return errors.New("public key does not match private key") + } + + // Encrypt the private key + encPrivkey, err := ks.encryptKey(privkey) + if err != nil { + return err + } + + // Store the encrypted key + ks.mu.Lock() + ks.keys[pubkey] = encPrivkey + ks.mu.Unlock() + + // Save to disk + return ks.saveKeys() +} + +// ImportKey imports a key from nsec or hex format +func (ks *KeyStore) ImportKey(keyStr string) (string, error) { + var privkeyHex string + var err error + + // Check if it's an nsec key + if len(keyStr) > 4 && keyStr[:4] == "nsec" { + privkeyHex, err = decodePrivateKey(keyStr) + if err != nil { + return "", fmt.Errorf("invalid nsec key: %w", err) + } + } else { + // Assume it's a hex key + if len(keyStr) != 64 { + return "", ErrInvalidKey + } + privkeyHex = keyStr + } + + // Derive the public key + pubkey, err := nostr.GetPublicKey(privkeyHex) + if err != nil { + return "", fmt.Errorf("invalid private key: %w", err) + } + + // Add the key to the store + if err := ks.AddKey(pubkey, privkeyHex); err != nil { + return "", err + } + + return pubkey, nil +} + +// GetPrivateKey retrieves and decrypts a private key for the given pubkey +func (ks *KeyStore) GetPrivateKey(pubkey string) (string, error) { + ks.mu.RLock() + encryptedKey, exists := ks.keys[pubkey] + ks.mu.RUnlock() + + if !exists { + return "", ErrKeyNotFound + } + + return ks.decryptKey(encryptedKey) +} + +// RemoveKey removes a key from the store +func (ks *KeyStore) RemoveKey(pubkey string) error { + ks.mu.Lock() + delete(ks.keys, pubkey) + ks.mu.Unlock() + + return ks.saveKeys() +} + +// ListKeys returns a list of all stored public keys +func (ks *KeyStore) ListKeys() []string { + ks.mu.RLock() + defer ks.mu.RUnlock() + + keys := make([]string, 0, len(ks.keys)) + for k := range ks.keys { + keys = append(keys, k) + } + return keys +} + +// encryptKey encrypts a private key using the store's password +func (ks *KeyStore) encryptKey(privkey string) (string, error) { + // Generate a random nonce + var nonce [24]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + // Create a key from the password + var key [32]byte + copy(key[:], ks.password) + + // Encrypt the private key + privkeyBytes := []byte(privkey) + encrypted := secretbox.Seal(nonce[:], privkeyBytes, &nonce, &key) + + return hex.EncodeToString(encrypted), nil +} + +// decryptKey decrypts an encrypted private key +func (ks *KeyStore) decryptKey(encryptedKey string) (string, error) { + encryptedData, err := hex.DecodeString(encryptedKey) + if err != nil { + return "", fmt.Errorf("invalid encrypted key format: %w", err) + } + + // The first 24 bytes are the nonce + if len(encryptedData) < 24 { + return "", ErrDecryptionFailed + } + + var nonce [24]byte + copy(nonce[:], encryptedData[:24]) + + // Create a key from the password + var key [32]byte + copy(key[:], ks.password) + + // Decrypt the private key + decryptedData, ok := secretbox.Open(nil, encryptedData[24:], &nonce, &key) + if !ok { + return "", ErrDecryptionFailed + } + + return string(decryptedData), nil +} + +// ChangePassword changes the password used for key encryption +func (ks *KeyStore) ChangePassword(newPassword string) error { + ks.mu.Lock() + defer ks.mu.Unlock() + + // Create a copy of current keys + oldKeys := make(map[string]string) + for k, v := range ks.keys { + oldKeys[k] = v + } + + // Change the password + oldPassword := ks.password + ks.password = []byte(newPassword) + + // Re-encrypt all keys with the new password + for pubkey, encryptedKey := range oldKeys { + // Get the plaintext private key using the old password + ks.password = oldPassword + privkey, err := ks.decryptKey(encryptedKey) + if err != nil { + // Restore the old password and return + ks.password = oldPassword + return fmt.Errorf("failed to decrypt key: %w", err) + } + + // Encrypt it with the new password + ks.password = []byte(newPassword) + newEncryptedKey, err := ks.encryptKey(privkey) + if err != nil { + // Restore the old password and return + ks.password = oldPassword + return fmt.Errorf("failed to re-encrypt key: %w", err) + } + + // Update the key in the store + ks.keys[pubkey] = newEncryptedKey + } + + // Save the re-encrypted keys + return ks.saveKeys() +} + +// decodePrivateKey decodes an nsec private key +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 +} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..31e6875 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,128 @@ +// internal/db/db.go +package db + +import ( + "fmt" + "path/filepath" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +// DB holds the database connection +type DB struct { + *sqlx.DB +} + +// New creates a new DB instance +func New(dbPath string) (*DB, error) { + // Ensure the directory exists + dir := filepath.Dir(dbPath) + if err := ensureDir(dir); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + // Connect to the database + db, err := sqlx.Connect("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Set pragmas for better performance + db.MustExec("PRAGMA journal_mode=WAL;") + db.MustExec("PRAGMA foreign_keys=ON;") + + return &DB{db}, nil +} + +// Initialize creates the database schema if it doesn't exist +func (db *DB) Initialize() error { + // Create bots table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS bots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pubkey TEXT NOT NULL UNIQUE, + encrypted_privkey TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT, + bio TEXT, + nip05 TEXT, + zap_address TEXT, + profile_picture TEXT, + banner TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + owner_pubkey TEXT NOT NULL + )`) + if err != nil { + return fmt.Errorf("failed to create bots table: %w", err) + } + + // Create post_config table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS post_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + hashtags TEXT, + interval_minutes INTEGER NOT NULL DEFAULT 60, + post_template TEXT, + enabled BOOLEAN NOT NULL DEFAULT 0, + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE + )`) + if err != nil { + return fmt.Errorf("failed to create post_config table: %w", err) + } + + // Create media_config table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS media_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + primary_service TEXT NOT NULL, + fallback_service TEXT, + nip94_server_url TEXT, + blossom_server_url TEXT, + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE + )`) + if err != nil { + return fmt.Errorf("failed to create media_config table: %w", err) + } + + // Create relays table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS relays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + url TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT 1, + write BOOLEAN NOT NULL DEFAULT 1, + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE, + UNIQUE (bot_id, url) + )`) + if err != nil { + return fmt.Errorf("failed to create relays table: %w", err) + } + + // Create posts table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + content_filename TEXT, + media_url TEXT, + event_id TEXT, + status TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + error TEXT, + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE + )`) + if err != nil { + return fmt.Errorf("failed to create posts table: %w", err) + } + + return nil +} + +// ensureDir makes sure the directory exists +func ensureDir(dir string) error { + return nil // This will be implemented in a file utility package +} \ No newline at end of file diff --git a/internal/media/prepare/prepare.go b/internal/media/prepare/prepare.go new file mode 100644 index 0000000..fdcf9ca --- /dev/null +++ b/internal/media/prepare/prepare.go @@ -0,0 +1,181 @@ +// internal/media/prepare/prepare.go +package prepare + +import ( + "bytes" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "golang.org/x/image/webp" + "go.uber.org/zap" +) + +// ImageDimensions represents the dimensions of an image +type ImageDimensions struct { + Width int + Height int +} + +// Manager handles media preparation and optimization +type Manager struct { + logger *zap.Logger +} + +// NewManager creates a new preparation manager +func NewManager(logger *zap.Logger) *Manager { + if logger == nil { + // Create a default logger if none is provided + var err error + logger, err = zap.NewProduction() + if err != nil { + // If we can't create a logger, use a no-op logger + logger = zap.NewNop() + } + } + + return &Manager{ + logger: logger, + } +} + +// GetMediaDimensions gets the dimensions of an image or video file +func (m *Manager) GetMediaDimensions(filePath string) (*ImageDimensions, error) { + // Open the file + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Get the file extension + ext := strings.ToLower(filepath.Ext(filePath)) + + // Decode the image based on the file extension + var img image.Image + + switch ext { + case ".jpg", ".jpeg": + img, err = jpeg.Decode(file) + case ".png": + img, err = png.Decode(file) + case ".gif": + img, err = gif.Decode(file) + case ".webp": + img, err = webp.Decode(file) + default: + return nil, fmt.Errorf("unsupported image format: %s", ext) + } + + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + // Get the dimensions + bounds := img.Bounds() + return &ImageDimensions{ + Width: bounds.Max.X - bounds.Min.X, + Height: bounds.Max.Y - bounds.Min.Y, + }, nil +} + +// ResizeImage resizes an image to the specified dimensions +// Returns the path to the resized image +func (m *Manager) ResizeImage(filePath string, maxWidth, maxHeight int) (string, error) { + // For now, just return the original image + // In a future update, we can add actual resizing functionality + return filePath, nil +} + +// OptimizeImage optimizes an image for web usage +// Returns the path to the optimized image +func (m *Manager) OptimizeImage(filePath string) (string, error) { + // For now, just return the original image + // In a future update, we can add optimization functionality + return filePath, nil +} + +// GetMediaType gets the media type of a file +func (m *Manager) GetMediaType(filePath 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() + + // Read the first 512 bytes to detect the content type + buffer := make([]byte, 512) + _, err = file.Read(buffer) + if err != nil && err != io.EOF { + return "", fmt.Errorf("failed to read file: %w", err) + } + + // Detect the content type + contentType := detectContentType(buffer) + + return contentType, nil +} + +// ExtractMetadata extracts metadata from a media file +func (m *Manager) ExtractMetadata(filePath string) (map[string]interface{}, error) { + // Get the media type + mediaType, err := m.GetMediaType(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get media type: %w", err) + } + + // Create the metadata map + metadata := map[string]interface{}{ + "media_type": mediaType, + } + + // If it's an image, get the dimensions + if strings.HasPrefix(mediaType, "image/") { + dims, err := m.GetMediaDimensions(filePath) + if err != nil { + m.logger.Warn("Failed to get image dimensions", + zap.String("filePath", filePath), + zap.Error(err)) + } else { + metadata["width"] = dims.Width + metadata["height"] = dims.Height + metadata["dimensions"] = fmt.Sprintf("%dx%d", dims.Width, dims.Height) + } + } + + // Get the file size + fileInfo, err := os.Stat(filePath) + if err != nil { + m.logger.Warn("Failed to get file size", + zap.String("filePath", filePath), + zap.Error(err)) + } else { + metadata["size"] = fileInfo.Size() + } + + return metadata, nil +} + +// detectContentType detects the content type of a file +func detectContentType(buffer []byte) string { + // Try to detect the content type + contentType := http.DetectContentType(buffer) + + // Some additional checks for specific formats + if contentType == "application/octet-stream" { + // Check for WebP signature + if bytes.HasPrefix(buffer, []byte("RIFF")) && bytes.Contains(buffer[8:12], []byte("WEBP")) { + return "image/webp" + } + } + + return contentType +} \ No newline at end of file diff --git a/internal/media/upload/blossom/upload.go b/internal/media/upload/blossom/upload.go new file mode 100644 index 0000000..f6c8535 --- /dev/null +++ b/internal/media/upload/blossom/upload.go @@ -0,0 +1,304 @@ +// internal/media/upload/blossom/upload.go +package blossom + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/nbd-wtf/go-nostr" + "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"` +} + +// UploadResponse represents the response from a Blossom upload +type UploadResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Blob BlobDescriptor `json:"blob"` +} + +// Uploader implements the media upload functionality for Blossom +type Uploader struct { + serverURL string + logger *zap.Logger + // Function to get a signed auth header (for Blossom authentication) + getAuthHeader func(url, method string) (string, error) +} + +// NewUploader creates a new Blossom uploader +func NewUploader( + serverURL string, + logger *zap.Logger, + getAuthHeader func(url, method string) (string, error), +) *Uploader { + if logger == nil { + // Create a default logger if none is provided + var err error + logger, err = zap.NewProduction() + if err != nil { + // If we can't create a logger, use a no-op logger + logger = zap.NewNop() + } + } + + return &Uploader{ + serverURL: serverURL, + logger: logger, + getAuthHeader: getAuthHeader, + } +} + +// UploadFile uploads a file to a Blossom server +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 +} + +// 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}, + } + + // Create the auth event + authEvent := nostr.Event{ + Kind: 24242, // Blossom Authorization event + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: "", // Empty content for auth events + } + + // Get the public 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 { + 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 +} \ No newline at end of file diff --git a/internal/media/upload/nip94/upload.go b/internal/media/upload/nip94/upload.go new file mode 100644 index 0000000..68b44f0 --- /dev/null +++ b/internal/media/upload/nip94/upload.go @@ -0,0 +1,473 @@ +// internal/media/upload/nip94/upload.go +package nip94 + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/utils" + "go.uber.org/zap" +) + +// NIP96ServerConfig represents the configuration for a NIP-96 server +type NIP96ServerConfig struct { + APIURL string `json:"api_url"` + DownloadURL string `json:"download_url,omitempty"` + DelegatedToURL string `json:"delegated_to_url,omitempty"` + SupportedContentTypes []string `json:"content_types,omitempty"` +} + +// NIP96UploadResponse represents the response from a NIP-96 server upload +type NIP96UploadResponse struct { + Status string `json:"status"` + Message string `json:"message"` + ProcessingURL string `json:"processing_url,omitempty"` + NIP94Event struct { + Tags [][]string `json:"tags"` + Content string `json:"content"` + } `json:"nip94_event"` +} + +// Uploader implements the media upload functionality for NIP-94/96 +type Uploader struct { + serverURL string + downloadURL string + supportedTypes []string + logger *zap.Logger + // Function to get a signed auth header (for NIP-98) + getAuthHeader func(url, method string, payload []byte) (string, error) +} + +// NewUploader creates a new NIP-94/96 uploader +func NewUploader( + serverURL string, + downloadURL string, + supportedTypes []string, + logger *zap.Logger, + getAuthHeader func(url, method string, payload []byte) (string, error), +) *Uploader { + if logger == nil { + // Create a default logger if none is provided + var err error + logger, err = zap.NewProduction() + if err != nil { + // If we can't create a logger, use a no-op logger + logger = zap.NewNop() + } + } + + return &Uploader{ + serverURL: serverURL, + downloadURL: downloadURL, + supportedTypes: supportedTypes, + logger: logger, + getAuthHeader: getAuthHeader, + } +} + +// DiscoverServer discovers a NIP-96 server's configuration +func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) { + // Make sure we have the base URL without path + baseURL := serverURL + + // Fetch the well-known JSON file + resp, err := http.Get(baseURL + "/.well-known/nostr/nip96.json") + if err != nil { + return nil, fmt.Errorf("failed to fetch server configuration: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned non-OK status: %d", resp.StatusCode) + } + + // Parse the response + var config NIP96ServerConfig + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + return nil, fmt.Errorf("failed to parse server configuration: %w", err) + } + + // If the server delegates to another URL, follow the delegation + if config.APIURL == "" && config.DownloadURL == "" { + delegatedURL := config.DelegatedToURL + if delegatedURL == "" { + return nil, errors.New("server configuration missing both api_url and delegated_to_url") + } + + return DiscoverServer(delegatedURL) + } + + return &config, nil +} + +// UploadFile uploads a file to a NIP-96 compatible server +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 content type + if err := writer.WriteField("content_type", contentType); err != nil { + return "", "", fmt.Errorf("failed to add content type: %w", err) + } + + // Add file size + if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil { + return "", "", fmt.Errorf("failed to add file size: %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 request + req, err := http.NewRequest("POST", u.serverURL, &requestBody) + if err != nil { + return "", "", fmt.Errorf("failed to create request: %w", err) + } + + // Set content type + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Get the request body for the NIP-98 auth + bodyBytes := requestBody.Bytes() + + // Add NIP-98 auth header if available + if u.getAuthHeader != nil { + // Calculate SHA-256 of the entire request body for more comprehensive authentication + bodyHasher := sha256.New() + bodyHasher.Write(bodyBytes) + bodyHash := bodyHasher.Sum(nil) + + // Use the body hash for authentication + authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash) + 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 && resp.StatusCode != http.StatusAccepted { + bodyBytes, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + // Parse response + var uploadResp NIP96UploadResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return "", "", fmt.Errorf("failed to parse response: %w", err) + } + + // Check if we need to wait for processing + if uploadResp.Status == "processing" && uploadResp.ProcessingURL != "" { + // Wait for processing to complete + return u.waitForProcessing(uploadResp.ProcessingURL) + } + + // Check for success + if uploadResp.Status != "success" { + return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message) + } + + // Extract URL and hash from the NIP-94 event + var mediaURL string + var mediaHash string + + for _, tag := range uploadResp.NIP94Event.Tags { + if len(tag) >= 2 { + if tag[0] == "url" { + mediaURL = tag[1] + } else if tag[0] == "ox" || tag[0] == "x" { + mediaHash = tag[1] + } + } + } + + if mediaURL == "" { + return "", "", errors.New("missing URL in response") + } + + // Verify the hash matches what we calculated + if mediaHash != "" && mediaHash != fileHash { + u.logger.Warn("Server returned different hash than calculated", + zap.String("calculated", fileHash), + zap.String("returned", mediaHash)) + } + + return mediaURL, mediaHash, nil +} + +// waitForProcessing waits for a file processing to complete +func (u *Uploader) waitForProcessing(processingURL string) (string, string, error) { + // Create HTTP client + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Try several times + maxAttempts := 10 + for attempt := 1; attempt <= maxAttempts; attempt++ { + // Wait before retry + if attempt > 1 { + time.Sleep(time.Duration(attempt) * time.Second) + } + + // Check processing status + resp, err := client.Get(processingURL) + if err != nil { + u.logger.Warn("Failed to check processing status", + zap.String("url", processingURL), + zap.Error(err), + zap.Int("attempt", attempt)) + continue + } + + // Read response body + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + u.logger.Warn("Failed to read processing response", + zap.Error(err), + zap.Int("attempt", attempt)) + continue + } + + // Check if processing is complete + if resp.StatusCode == http.StatusCreated { + // Parse the complete response + var uploadResp NIP96UploadResponse + if err := json.Unmarshal(body, &uploadResp); err != nil { + return "", "", fmt.Errorf("failed to parse processing completion response: %w", err) + } + + // Extract URL and hash from the NIP-94 event + var mediaURL string + var mediaHash string + + for _, tag := range uploadResp.NIP94Event.Tags { + if len(tag) >= 2 { + if tag[0] == "url" { + mediaURL = tag[1] + } else if tag[0] == "ox" || tag[0] == "x" { + mediaHash = tag[1] + } + } + } + + if mediaURL == "" { + return "", "", errors.New("missing URL in processing completion response") + } + + return mediaURL, mediaHash, nil + } + + // Parse the processing status + var statusResp struct { + Status string `json:"status"` + Message string `json:"message"` + Percentage int `json:"percentage"` + } + + if err := json.Unmarshal(body, &statusResp); err != nil { + u.logger.Warn("Failed to parse processing status response", + zap.Error(err), + zap.String("body", string(body)), + zap.Int("attempt", attempt)) + continue + } + + // Check if processing failed + if statusResp.Status == "error" { + return "", "", fmt.Errorf("processing failed: %s", statusResp.Message) + } + + // Log progress + u.logger.Info("File processing in progress", + zap.String("url", processingURL), + zap.Int("percentage", statusResp.Percentage), + zap.Int("attempt", attempt)) + } + + return "", "", errors.New("processing timed out") +} + +// DeleteFile deletes a file from the 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 NIP-98 auth header if available + if u.getAuthHeader != nil { + authHeader, err := u.getAuthHeader(deleteURL, "DELETE", nil) + 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 +} + +// CreateNIP98AuthHeader creates a NIP-98 authorization header +func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (string, error) { + // Create the event + tags := []nostr.Tag{ + {"u", url}, + {"method", method}, + } + + // Add payload hash if provided + if payload != nil { + payloadHasher := sha256.New() + payloadHasher.Write(payload) + payloadHash := hex.EncodeToString(payloadHasher.Sum(nil)) + tags = append(tags, nostr.Tag{"payload", payloadHash}) + } + + // Create the auth event + authEvent := nostr.Event{ + Kind: 27235, // NIP-98 HTTP Auth + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: "", // Empty content for auth events + } + + // Get the public 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 { + 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 +} \ No newline at end of file diff --git a/internal/models/bot.go b/internal/models/bot.go new file mode 100644 index 0000000..e453124 --- /dev/null +++ b/internal/models/bot.go @@ -0,0 +1,68 @@ +// internal/models/bot.go +package models + +import ( + "time" +) + +// Bot represents a Nostr posting bot +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 + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + Bio string `db:"bio" json:"bio"` + Nip05 string `db:"nip05" json:"nip05"` + ZapAddress string `db:"zap_address" json:"zap_address"` + ProfilePicture string `db:"profile_picture" json:"profile_picture"` + Banner string `db:"banner" json:"banner"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"` + + // The following are not stored in the database + PostConfig *PostConfig `json:"post_config,omitempty"` + MediaConfig *MediaConfig `json:"media_config,omitempty"` + Relays []*Relay `json:"relays,omitempty"` +} + +// PostConfig represents the posting configuration for a bot +type PostConfig struct { + ID int64 `db:"id" json:"id"` + BotID int64 `db:"bot_id" json:"-"` + Hashtags string `db:"hashtags" json:"hashtags"` // JSON array stored as string + IntervalMinutes int `db:"interval_minutes" json:"interval_minutes"` + PostTemplate string `db:"post_template" json:"post_template"` + Enabled bool `db:"enabled" json:"enabled"` +} + +// MediaConfig represents the media upload configuration for a bot +type MediaConfig struct { + ID int64 `db:"id" json:"id"` + BotID int64 `db:"bot_id" json:"-"` + PrimaryService string `db:"primary_service" json:"primary_service"` // "nip94" or "blossom" + FallbackService string `db:"fallback_service" json:"fallback_service"` + Nip94ServerURL string `db:"nip94_server_url" json:"nip94_server_url"` + BlossomServerURL string `db:"blossom_server_url" json:"blossom_server_url"` +} + +// Relay represents a Nostr relay configuration +type Relay struct { + ID int64 `db:"id" json:"id"` + BotID int64 `db:"bot_id" json:"-"` + URL string `db:"url" json:"url"` + Read bool `db:"read" json:"read"` + Write bool `db:"write" json:"write"` +} + +// Post represents a post made by the bot +type Post struct { + ID int64 `db:"id" json:"id"` + BotID int64 `db:"bot_id" json:"bot_id"` + ContentFilename string `db:"content_filename" json:"content_filename"` + MediaURL string `db:"media_url" json:"media_url"` + EventID string `db:"event_id" json:"event_id"` + Status string `db:"status" json:"status"` // "pending", "posted", "failed" + CreatedAt time.Time `db:"created_at" json:"created_at"` + Error string `db:"error" json:"error,omitempty"` +} \ No newline at end of file diff --git a/internal/nostr/events/events.go b/internal/nostr/events/events.go new file mode 100644 index 0000000..8f85a54 --- /dev/null +++ b/internal/nostr/events/events.go @@ -0,0 +1,285 @@ +// internal/nostr/events/events.go +package events + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/models" +) + +// EventManager handles creation and signing of Nostr events +type EventManager struct { + // The private key getter function returns a private key for the given pubkey + getPrivateKey func(pubkey string) (string, error) +} + +// NewEventManager creates a new EventManager +func NewEventManager(getPrivateKey func(pubkey string) (string, error)) *EventManager { + return &EventManager{ + getPrivateKey: getPrivateKey, + } +} + +// CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot +func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) { + // Create the metadata structure + metadata := map[string]interface{}{ + "name": bot.Name, + "display_name": bot.DisplayName, + "about": bot.Bio, + } + + // Add optional fields if they exist + if bot.Nip05 != "" { + metadata["nip05"] = bot.Nip05 + } + + if bot.ProfilePicture != "" { + metadata["picture"] = bot.ProfilePicture + } + + if bot.Banner != "" { + metadata["banner"] = bot.Banner + } + + // Convert metadata to JSON + content, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Create the event + ev := nostr.Event{ + Kind: 0, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: []nostr.Tag{}, + Content: string(content), + } + + // Sign the event + if err := em.SignEvent(&ev, bot.Pubkey); err != nil { + return nil, err + } + + return &ev, nil +} + +// CreateAndSignTextNoteEvent creates and signs a kind 1 text note event +func (em *EventManager) CreateAndSignTextNoteEvent( + pubkey string, + content string, + tags []nostr.Tag, +) (*nostr.Event, error) { + // Create the event + ev := nostr.Event{ + Kind: 1, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: content, + } + + // Sign the event + if err := em.SignEvent(&ev, pubkey); err != nil { + return nil, err + } + + return &ev, nil +} + +// CreateAndSignMediaEvent creates and signs a kind 1 event with media attachment +func (em *EventManager) CreateAndSignMediaEvent( + pubkey string, + content string, + mediaURL string, + mediaType string, + mediaHash string, + altText string, + hashtags []string, +) (*nostr.Event, error) { + // Create the imeta tag for the media + imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} + + // 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 + tags := []nostr.Tag{imeta} + + // Add hashtags + for _, tag := range hashtags { + tags = append(tags, nostr.Tag{"t", tag}) + } + + // Create the event + ev := nostr.Event{ + Kind: 1, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: content, + } + + // Sign the event + if err := em.SignEvent(&ev, pubkey); err != nil { + return nil, err + } + + return &ev, nil +} + +// CreateAndSignPictureEvent creates and signs a kind 20 picture event (NIP-68) +func (em *EventManager) CreateAndSignPictureEvent( + pubkey string, + title string, + description string, + mediaURL string, + mediaType string, + mediaHash string, + altText string, + hashtags []string, +) (*nostr.Event, error) { + // Create the imeta tag for the media + imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} + + // 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 + tags := []nostr.Tag{ + {"title", title}, + imeta, + } + + // Add media type tag + tags = append(tags, nostr.Tag{"m", mediaType}) + + // Add media hash tag + if mediaHash != "" { + tags = append(tags, nostr.Tag{"x", mediaHash}) + } + + // Add hashtags + for _, tag := range hashtags { + tags = append(tags, nostr.Tag{"t", tag}) + } + + // Create the event + ev := nostr.Event{ + Kind: 20, // NIP-68 Picture Event + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: description, + } + + // Sign the event + if err := em.SignEvent(&ev, pubkey); err != nil { + return nil, err + } + + return &ev, nil +} + +// CreateAndSignVideoEvent creates and signs a kind 21/22 video event (NIP-71) +func (em *EventManager) CreateAndSignVideoEvent( + pubkey string, + title string, + description string, + mediaURL string, + mediaType string, + mediaHash string, + previewImageURL string, + duration int, + altText string, + hashtags []string, + isShortVideo bool, +) (*nostr.Event, error) { + // Create the imeta tag for the media + imeta := []string{ + "imeta", + "url " + mediaURL, + "m " + mediaType, + } + + // Add hash if available + if mediaHash != "" { + imeta = append(imeta, "x "+mediaHash) + } + + // Add preview image if available + if previewImageURL != "" { + imeta = append(imeta, "image "+previewImageURL) + } + + // Create tags + tags := []nostr.Tag{ + {"title", title}, + imeta, + } + + // Add duration if available + if duration > 0 { + tags = append(tags, nostr.Tag{"duration", fmt.Sprintf("%d", duration)}) + } + + // Add alt text if available + if altText != "" { + tags = append(tags, nostr.Tag{"alt", altText}) + } + + // Add hashtags + for _, tag := range hashtags { + tags = append(tags, nostr.Tag{"t", tag}) + } + + // Choose the right kind based on video type + kind := 21 // Regular video + if isShortVideo { + kind = 22 // Short video + } + + // Create the event + ev := nostr.Event{ + Kind: kind, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + Tags: tags, + Content: description, + } + + // Sign the event + if err := em.SignEvent(&ev, pubkey); err != nil { + return nil, err + } + + return &ev, nil +} + +// SignEvent signs a Nostr event using the private key for the given pubkey +func (em *EventManager) SignEvent(ev *nostr.Event, pubkey string) error { + // Set the public key + ev.PubKey = pubkey + + // Get the private key + privkey, err := em.getPrivateKey(pubkey) + if err != nil { + return fmt.Errorf("failed to get private key: %w", err) + } + + // Sign the event + return ev.Sign(privkey) +} \ No newline at end of file diff --git a/internal/nostr/poster/poster.go b/internal/nostr/poster/poster.go new file mode 100644 index 0000000..a61b347 --- /dev/null +++ b/internal/nostr/poster/poster.go @@ -0,0 +1,183 @@ +// internal/nostr/poster/poster.go +package poster + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" + "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" + "go.uber.org/zap" +) + +// Poster handles posting content to Nostr +type Poster struct { + eventMgr *events.EventManager + relayMgr *relay.Manager + mediaPrep *prepare.Manager + logger *zap.Logger +} + +// NewPoster creates a new content poster +func NewPoster( + eventMgr *events.EventManager, + relayMgr *relay.Manager, + mediaPrep *prepare.Manager, + logger *zap.Logger, +) *Poster { + if logger == nil { + // Create a default logger if none is provided + var err error + logger, err = zap.NewProduction() + if err != nil { + // If we can't create a logger, use a no-op logger + logger = zap.NewNop() + } + } + + return &Poster{ + eventMgr: eventMgr, + relayMgr: relayMgr, + mediaPrep: mediaPrep, + logger: logger, + } +} + +// PostContent posts content to Nostr +func (p *Poster) PostContent( + pubkey string, + contentPath string, + contentType string, + mediaURL string, + mediaHash string, + caption string, + hashtags []string, +) error { + // Determine the type of content + isImage := strings.HasPrefix(contentType, "image/") + isVideo := strings.HasPrefix(contentType, "video/") + + // Create alt text if not provided (initialize it here) + altText := caption + if altText == "" { + // Use the filename without extension as a fallback + altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) + } + + // Extract media dimensions if it's an image + if isImage { + dims, err := p.mediaPrep.GetMediaDimensions(contentPath) + if err != nil { + p.logger.Warn("Failed to get image dimensions, continuing anyway", + zap.String("file", contentPath), + zap.Error(err)) + } else { + // Log the dimensions + p.logger.Debug("Image dimensions", + zap.Int("width", dims.Width), + zap.Int("height", dims.Height)) + + // Add dimensions to alt text if available + if altText != "" { + altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height) + } + } + } + + // Create a context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var event *nostr.Event + var err error + + // Determine the appropriate event kind and create the event + if isImage { + // Use kind 1 (text note) for images + // Create hashtag string for post content + var hashtagStr string + if len(hashtags) > 0 { + hashtagArr := make([]string, len(hashtags)) + for i, tag := range hashtags { + hashtagArr[i] = "#" + tag + } + hashtagStr = "\n\n" + strings.Join(hashtagArr, " ") + } + + content := caption + hashtagStr + + event, err = p.eventMgr.CreateAndSignMediaEvent( + pubkey, + content, + mediaURL, + contentType, + mediaHash, + altText, + hashtags, + ) + } else if isVideo { + // For videos, determine if it's a short video + isShortVideo := false // Just a placeholder, would need logic to determine + + // Create the video event + event, err = p.eventMgr.CreateAndSignVideoEvent( + pubkey, + caption, // Title + caption, // Description + mediaURL, + contentType, + mediaHash, + "", // Preview image URL + 0, // Duration + altText, + hashtags, + isShortVideo, + ) + } else { + // For other types, use a regular text note with attachment + event, err = p.eventMgr.CreateAndSignMediaEvent( + pubkey, + caption, + mediaURL, + contentType, + mediaHash, + altText, + hashtags, + ) + } + + if err != nil { + return fmt.Errorf("failed to create event: %w", err) + } + + // Publish the event + relays, err := p.relayMgr.PublishEvent(ctx, event) + if err != nil { + return fmt.Errorf("failed to publish event: %w", err) + } + + p.logger.Info("Published content to relays", + zap.String("event_id", event.ID), + zap.Strings("relays", relays)) + + return nil +} + +// CreatePostContentFunc creates a function for posting content +func CreatePostContentFunc( + eventMgr *events.EventManager, + relayMgr *relay.Manager, + mediaPrep *prepare.Manager, + logger *zap.Logger, +) func(string, string, string, string, string, string, []string) error { + poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger) + + return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error { + return poster.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) + } +} \ No newline at end of file diff --git a/internal/nostr/relay/manager.go b/internal/nostr/relay/manager.go new file mode 100644 index 0000000..b844208 --- /dev/null +++ b/internal/nostr/relay/manager.go @@ -0,0 +1,325 @@ +// internal/nostr/relay/manager.go +package relay + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/nbd-wtf/go-nostr" + "go.uber.org/zap" +) + +// Manager handles connections to Nostr relays +type Manager struct { + relays map[string]*nostr.Relay + readURLs []string + writeURLs []string + mu sync.RWMutex + logger *zap.Logger +} + +// NewManager creates a new relay manager +func NewManager(logger *zap.Logger) *Manager { + if logger == nil { + // Create a default logger if none is provided + var err error + logger, err = zap.NewProduction() + if err != nil { + // If we can't create a logger, use a no-op logger + logger = zap.NewNop() + } + } + + return &Manager{ + relays: make(map[string]*nostr.Relay), + readURLs: []string{}, + writeURLs: []string{}, + logger: logger, + } +} + +// AddRelay adds a relay to the manager and connects to it +func (m *Manager) AddRelay(url string, read, write bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if we're already connected to this relay + if relay, exists := m.relays[url]; exists { + // Update read/write flags + if read && !isInSlice(url, m.readURLs) { + m.readURLs = append(m.readURLs, url) + } else if !read { + m.readURLs = removeFromSlice(url, m.readURLs) + } + + if write && !isInSlice(url, m.writeURLs) { + m.writeURLs = append(m.writeURLs, url) + } else if !write { + m.writeURLs = removeFromSlice(url, m.writeURLs) + } + + // If we don't need to read or write to this relay, close the connection + if !read && !write { + relay.Close() + delete(m.relays, url) + } + + return nil + } + + // Only connect if we need to read or write + if !read && !write { + return nil + } + + // Connect to the relay + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + relay, err := nostr.RelayConnect(ctx, url) + if err != nil { + return fmt.Errorf("failed to connect to relay %s: %w", url, err) + } + + // Store the relay + m.relays[url] = relay + + // Update read/write lists + if read { + m.readURLs = append(m.readURLs, url) + } + + if write { + m.writeURLs = append(m.writeURLs, url) + } + + return nil +} + +// RemoveRelay removes a relay from the manager and closes the connection +func (m *Manager) RemoveRelay(url string) { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if we're connected to this relay + if relay, exists := m.relays[url]; exists { + relay.Close() + delete(m.relays, url) + } + + // Remove from read/write lists + m.readURLs = removeFromSlice(url, m.readURLs) + m.writeURLs = removeFromSlice(url, m.writeURLs) +} + +// PublishEvent publishes an event to all write relays +func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]string, error) { + m.mu.RLock() + writeURLs := make([]string, len(m.writeURLs)) + copy(writeURLs, m.writeURLs) + m.mu.RUnlock() + + if len(writeURLs) == 0 { + return nil, fmt.Errorf("no write relays configured") + } + + // Keep track of successful publishes + var successful []string + var wg sync.WaitGroup + var mu sync.Mutex + + // Publish to all write relays in parallel + for _, url := range writeURLs { + wg.Add(1) + go func(relayURL string) { + defer wg.Done() + + m.mu.RLock() + relay, exists := m.relays[relayURL] + m.mu.RUnlock() + + if !exists { + m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL)) + return + } + + // Create a new context with timeout + publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + // Publish the event + err := relay.Publish(publishCtx, *event) + status := err == nil // Assuming no error means success + if err != nil { + m.logger.Warn("Failed to publish event", + zap.String("relay", relayURL), + zap.Error(err)) + return + } + + // Check if the publish was successful + if status { + mu.Lock() + successful = append(successful, relayURL) + mu.Unlock() + + m.logger.Info("Event published successfully", + zap.String("relay", relayURL), + zap.String("event_id", event.ID)) + } else { + m.logger.Warn("Relay rejected event", + zap.String("relay", relayURL), + zap.String("event_id", event.ID)) + } + }(url) + } + + // Wait for all publish operations to complete + wg.Wait() + + if len(successful) == 0 { + return nil, fmt.Errorf("failed to publish event to any relay") + } + + return successful, nil +} + +// SubscribeToEvents subscribes to events matching the given filters +func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) { + m.mu.RLock() + readURLs := make([]string, len(m.readURLs)) + copy(readURLs, m.readURLs) + m.mu.RUnlock() + + if len(readURLs) == 0 { + return nil, fmt.Errorf("no read relays configured") + } + + // Create a channel for events + eventChan := make(chan *nostr.Event) + + // Create a new context with timeout for subscriptions + subCtx, cancel := context.WithCancel(ctx) + + // Keep track of subscriptions + var subscriptions []*nostr.Subscription + + // Subscribe to all read relays + var wg sync.WaitGroup + for _, url := range readURLs { + wg.Add(1) + go func(relayURL string) { + defer wg.Done() + + m.mu.RLock() + relay, exists := m.relays[relayURL] + m.mu.RUnlock() + + if !exists { + m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL)) + return + } + + // Subscribe to events + sub, err := relay.Subscribe(subCtx, filters) + if err != nil { + m.logger.Warn("Failed to subscribe to relay", + zap.String("relay", relayURL), + zap.Error(err)) + return + } + + m.mu.Lock() + subscriptions = append(subscriptions, sub) + m.mu.Unlock() + + // Handle events + go func() { + for ev := range sub.Events { + select { + case eventChan <- ev: + // Event sent to caller + case <-subCtx.Done(): + return + } + } + }() + }(url) + } + + // Wait for all subscriptions to be set up + wg.Wait() + + // Return a cleanup function + go func() { + <-ctx.Done() + cancel() + close(eventChan) + + // Close all subscriptions + for _, sub := range subscriptions { + sub.Unsub() + } + }() + + return eventChan, nil +} + +// Close closes all relay connections +func (m *Manager) Close() { + m.mu.Lock() + defer m.mu.Unlock() + + for url, relay := range m.relays { + relay.Close() + delete(m.relays, url) + } + + m.readURLs = []string{} + m.writeURLs = []string{} +} + +// GetRelays returns the list of connected relays +func (m *Manager) GetRelays() map[string]struct{ Read, Write bool } { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string]struct{ Read, Write bool }) + + for url := range m.relays { + result[url] = struct { + Read bool + Write bool + }{ + Read: isInSlice(url, m.readURLs), + Write: isInSlice(url, m.writeURLs), + } + } + + return result +} + +// Helper functions + +// isInSlice checks if a string is in a slice +func isInSlice(s string, slice []string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// removeFromSlice removes a string from a slice +func removeFromSlice(s string, slice []string) []string { + var result []string + for _, item := range slice { + if item != s { + result = append(result, item) + } + } + return result +} \ No newline at end of file diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..ca63aaa --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,383 @@ +// internal/scheduler/scheduler.go +package scheduler + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + "encoding/json" + + "github.com/robfig/cron/v3" + "git.sovbit.dev/Enki/nostr-poster/internal/db" + "git.sovbit.dev/Enki/nostr-poster/internal/models" + "git.sovbit.dev/Enki/nostr-poster/internal/utils" + "go.uber.org/zap" +) + +// MediaUploader defines the interface for uploading media +type MediaUploader interface { + UploadFile(filePath string, caption string, altText string) (string, string, error) + DeleteFile(fileHash string) error +} + +// PostPublisher defines the interface for publishing posts +type PostPublisher interface { + PublishEvent(ctx context.Context, event interface{}) ([]string, error) +} + +// ContentPoster defines the function to post content +type ContentPoster func( + pubkey string, + contentPath string, + contentType string, + mediaURL string, + mediaHash string, + caption string, + hashtags []string, +) error + +// Scheduler manages scheduled content posting +type Scheduler struct { + db *db.DB + cron *cron.Cron + logger *zap.Logger + contentDir string + archiveDir string + nip94Uploader MediaUploader + blossomUploader MediaUploader + postContent ContentPoster + botJobs map[int64]cron.EntryID + mu sync.RWMutex +} + +// NewScheduler creates a new content scheduler +func NewScheduler( + db *db.DB, + logger *zap.Logger, + contentDir string, + archiveDir string, + nip94Uploader MediaUploader, + blossomUploader MediaUploader, + postContent ContentPoster, +) *Scheduler { + if logger == nil { + // Create a default logger + var err error + logger, err = zap.NewProduction() + if err != nil { + logger = zap.NewNop() + } + } + + // Create a new cron scheduler with seconds precision + cronScheduler := cron.New(cron.WithSeconds()) + + return &Scheduler{ + db: db, + cron: cronScheduler, + logger: logger, + contentDir: contentDir, + archiveDir: archiveDir, + nip94Uploader: nip94Uploader, + blossomUploader: blossomUploader, + postContent: postContent, + botJobs: make(map[int64]cron.EntryID), + } +} + +// Start starts the scheduler +func (s *Scheduler) Start() error { + // Load all bots with enabled post configs + query := ` + SELECT b.*, pc.*, mc.* + FROM bots b + JOIN post_config pc ON b.id = pc.bot_id + JOIN media_config mc ON b.id = mc.bot_id + WHERE pc.enabled = 1 + ` + + rows, err := s.db.Queryx(query) + if err != nil { + return fmt.Errorf("failed to load bots: %w", err) + } + defer rows.Close() + + // Process each bot + for rows.Next() { + var bot models.Bot + var postConfig models.PostConfig + var mediaConfig models.MediaConfig + + // Map the results to our structs + err := rows.Scan( + &bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName, + &bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner, + &bot.CreatedAt, &bot.OwnerPubkey, + &postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes, + &postConfig.PostTemplate, &postConfig.Enabled, + &mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService, + &mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL, + ) + if err != nil { + s.logger.Error("Failed to scan bot row", zap.Error(err)) + continue + } + + // Set the associated config + bot.PostConfig = &postConfig + bot.MediaConfig = &mediaConfig + + // Schedule the bot + if err := s.ScheduleBot(&bot); err != nil { + s.logger.Error("Failed to schedule bot", + zap.String("name", bot.Name), + zap.Error(err)) + } + } + + // Start the cron scheduler + s.cron.Start() + + return nil +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + if s.cron != nil { + s.cron.Stop() + } +} + +// ScheduleBot schedules posting for a specific bot +func (s *Scheduler) ScheduleBot(bot *models.Bot) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Unschedule if already scheduled + if entryID, exists := s.botJobs[bot.ID]; exists { + s.cron.Remove(entryID) + delete(s.botJobs, bot.ID) + } + + // Create bot-specific content directory if it doesn't exist + botContentDir := filepath.Join(s.contentDir, fmt.Sprintf("bot_%d", bot.ID)) + if err := utils.EnsureDir(botContentDir); err != nil { + return fmt.Errorf("failed to create bot content directory: %w", err) + } + + // Create bot-specific archive directory if it doesn't exist + botArchiveDir := filepath.Join(s.archiveDir, fmt.Sprintf("bot_%d", bot.ID)) + if err := utils.EnsureDir(botArchiveDir); err != nil { + return fmt.Errorf("failed to create bot archive directory: %w", err) + } + + // Parse hashtags + var hashtags []string + if bot.PostConfig.Hashtags != "" { + if err := json.Unmarshal([]byte(bot.PostConfig.Hashtags), &hashtags); err != nil { + s.logger.Warn("Failed to parse hashtags", + zap.String("hashtags", bot.PostConfig.Hashtags), + zap.Error(err)) + hashtags = []string{} + } + } + + // Create the schedule expression + // Format: "0 */X * * * *" where X is the interval in minutes + // This runs at 0 seconds, every X minutes + schedule := fmt.Sprintf("0 */%d * * * *", bot.PostConfig.IntervalMinutes) + + // Create a job function that captures the bot's information + jobFunc := func() { + s.logger.Info("Running scheduled post", + zap.Int64("bot_id", bot.ID), + zap.String("name", bot.Name)) + + // Get a random media file + contentPath, err := utils.GetRandomFile(botContentDir, utils.GetAllSupportedMediaExtensions()) + if err != nil { + s.logger.Error("Failed to get random file", + zap.Int64("bot_id", bot.ID), + zap.Error(err)) + return + } + + // Get content type + contentType, err := utils.GetFileContentType(contentPath) + if err != nil { + s.logger.Error("Failed to determine content type", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.Error(err)) + return + } + + // Select the appropriate uploader + var uploader MediaUploader + if bot.MediaConfig.PrimaryService == "blossom" { + uploader = s.blossomUploader + } else { + uploader = s.nip94Uploader + } + + // Upload the file + mediaURL, mediaHash, err := uploader.UploadFile(contentPath, "", "") + if err != nil { + // Try fallback if available + if bot.MediaConfig.FallbackService != "" && bot.MediaConfig.FallbackService != bot.MediaConfig.PrimaryService { + s.logger.Warn("Primary upload failed, trying fallback", + zap.Int64("bot_id", bot.ID), + zap.String("primary", bot.MediaConfig.PrimaryService), + zap.String("fallback", bot.MediaConfig.FallbackService), + zap.Error(err)) + + if bot.MediaConfig.FallbackService == "blossom" { + uploader = s.blossomUploader + } else { + uploader = s.nip94Uploader + } + + mediaURL, mediaHash, err = uploader.UploadFile(contentPath, "", "") + if err != nil { + s.logger.Error("Fallback upload failed", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.Error(err)) + return + } + } else { + s.logger.Error("File upload failed", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.Error(err)) + return + } + } + + // Get the base filename without extension + filename := filepath.Base(contentPath) + caption := strings.TrimSuffix(filename, filepath.Ext(filename)) + + // Post the content + err = s.postContent( + bot.Pubkey, + contentPath, + contentType, + mediaURL, + mediaHash, + caption, + hashtags, + ) + + if err != nil { + s.logger.Error("Failed to post content", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.Error(err)) + return + } + + // Move the file to the archive directory + archivePath := filepath.Join(botArchiveDir, filepath.Base(contentPath)) + if err := utils.MoveFile(contentPath, archivePath); err != nil { + s.logger.Error("Failed to archive file", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.String("archive", archivePath), + zap.Error(err)) + return + } + + s.logger.Info("Successfully posted content", + zap.Int64("bot_id", bot.ID), + zap.String("file", contentPath), + zap.String("media_url", mediaURL)) + } + + // Schedule the job + entryID, err := s.cron.AddFunc(schedule, jobFunc) + if err != nil { + return fmt.Errorf("failed to schedule bot: %w", err) + } + + // Store the entry ID + s.botJobs[bot.ID] = entryID + + s.logger.Info("Bot scheduled successfully", + zap.Int64("bot_id", bot.ID), + zap.String("name", bot.Name), + zap.String("schedule", schedule)) + + return nil +} + +// UnscheduleBot removes a bot from the scheduler +func (s *Scheduler) UnscheduleBot(botID int64) { + s.mu.Lock() + defer s.mu.Unlock() + + if entryID, exists := s.botJobs[botID]; exists { + s.cron.Remove(entryID) + delete(s.botJobs, botID) + s.logger.Info("Bot unscheduled", zap.Int64("bot_id", botID)) + } +} + +// UpdateBotSchedule updates a bot's schedule +func (s *Scheduler) UpdateBotSchedule(bot *models.Bot) error { + // Unschedule first + s.UnscheduleBot(bot.ID) + + // Reschedule with new configuration + return s.ScheduleBot(bot) +} + +// RunNow triggers an immediate post for a bot +func (s *Scheduler) RunNow(botID int64) error { + // Load the bot with its configurations + var bot models.Bot + err := s.db.Get(&bot, "SELECT * FROM bots WHERE id = ?", botID) + if err != nil { + return fmt.Errorf("failed to load bot: %w", err) + } + + // Load post config + var postConfig models.PostConfig + err = s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", botID) + if err != nil { + 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 = ?", botID) + if err != nil { + return fmt.Errorf("failed to load media config: %w", err) + } + bot.MediaConfig = &mediaConfig + + // Create job and run it + s.ScheduleBot(&bot) + + s.mu.RLock() + entryID, exists := s.botJobs[botID] + s.mu.RUnlock() + + if !exists { + return fmt.Errorf("bot not scheduled") + } + + // Get the job + entry := s.cron.Entry(entryID) + if entry.Job == nil { + return fmt.Errorf("job not found") + } + + // Run the job + entry.Job.Run() + + return nil +} \ No newline at end of file diff --git a/internal/utils/files.go b/internal/utils/files.go new file mode 100644 index 0000000..941931b --- /dev/null +++ b/internal/utils/files.go @@ -0,0 +1,183 @@ +// internal/utils/files.go +package utils + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func init() { + // Seed the random number generator + rand.Seed(time.Now().UnixNano()) +} + +// EnsureDir makes sure the directory exists +func EnsureDir(dir string) error { + return os.MkdirAll(dir, 0755) +} + +// FileExists checks if a file exists and is not a directory +func FileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// GetRandomFile returns a random file from the specified directory +// It only includes files with the given extensions (no dot, e.g. "jpg", "png") +// If extensions is empty, it includes all files +func GetRandomFile(dir string, extensions []string) (string, error) { + files, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("failed to read directory: %w", err) + } + + // Filter files based on extensions + var validFiles []string + for _, file := range files { + if file.IsDir() { + continue + } + + if len(extensions) == 0 { + validFiles = append(validFiles, file.Name()) + continue + } + + ext := strings.TrimPrefix(filepath.Ext(file.Name()), ".") + for _, validExt := range extensions { + if strings.EqualFold(ext, validExt) { + validFiles = append(validFiles, file.Name()) + break + } + } + } + + if len(validFiles) == 0 { + return "", fmt.Errorf("no valid files found in directory") + } + + // Pick a random file + randomIndex := rand.Intn(len(validFiles)) + return filepath.Join(dir, validFiles[randomIndex]), nil +} + +// MoveFile moves a file from src to dst +func MoveFile(src, dst string) error { + // Ensure the destination directory exists + if err := EnsureDir(filepath.Dir(dst)); err != nil { + return err + } + + // First try to rename (which works within the same filesystem) + err := os.Rename(src, dst) + if err == nil { + return nil + } + + // If rename fails (e.g., across filesystems), copy and delete the original + if err := CopyFile(src, dst); err != nil { + return err + } + + return os.Remove(src) +} + +// CopyFile copies a file from src to dst +func CopyFile(src, dst string) error { + // Ensure the destination directory exists + if err := EnsureDir(filepath.Dir(dst)); err != nil { + return err + } + + // Open the source file + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + // Create the destination file + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + // Copy the content + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return err + } + + // Flush the write buffer to disk + return destFile.Sync() +} + +// CalculateFileHash returns the SHA-256 hash of a file +func CalculateFileHash(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// 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() + + // 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 + } + + // 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 +} + +// GetSupportedImageExtensions returns a list of supported image extensions +func GetSupportedImageExtensions() []string { + return []string{"jpg", "jpeg", "png", "gif", "webp", "avif"} +} + +// GetSupportedVideoExtensions returns a list of supported video extensions +func GetSupportedVideoExtensions() []string { + return []string{"mp4", "webm", "mov", "avi"} +} + +// GetAllSupportedMediaExtensions returns all supported media extensions +func GetAllSupportedMediaExtensions() []string { + return append(GetSupportedImageExtensions(), GetSupportedVideoExtensions()...) +} \ No newline at end of file diff --git a/keys.json b/keys.json new file mode 100644 index 0000000..89ed294 --- /dev/null +++ b/keys.json @@ -0,0 +1,5 @@ +{ + "10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4", + "2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504", + "67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a" +} \ No newline at end of file diff --git a/makefile b/makefile new file mode 100644 index 0000000..beea769 --- /dev/null +++ b/makefile @@ -0,0 +1,86 @@ +# Makefile for Nostr Poster + +# Variables +BINARY_NAME=nostr-poster +BUILD_DIR=./build +BIN_DIR=$(BUILD_DIR)/bin +CONFIG_DIR=$(BUILD_DIR)/config +CONTENT_DIR=$(BUILD_DIR)/content +ARCHIVE_DIR=$(BUILD_DIR)/archive +DB_DIR=$(BUILD_DIR)/db + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get + +# Main package +MAIN_PKG=./cmd/server + +# Default target +.PHONY: all +all: clean build + +# Build binary +.PHONY: build +build: + mkdir -p $(BIN_DIR) + mkdir -p $(CONFIG_DIR) + mkdir -p $(CONTENT_DIR) + mkdir -p $(ARCHIVE_DIR) + mkdir -p $(DB_DIR) + # Copy config file if it exists + if [ -f ./config/config.yaml ]; then cp ./config/config.yaml $(CONFIG_DIR)/config.yaml; fi + $(GOBUILD) -o $(BIN_DIR)/$(BINARY_NAME) $(MAIN_PKG) + +# Run the application +.PHONY: run +run: build + $(BIN_DIR)/$(BINARY_NAME) --config=$(CONFIG_DIR)/config.yaml --db=$(DB_DIR)/nostr-poster.db + +# Clean build artifacts +.PHONY: clean +clean: + $(GOCLEAN) + rm -rf $(BUILD_DIR) + +# Run tests +.PHONY: test +test: + $(GOTEST) -v ./... + +# Update dependencies +.PHONY: deps +deps: + $(GOGET) -u + +# Install required tools +.PHONY: tools +tools: + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +# Lint the code +.PHONY: lint +lint: + golangci-lint run ./... + +# Format the code +.PHONY: fmt +fmt: + goimports -w . + +# Show help +.PHONY: help +help: + @echo "make - Build the application" + @echo "make build - Build the application" + @echo "make run - Run the application" + @echo "make clean - Clean build artifacts" + @echo "make test - Run tests" + @echo "make deps - Update dependencies" + @echo "make tools - Install required tools" + @echo "make lint - Lint the code" + @echo "make fmt - Format the code" \ No newline at end of file diff --git a/web/assets/css/style.css b/web/assets/css/style.css new file mode 100644 index 0000000..7e37827 --- /dev/null +++ b/web/assets/css/style.css @@ -0,0 +1,160 @@ +:root { + --primary-black: #121212; + --secondary-black: #1e1e1e; + --primary-gray: #2d2d2d; + --secondary-gray: #3d3d3d; + --light-gray: #aaaaaa; + --primary-purple: #9370DB; /* Medium Purple */ + --secondary-purple: #7B68EE; /* Medium Slate Blue */ + --dark-purple: #6A5ACD; /* Slate Blue */ + --text-color: #e0e0e0; +} + +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; + color: var(--text-color); + 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; +} + +.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); + margin-bottom: 20px; +} + +.navbar-brand { + color: var(--text-color) !important; + font-weight: bold; + text-shadow: 0 0 10px rgba(147, 112, 219, 0.5); +} + +.nav-link { + color: var(--text-color) !important; +} + +.nav-link:hover { + color: var(--primary-purple) !important; +} + +.btn-primary { + background-color: var(--primary-purple) !important; + border-color: var(--primary-purple) !important; +} + +.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; +} + +.btn-secondary { + background-color: var(--secondary-gray) !important; + border-color: var(--secondary-gray) !important; +} + +.truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pubkey { + font-family: monospace; + font-size: 0.85em; + color: var(--light-gray); + background-color: var(--secondary-black); + padding: 2px 6px; + border-radius: 4px; + 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 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--primary-black); +} + +::-webkit-scrollbar-thumb { + background: var(--primary-purple); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--secondary-purple); +} \ No newline at end of file diff --git a/web/assets/js/main.js b/web/assets/js/main.js new file mode 100644 index 0000000..6128277 --- /dev/null +++ b/web/assets/js/main.js @@ -0,0 +1,351 @@ +document.addEventListener('DOMContentLoaded', () => { + // DOM Elements + const loginButton = document.getElementById('loginButton'); + const logoutButton = document.getElementById('logoutButton'); + const userInfo = document.getElementById('userInfo'); + const userPubkey = document.getElementById('userPubkey'); + const authSection = document.getElementById('auth-section'); + const mainContent = document.getElementById('main-content'); + 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 + let createBotModal; + if (typeof bootstrap !== 'undefined') { + createBotModal = new bootstrap.Modal(document.getElementById('createBotModal')); + } + + // State + let currentUser = null; + const API_ENDPOINT = ''; + + // 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); + + // Functions + async function checkAuth() { + const token = localStorage.getItem('authToken'); + if (!token) { + showAuthSection(); + return; + } + + try { + const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, { + headers: { + 'Authorization': token + } + }); + + if (response.ok) { + const data = await response.json(); + currentUser = data.pubkey; + showMainContent(); + fetchBots(); + } else { + // Token invalid + localStorage.removeItem('authToken'); + showAuthSection(); + } + } catch (error) { + console.error('Auth check failed:', error); + showAuthSection(); + } + } + + async function login() { + if (!window.nostr) { + alert('Nostr extension not found. Please install a NIP-07 compatible extension like nos2x or Alby.'); + return; + } + + try { + // Get user's public key + const pubkey = await window.nostr.getPublicKey(); + + // Create challenge event for signing + const event = { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [['challenge', 'nostr-poster-auth']], + content: 'Authenticate with Nostr Poster' + }; + + // 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' + }, + body: JSON.stringify({ + pubkey: pubkey, + signature: signedEvent.sig, + event: JSON.stringify(signedEvent) + }) + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem('authToken', data.token); + currentUser = pubkey; + showMainContent(); + fetchBots(); + } else { + alert('Authentication failed'); + } + } catch (error) { + console.error('Login failed:', error); + alert('Login failed: ' + error.message); + } + } + + function logout() { + localStorage.removeItem('authToken'); + currentUser = null; + showAuthSection(); + } + + 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'); + } + + 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'); + + // Truncate pubkey for display + const shortPubkey = currentUser.substring(0, 8) + '...' + currentUser.substring(currentUser.length - 4); + userPubkey.textContent = shortPubkey; + } + + async function fetchBots() { + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`${API_ENDPOINT}/api/bots`, { + headers: { + 'Authorization': token + } + }); + + if (response.ok) { + const bots = await response.json(); + renderBots(bots); + } else { + console.error('Failed to fetch bots'); + } + } catch (error) { + console.error('Error fetching bots:', error); + } + } + + function renderBots(bots) { + botsList.innerHTML = ''; + + if (bots.length === 0) { + botsList.innerHTML = ` +
+
+
+ + + + +

You don't have any bots yet.

+ +
+
+
+ `; + return; + } + + bots.forEach(bot => { + // Create a status badge + let statusBadge = ''; + if (bot.post_config?.enabled) { + statusBadge = 'Active'; + } else { + statusBadge = 'Inactive'; + } + + // 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(); + + const card = document.createElement('div'); + card.className = 'col-md-4 mb-4'; + card.innerHTML = ` +
+
+
+
+ ${initials} +
+
+
${bot.name}
+
+ ${bot.display_name || ''} +
${statusBadge}
+
+
+
+

${bot.bio || 'No bio'}

+

npub...${bot.pubkey.substring(bot.pubkey.length - 8)}

+
+ +
+ `; + botsList.appendChild(card); + }); + + // Helper function to generate a color from a string + function generateColorFromString(str) { + let hash = 0; + 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%)`; + } + } + + function showCreateBotModal() { + // Reset form + document.getElementById('create-bot-form').reset(); + generateKeypair.checked = true; + keypairInput.classList.add('d-none'); + + // Show modal + createBotModal.show(); + } + + function toggleKeypairInput() { + if (generateKeypair.checked) { + keypairInput.classList.add('d-none'); + } else { + keypairInput.classList.remove('d-none'); + } + } + + 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; + + if (!name) { + alert('Bot name is required.'); + return; + } + + let pubkey = ''; + let privkey = ''; + + 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.'); + return; + } + } + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`${API_ENDPOINT}/api/bots`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token + }, + body: JSON.stringify({ + name, + display_name: displayName, + bio, + nip05, + pubkey, + encrypted_privkey: privkey + }) + }); + + if (response.ok) { + createBotModal.hide(); + fetchBots(); + } else { + const error = await response.json(); + alert(`Failed to create bot: ${error.error}`); + } + } catch (error) { + console.error('Error creating bot:', error); + alert('Error creating bot: ' + error.message); + } + } + + 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 = ` + + + + `; + } else { + privkeyInput.type = 'password'; + eyeIcon.innerHTML = ` + + + `; + } + }); + } + + +}); \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a59d106 --- /dev/null +++ b/web/index.html @@ -0,0 +1,228 @@ + + + + + + + Nostr Poster + + + + + + + +
+
+
+
+

Welcome to Nostr Poster

+
+
+
+ + + +
+

Automate your Nostr content posting with ease. Schedule posts, manage + multiple bots, and more.

+

Please login with your Nostr extension (NIP-07) to manage your bots.

+ +
+
+
+
+
+ +
+
+
+

+ + + + + Your Bots +

+ +
+
+ +
+
+
+
+ + + + + + + + + \ No newline at end of file