// internal/api/routes.go package api import ( "bytes" "context" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "regexp" "database/sql" "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 globalRelayService *GlobalRelayService } // NewAPI creates a new API instance func NewAPI( logger *zap.Logger, botService *BotService, authService *auth.Service, scheduler *scheduler.Scheduler, globalRelayService *GlobalRelayService, // Changed from colon to comma ) *API { router := gin.Default() // Add CORS middleware router.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Authorization, Content-Type") // Handle preflight requests if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusOK) return } c.Next() }) api := &API{ router: router, logger: logger, botService: botService, authService: authService, scheduler: scheduler, globalRelayService: globalRelayService, // Added this missing field } // 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) } // Global relay management globalRelayGroup := apiGroup.Group("/global-relays") globalRelayGroup.Use(a.requireAuth) { globalRelayGroup.GET("", a.listGlobalRelays) globalRelayGroup.POST("", a.addGlobalRelay) globalRelayGroup.PUT("/:id", a.updateGlobalRelay) globalRelayGroup.DELETE("/:id", a.deleteGlobalRelay) } // 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 botUpdate models.Bot if err := c.ShouldBindJSON(&botUpdate); 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 the received data for debugging a.logger.Info("Received update data", zap.Int64("bot_id", botID), zap.String("name", botUpdate.Name), zap.String("display_name", botUpdate.DisplayName), zap.String("profile_picture", botUpdate.ProfilePicture), zap.String("banner", botUpdate.Banner), zap.Any("website", botUpdate.Website)) // Set the ID and owner botUpdate.ID = botID botUpdate.OwnerPubkey = pubkey // Create SQL NullString for website if it's provided if websiteStr, ok := c.GetPostForm("website"); ok && websiteStr != "" { botUpdate.Website = sql.NullString{ String: websiteStr, Valid: true, } } // Update the bot updatedBot, err := a.botService.UpdateBot(&botUpdate) if err != nil { a.logger.Error("Failed to update bot", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()}) 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 } // DEBUG: Log the raw request body rawData, _ := c.GetRawData() a.logger.Info("Raw config update request", zap.String("raw_body", string(rawData)), zap.Int64("botID", botID)) // Need to restore the body for binding c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData)) var config struct { PostConfig *models.PostConfig `json:"post_config"` MediaConfig *models.MediaConfig `json:"media_config"` } if err := c.ShouldBindJSON(&config); err != nil { a.logger.Error("Failed to bind JSON", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data: " + err.Error()}) return } // Log the parsed configuration a.logger.Info("Parsed bot config update", zap.Int64("botID", botID), zap.Any("post_config", config.PostConfig)) // Set the bot ID if config.PostConfig != nil { config.PostConfig.BotID = botID } if config.MediaConfig != nil { config.MediaConfig.BotID = botID } // Update the configs - log before update if config.PostConfig != nil { a.logger.Info("Updating post config in database", zap.Int64("botID", botID), zap.String("post_mode", config.PostConfig.PostMode), zap.Int("interval", config.PostConfig.IntervalMinutes), zap.String("hashtags", config.PostConfig.Hashtags)) } // 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 } // Log after update - query the database to confirm update var dbPostMode string dbErr := a.botService.db.Get(&dbPostMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID) if dbErr != nil { a.logger.Warn("Failed to verify post_mode update in database", zap.Error(dbErr)) } else { a.logger.Info("Post mode after database update", zap.String("db_post_mode", dbPostMode)) } // 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 with NIP-19 encoding event, err := a.botService.PublishBotProfileWithEncoding(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", "event": event, }) } // 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. IsProfile bool `json:"isProfile"` // Optional: indicates this is a profile/banner image } 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 using a fallback chain: // 1. Request URL, 2. Bot's config URL, 3. Global config URL serverURL := req.ServerURL if serverURL == "" { serverURL = bot.MediaConfig.BlossomServerURL // If still empty, use the global config URL if serverURL == "" { // Get from global config - you'll need to access this from somewhere // Assuming we have a config accessor, something like: serverURL = "https://cdn.sovbit.host" // Default from config.yaml as fallback } } // 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" { // Similar fallback chain for NIP-94 serverURL := req.ServerURL if serverURL == "" { serverURL = bot.MediaConfig.Nip94ServerURL // If still empty, use the global config URL if serverURL == "" { // Get from global config serverURL = "https://files.sovbit.host" // Default from config.yaml as fallback } } // Log the chosen server URL a.logger.Info("Creating NIP-94 uploader with server URL", zap.String("url", 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 } // Create appropriate caption and alt text for profile images caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename)) altText := caption // For profile or banner images, set more appropriate caption/alt text if req.IsProfile { if strings.Contains(strings.ToLower(req.Filename), "profile") { caption = fmt.Sprintf("%s's profile picture", bot.Name) altText = caption } else if strings.Contains(strings.ToLower(req.Filename), "banner") { caption = fmt.Sprintf("%s's banner image", bot.Name) 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") botID, err := strconv.ParseInt(c.Param("id"), 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"` Hashtags []string `json:"hashtags"` PostMode string `json:"post_mode"` // Added post_mode parameter for hybrid support } 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 mediaType = inferMediaTypeFromURL(mediaURL) } // Create the appropriate event var event *nostr.Event var textEvent *nostr.Event // For hybrid mode var eventErr error var hybridMode bool // Check if we're using hybrid mode hybridMode = (req.PostMode == "hybrid") switch req.Kind { case 1: // Standard text note 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 if hybridMode && strings.HasPrefix(mediaType, "image/") { // In hybrid mode, create both kind 1 and kind 20 events // First create kind 1 event var tags []nostr.Tag // Add hashtags to tags for _, tag := range req.Hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create media tags for kind 1 if mediaURL != "" { imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } if req.Alt != "" { imeta = append(imeta, "alt "+req.Alt) } tags = append(tags, imeta) } textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags) if eventErr != nil { a.logger.Error("Failed to create kind 1 event in hybrid mode", zap.Error(eventErr)) // Continue with kind 20 even if kind 1 fails } else { // Publish kind 1 event separately ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second) defer cancelText() textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent) if textErr != nil { a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr)) } else { a.logger.Info("Published kind 1 event in hybrid mode", zap.String("event_id", textEncoded.ID)) } } } // Then create kind 20 event (for both normal and hybrid modes) event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( bot.Pubkey, req.Title, req.Content, mediaURL, mediaType, mediaHash, req.Alt, req.Hashtags, ) case 0: // Special case for hybrid mode selection if !hybridMode { c.JSON(http.StatusBadRequest, gin.H{"error": "Kind 0 is only valid with hybrid mode"}) return } // Create both a kind 1 and kind 20 event var tags []nostr.Tag for _, tag := range req.Hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Add media tags for kind 1 if mediaURL != "" { imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } if req.Alt != "" { imeta = append(imeta, "alt "+req.Alt) } tags = append(tags, imeta) } // Create and publish kind 1 event textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags) if eventErr == nil { ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second) defer cancelText() textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent) if textErr != nil { a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr)) } else { a.logger.Info("Published kind 1 event in hybrid mode", zap.String("event_id", textEncoded.ID)) } } // If it's an image, create kind 20 event too if strings.HasPrefix(mediaType, "image/") { event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( bot.Pubkey, req.Title, req.Content, mediaURL, mediaType, mediaHash, req.Alt, req.Hashtags, ) } else { // For non-images, just use the kind 1 event we already created c.JSON(http.StatusOK, gin.H{ "message": "Kind 1 post published successfully in hybrid mode", }) return } 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 } // Get combined relays (bot + global) combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey) if err != nil { a.logger.Warn("Failed to get combined relays, using bot relays only", zap.Int64("botID", botID), zap.Error(err)) combinedRelays = bot.Relays } // Configure relay manager with combined relays for _, relay := range combinedRelays { 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 with indication of hybrid mode if used message := "Post published successfully" if hybridMode { message = "Post published successfully (hybrid mode - both kind:1 and kind:20)" } c.JSON(http.StatusOK, gin.H{ "message": message, "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 } // addGlobalRelay adds a global relay for the current user func (a *API) addGlobalRelay(c *gin.Context) { pubkey := c.GetString("pubkey") var relay models.Relay if err := c.ShouldBindJSON(&relay); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"}) return } // Set the owner pubkey relay.OwnerPubkey = pubkey if err := a.globalRelayService.AddGlobalRelay(&relay); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add global relay"}) a.logger.Error("Failed to add global relay", zap.Error(err)) return } // Get the updated list relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"}) a.logger.Error("Failed to get updated global relays", zap.Error(err)) return } c.JSON(http.StatusOK, relays) } // updateGlobalRelay updates a global relay func (a *API) updateGlobalRelay(c *gin.Context) { pubkey := c.GetString("pubkey") relayID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"}) return } var relay models.Relay if err := c.ShouldBindJSON(&relay); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"}) return } // Set the ID and owner pubkey relay.ID = relayID relay.OwnerPubkey = pubkey if err := a.globalRelayService.UpdateGlobalRelay(&relay); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update global relay"}) a.logger.Error("Failed to update global relay", zap.Error(err)) return } // Get the updated list relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"}) a.logger.Error("Failed to get updated global relays", zap.Error(err)) return } c.JSON(http.StatusOK, relays) } // deleteGlobalRelay deletes a global relay func (a *API) deleteGlobalRelay(c *gin.Context) { pubkey := c.GetString("pubkey") relayID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"}) return } if err := a.globalRelayService.DeleteGlobalRelay(relayID, pubkey); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete global relay"}) a.logger.Error("Failed to delete global relay", zap.Error(err)) return } c.JSON(http.StatusOK, gin.H{"message": "Global relay deleted"}) } func (a *API) listGlobalRelays(c *gin.Context) { pubkey := c.GetString("pubkey") // Use your existing service method to get global relays relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get global relays"}) a.logger.Error("Failed to get global relays", zap.Error(err)) return } c.JSON(http.StatusOK, relays) }