1002 lines
27 KiB
Go
1002 lines
27 KiB
Go
// internal/api/routes.go
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"regexp"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/nbd-wtf/go-nostr"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// 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)
|
|
|
|
// NEW: Manual post creation
|
|
botGroup.POST("/:id/post", a.createManualPost)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// NEW: Media server upload
|
|
contentGroup.POST("/:botId/uploadToMediaServer", a.uploadToMediaServer)
|
|
}
|
|
|
|
// 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.StaticFile("/content.html", "./web/content.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)
|
|
}
|
|
|
|
func (a *API) createBot(c *gin.Context) {
|
|
pubkey := c.GetString("pubkey")
|
|
a.logger.Info("Bot creation request received", zap.String("owner_pubkey", pubkey))
|
|
|
|
var bot models.Bot
|
|
if err := c.ShouldBindJSON(&bot); err != nil {
|
|
a.logger.Error("Invalid bot data", zap.Error(err))
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Log received data
|
|
a.logger.Info("Received bot creation data",
|
|
zap.String("name", bot.Name),
|
|
zap.String("display_name", bot.DisplayName),
|
|
zap.Bool("has_privkey", bot.EncryptedPrivkey != ""),
|
|
zap.Bool("has_pubkey", bot.Pubkey != ""))
|
|
|
|
// Set the owner
|
|
bot.OwnerPubkey = pubkey
|
|
|
|
// Create the bot
|
|
createdBot, err := a.botService.CreateBot(&bot)
|
|
if err != nil {
|
|
a.logger.Error("Failed to create bot", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
a.logger.Info("Bot created successfully",
|
|
zap.Int64("bot_id", createdBot.ID),
|
|
zap.String("name", createdBot.Name))
|
|
|
|
createdBot.EncryptedPrivkey = ""
|
|
|
|
c.JSON(http.StatusCreated, createdBot)
|
|
}
|
|
|
|
// 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) {
|
|
pubkey := c.GetString("pubkey")
|
|
|
|
botIDStr := c.Param("botId")
|
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
|
return
|
|
}
|
|
|
|
// Ensure the bot belongs to the user
|
|
_, err = a.botService.GetBotByID(botID, pubkey)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
|
return
|
|
}
|
|
|
|
// Build the path to the bot's content folder
|
|
// e.g. content/bot_123
|
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
|
// If the folder doesn't exist yet, return empty
|
|
if _, err := os.Stat(contentDir); os.IsNotExist(err) {
|
|
c.JSON(http.StatusOK, []string{})
|
|
return
|
|
}
|
|
|
|
// Read the directory
|
|
entries, err := os.ReadDir(contentDir)
|
|
if err != nil {
|
|
a.logger.Error("Failed to read content folder", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read content folder"})
|
|
return
|
|
}
|
|
|
|
// Collect filenames
|
|
var files []string
|
|
for _, entry := range entries {
|
|
// skip subdirs if you want, or handle them
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
files = append(files, entry.Name())
|
|
}
|
|
|
|
c.JSON(http.StatusOK, files)
|
|
}
|
|
|
|
// uploadContent uploads content for a bot
|
|
func (a *API) uploadContent(c *gin.Context) {
|
|
pubkey := c.GetString("pubkey")
|
|
|
|
botIDStr := c.Param("botId")
|
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
|
return
|
|
}
|
|
|
|
// Ensure the bot belongs to user
|
|
_, err = a.botService.GetBotByID(botID, pubkey)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
|
return
|
|
}
|
|
|
|
// Get the file from the form data
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No file in request"})
|
|
return
|
|
}
|
|
|
|
// Build the path: e.g. content/bot_123
|
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
|
// Ensure the directory exists
|
|
if err := os.MkdirAll(contentDir, 0755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot folder"})
|
|
return
|
|
}
|
|
|
|
// Destination path
|
|
destPath := filepath.Join(contentDir, file.Filename)
|
|
|
|
// Save the file
|
|
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
|
a.logger.Error("Failed to save uploaded file", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "File uploaded successfully", "filename": file.Filename})
|
|
}
|
|
|
|
// deleteContent deletes content for a bot
|
|
func (a *API) deleteContent(c *gin.Context) {
|
|
pubkey := c.GetString("pubkey")
|
|
|
|
botIDStr := c.Param("botId")
|
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
|
return
|
|
}
|
|
|
|
filename := c.Param("filename")
|
|
if filename == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No filename"})
|
|
return
|
|
}
|
|
|
|
// Ensure the bot belongs to user
|
|
_, err = a.botService.GetBotByID(botID, pubkey)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
|
return
|
|
}
|
|
|
|
// Build the path
|
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
|
filePath := filepath.Join(contentDir, filename)
|
|
|
|
// Remove the file
|
|
if err := os.Remove(filePath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
|
} else {
|
|
a.logger.Error("Failed to delete file", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"})
|
|
}
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// uploadToMediaServer handles uploading a file from the server to a media server
|
|
func (a *API) uploadToMediaServer(c *gin.Context) {
|
|
pubkey := c.GetString("pubkey")
|
|
botIDStr := c.Param("botId")
|
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
|
return
|
|
}
|
|
|
|
// Look up the bot to get its details (including its pubkey and media config)
|
|
bot, err := a.botService.GetBotByID(botID, pubkey)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
|
return
|
|
}
|
|
|
|
// Parse request body.
|
|
var req struct {
|
|
Filename string `json:"filename" binding:"required"`
|
|
Service string `json:"service" binding:"required"`
|
|
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
|
|
}
|
|
if err := c.BindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
// Build path to the file
|
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
|
filePath := filepath.Join(contentDir, req.Filename)
|
|
|
|
// Verify the file exists
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
|
return
|
|
}
|
|
|
|
// Create a new uploader instance that uses the bot's key.
|
|
var uploader scheduler.MediaUploader
|
|
if req.Service == "blossom" {
|
|
// Get the base Blossom server URL
|
|
serverURL := bot.MediaConfig.BlossomServerURL
|
|
if req.ServerURL != "" {
|
|
serverURL = req.ServerURL
|
|
}
|
|
|
|
// Log the URL for debugging purposes
|
|
a.logger.Info("Creating Blossom uploader with server URL",
|
|
zap.String("original_url", serverURL))
|
|
|
|
// According to BUD-02 specification, the upload endpoint should be /upload
|
|
// Make sure the URL ends with /upload
|
|
if !strings.HasSuffix(serverURL, "/upload") {
|
|
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
|
|
a.logger.Info("Adding /upload endpoint to URL",
|
|
zap.String("complete_url", serverURL))
|
|
}
|
|
|
|
uploader = blossom.NewUploader(
|
|
serverURL,
|
|
a.logger,
|
|
func(url, method string) (string, error) {
|
|
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return blossom.CreateBlossomAuthHeader(url, method, privkey)
|
|
},
|
|
)
|
|
} else if req.Service == "nip94" {
|
|
serverURL := bot.MediaConfig.Nip94ServerURL
|
|
if req.ServerURL != "" {
|
|
serverURL = req.ServerURL
|
|
}
|
|
uploader = nip94.NewUploader(
|
|
serverURL,
|
|
"", // Download URL will be discovered
|
|
nil, // Supported types will be discovered
|
|
a.logger,
|
|
func(url, method string, payload []byte) (string, error) {
|
|
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
|
|
},
|
|
)
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown service"})
|
|
return
|
|
}
|
|
|
|
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
|
|
altText := caption
|
|
|
|
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
|
|
if err != nil {
|
|
a.logger.Error("Failed to upload to media server",
|
|
zap.String("file", filePath),
|
|
zap.String("service", req.Service),
|
|
zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload to media server: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"url": mediaURL,
|
|
"hash": mediaHash,
|
|
"service": req.Service,
|
|
})
|
|
}
|
|
|
|
// Updated createManualPost function in routes.go
|
|
func (a *API) createManualPost(c *gin.Context) {
|
|
pubkey := c.GetString("pubkey")
|
|
botIDStr := c.Param("id")
|
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
|
return
|
|
}
|
|
|
|
// Ensure the bot belongs to the user
|
|
bot, err := a.botService.GetBotByID(botID, pubkey)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req struct {
|
|
Kind int `json:"kind" binding:"required"`
|
|
Content string `json:"content" binding:"required"`
|
|
Title string `json:"title"`
|
|
Alt string `json:"alt"` // Added to support alt text for images
|
|
Hashtags []string `json:"hashtags"`
|
|
}
|
|
|
|
if err := c.BindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
// For kind 20 (picture post), title is required
|
|
if req.Kind == 20 && req.Title == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
|
|
return
|
|
}
|
|
|
|
// Process content to extract media URLs
|
|
var mediaURL string
|
|
var mediaType string
|
|
var mediaHash string
|
|
|
|
// Check if content contains URLs
|
|
re := regexp.MustCompile(`https?://[^\s]+`)
|
|
matches := re.FindAllString(req.Content, -1)
|
|
|
|
if len(matches) > 0 {
|
|
mediaURL = matches[0] // Use the first URL found
|
|
|
|
// Try to determine media type
|
|
mediaType = inferMediaTypeFromURL(mediaURL)
|
|
}
|
|
|
|
// Create the appropriate event
|
|
var event *nostr.Event
|
|
var eventErr error
|
|
|
|
switch req.Kind {
|
|
case 1:
|
|
// Standard text note
|
|
// Create tags
|
|
var tags []nostr.Tag
|
|
for _, tag := range req.Hashtags {
|
|
tags = append(tags, nostr.Tag{"t", tag})
|
|
}
|
|
|
|
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
|
|
case 20:
|
|
// Picture post
|
|
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
|
|
bot.Pubkey,
|
|
req.Title,
|
|
req.Content,
|
|
mediaURL,
|
|
mediaType,
|
|
mediaHash,
|
|
req.Alt, // Use the alt text if provided
|
|
req.Hashtags,
|
|
)
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
|
|
return
|
|
}
|
|
|
|
if eventErr != nil {
|
|
a.logger.Error("Failed to create event", zap.Error(eventErr))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
|
|
return
|
|
}
|
|
|
|
// Configure relay manager
|
|
for _, relay := range bot.Relays {
|
|
if relay.Write {
|
|
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
|
a.logger.Warn("Failed to add relay",
|
|
zap.String("url", relay.URL),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish to relays with NIP-19 encoding
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Use the new method that includes NIP-19 encoding
|
|
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
|
|
if err != nil {
|
|
a.logger.Error("Failed to publish event", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Return the encoded event response
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Post published successfully",
|
|
"event": encodedEvent,
|
|
})
|
|
}
|
|
|
|
// Helper function to infer media type from URL
|
|
func inferMediaTypeFromURL(url string) string {
|
|
// Basic implementation - check file extension
|
|
lowerURL := strings.ToLower(url)
|
|
|
|
// Image types
|
|
if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") {
|
|
return "image/jpeg"
|
|
} else if strings.HasSuffix(lowerURL, ".png") {
|
|
return "image/png"
|
|
} else if strings.HasSuffix(lowerURL, ".gif") {
|
|
return "image/gif"
|
|
} else if strings.HasSuffix(lowerURL, ".webp") {
|
|
return "image/webp"
|
|
} else if strings.HasSuffix(lowerURL, ".svg") {
|
|
return "image/svg+xml"
|
|
} else if strings.HasSuffix(lowerURL, ".avif") {
|
|
return "image/avif"
|
|
}
|
|
|
|
// Default to generic image type if we can't determine specifically
|
|
return "image/jpeg"
|
|
}
|
|
|
|
// getStats gets overall statistics
|
|
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"})
|
|
}
|
|
|
|
// Helper function to extract hashtags from tags
|
|
func extractHashtags(tags []nostr.Tag) []string {
|
|
var hashtags []string
|
|
for _, tag := range tags {
|
|
if tag[0] == "t" && len(tag) > 1 {
|
|
hashtags = append(hashtags, tag[1])
|
|
}
|
|
}
|
|
return hashtags
|
|
} |