Compare commits
No commits in common. "964f8128973c484aa74dd5caf993d2aefbb45c9d" and "c05d28f9768858a31beab0ac2e5f22d17dacef80" have entirely different histories.
964f812897
...
c05d28f976
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 enki
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
145
README.md
145
README.md
@ -1,144 +1,3 @@
|
||||
# Nostr Poster
|
||||
# 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.
|
||||
Nostr bot that posts sit to nostr
|
Binary file not shown.
@ -1,25 +0,0 @@
|
||||
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
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,193 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
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
|
66
go.mod
66
go.mod
@ -1,66 +0,0 @@
|
||||
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
|
||||
)
|
159
go.sum
159
go.sum
@ -1,159 +0,0 @@
|
||||
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=
|
@ -1,511 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,585 +0,0 @@
|
||||
// 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"})
|
||||
}
|
@ -1,188 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
// 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()
|
||||
}
|
@ -1,307 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,128 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,304 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,473 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
// 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"`
|
||||
}
|
@ -1,285 +0,0 @@
|
||||
// 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)
|
||||
}
|
@ -1,183 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
@ -1,325 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,383 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,183 +0,0 @@
|
||||
// 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()...)
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4",
|
||||
"2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504",
|
||||
"67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a"
|
||||
}
|
86
makefile
86
makefile
@ -1,86 +0,0 @@
|
||||
# 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"
|
@ -1,160 +0,0 @@
|
||||
: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);
|
||||
}
|
@ -1,351 +0,0 @@
|
||||
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 = `
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#9370DB" class="bi bi-robot mb-3" viewBox="0 0 16 16">
|
||||
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z"/>
|
||||
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z"/>
|
||||
</svg>
|
||||
<p class="lead">You don't have any bots yet.</p>
|
||||
<button class="btn btn-primary mt-3" onclick="document.getElementById('create-bot-btn').click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Create Your First Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
bots.forEach(bot => {
|
||||
// Create a status badge
|
||||
let statusBadge = '';
|
||||
if (bot.post_config?.enabled) {
|
||||
statusBadge = '<span class="badge bg-success">Active</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge bg-secondary">Inactive</span>';
|
||||
}
|
||||
|
||||
// Generate a profile image based on the bot's pubkey (just a colored square)
|
||||
const profileColor = generateColorFromString(bot.pubkey);
|
||||
const initials = (bot.name || 'Bot').substring(0, 2).toUpperCase();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'col-md-4 mb-4';
|
||||
card.innerHTML = `
|
||||
<div class="card bot-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3" style="width: 50px; height: 50px; background-color: ${profileColor}; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">
|
||||
${initials}
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="card-title mb-0">${bot.name}</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<small class="text-muted">${bot.display_name || ''}</small>
|
||||
<div class="ms-2">${statusBadge}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-text truncate">${bot.bio || 'No bio'}</p>
|
||||
<p class="pubkey">npub...${bot.pubkey.substring(bot.pubkey.length - 8)}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-sm btn-primary view-bot" data-id="${bot.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
||||
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
||||
</svg>
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-sm ${bot.post_config?.enabled ? 'btn-outline-danger' : 'btn-outline-success'}">
|
||||
${bot.post_config?.enabled ?
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill me-1" viewBox="0 0 16 16"><path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/></svg>Pause' :
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill me-1" viewBox="0 0 16 16"><path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg>Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
`;
|
||||
} else {
|
||||
privkeyInput.type = 'password';
|
||||
eyeIcon.innerHTML = `
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
228
web/index.html
228
web/index.html
@ -1,228 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nostr Poster</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
|
||||
class="bi bi-send-fill me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z" />
|
||||
</svg>
|
||||
Nostr Poster
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/#bots">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-robot me-1" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Bots
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/#content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-images me-1" viewBox="0 0 16 16">
|
||||
<path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
|
||||
<path
|
||||
d="M14.002 13a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2V5A2 2 0 0 1 2 3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v8a2 2 0 0 1-1.998 2zM14 2H4a1 1 0 0 0-1 1h9.002a2 2 0 0 1 2 2v7A1 1 0 0 0 15 11V3a1 1 0 0 0-1-1zM2.002 4a1 1 0 0 0-1 1v8l2.646-2.354a.5.5 0 0 1 .63-.062l2.66 1.773 3.71-3.71a.5.5 0 0 1 .577-.094l1.777 1.947V5a1 1 0 0 0-1-1h-10z" />
|
||||
</svg>
|
||||
Content
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item" id="loginButton">
|
||||
<button class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-lightning-fill me-1" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641l2.5-8.5z" />
|
||||
</svg>
|
||||
Login with Nostr
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item d-none" id="userInfo">
|
||||
<span class="nav-link">Welcome, <span id="userPubkey" class="pubkey"></span></span>
|
||||
</li>
|
||||
<li class="nav-item d-none" id="logoutButton">
|
||||
<button class="btn btn-outline-danger">Logout</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div id="auth-section">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-4">Welcome to Nostr Poster</h2>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" fill="#9370DB"
|
||||
class="bi bi-lightning-charge-fill" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="lead mb-4">Automate your Nostr content posting with ease. Schedule posts, manage
|
||||
multiple bots, and more.</p>
|
||||
<p>Please login with your Nostr extension (NIP-07) to manage your bots.</p>
|
||||
<button class="btn btn-primary btn-lg px-4 mt-3"
|
||||
onclick="document.getElementById('loginButton').querySelector('button').click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-lightning-fill me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641l2.5-8.5z" />
|
||||
</svg>
|
||||
Login with Nostr
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="d-none">
|
||||
<div id="bots-section">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="#9370DB"
|
||||
class="bi bi-robot me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Your Bots
|
||||
</h2>
|
||||
<button id="create-bot-btn" class="btn btn-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
|
||||
</svg>
|
||||
Create New Bot
|
||||
</button>
|
||||
</div>
|
||||
<div id="bots-list" class="row">
|
||||
<!-- Bot cards will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Bot Modal -->
|
||||
<div class="modal fade" id="createBotModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#9370DB"
|
||||
class="bi bi-robot me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Create New Bot
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="create-bot-form">
|
||||
<div class="mb-3">
|
||||
<label for="botName" class="form-label">Bot Name</label>
|
||||
<input type="text" class="form-control" id="botName" required placeholder="e.g., MyArtBot">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botDisplayName" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="botDisplayName"
|
||||
placeholder="e.g., My Art Posting Bot">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botBio" class="form-label">Bio</label>
|
||||
<textarea class="form-control" id="botBio" rows="3"
|
||||
placeholder="A brief description of your bot..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botNip05" class="form-label">NIP-05 Identifier</label>
|
||||
<input type="text" class="form-control" id="botNip05"
|
||||
placeholder="e.g., bot@yourdomain.com">
|
||||
<div class="form-text">Optional. Used for verification.</div>
|
||||
</div>
|
||||
<hr class="border-secondary">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="generateKeypair" checked>
|
||||
<label class="form-check-label" for="generateKeypair">
|
||||
Generate new keypair
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Recommended. A unique Nostr identity will be created for your bot.
|
||||
</div>
|
||||
</div>
|
||||
<div id="keypair-input" class="d-none">
|
||||
<div class="mb-3">
|
||||
<label for="botPubkey" class="form-label">Public Key (hex)</label>
|
||||
<input type="text" class="form-control" id="botPubkey">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botPrivkey" class="form-label">Private Key (hex)</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="botPrivkey">
|
||||
<button class="btn btn-secondary" type="button" id="togglePrivkey">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||
<path
|
||||
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Your private key will be encrypted on the server.</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="save-bot-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
|
||||
</svg>
|
||||
Create Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user