// internal/media/upload/blossom/upload.go package blossom import ( "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "git.sovbit.dev/Enki/nostr-poster/internal/utils" "go.uber.org/zap" ) // BlobDescriptor represents metadata about a blob stored with Blossom type BlobDescriptor struct { URL string `json:"url,omitempty"` SHA256 string `json:"sha256,omitempty"` MimeType string `json:"mime_type,omitempty"` Size int64 `json:"size,omitempty"` Height int `json:"height,omitempty"` Width int `json:"width,omitempty"` Alt string `json:"alt,omitempty"` Caption string `json:"caption,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` } // BlossomResponse represents the response from a Blossom upload type BlossomResponse struct { Status string `json:"status"` Message string `json:"message"` URL string `json:"url"` SHA256 string `json:"sha256"` Size int64 `json:"size"` Type string `json:"type"` Uploaded int64 `json:"uploaded"` BlurHash string `json:"blurhash"` Dim string `json:"dim"` PaymentRequest string `json:"payment_request"` Visibility int `json:"visibility"` } // Uploader implements the media upload functionality for Blossom type Uploader struct { serverURL string logger *zap.Logger // 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, } } // getEnhancedContentType tries multiple methods to get the most accurate content type func getEnhancedContentType(filePath string) (string, error) { // Get extension to help with MIME type detection ext := strings.ToLower(filepath.Ext(filePath)) // Try standard content type detection first contentType, err := utils.GetFileContentType(filePath) if err != nil { return "", fmt.Errorf("failed to determine content type: %w", err) } // Override with more specific MIME types for certain file extensions // These are common file extensions that sometimes get generic MIME types switch ext { case ".jpg", ".jpeg": return "image/jpeg", nil case ".png": return "image/png", nil case ".gif": return "image/gif", nil case ".webp": return "image/webp", nil case ".svg": return "image/svg+xml", nil case ".mp4": return "video/mp4", nil case ".webm": return "video/webm", nil case ".mov": return "video/quicktime", nil case ".mp3": return "audio/mpeg", nil case ".wav": return "audio/wav", nil case ".pdf": return "application/pdf", nil } // If we got a generic type like "application/octet-stream", try to be more specific if contentType == "application/octet-stream" || contentType == "text/plain" { // Add more specific mappings for binary files that might be detected as octet-stream switch ext { case ".jpg", ".jpeg": return "image/jpeg", nil case ".png": return "image/png", nil case ".mp4": return "video/mp4", nil } } return contentType, nil } // UploadFile uploads a file to a Blossom server using raw binary data in the request body func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) { // Ensure the URL ends with /upload for BUD-02 compliance serverURL := u.serverURL if !strings.HasSuffix(serverURL, "/upload") { serverURL = strings.TrimSuffix(serverURL, "/") + "/upload" u.logger.Info("Adding /upload endpoint to URL for BUD-02 compliance", zap.String("original_url", u.serverURL), zap.String("adjusted_url", serverURL)) } // Log information about the upload u.logger.Info("Uploading file to Blossom server", zap.String("filePath", filePath), zap.String("serverURL", serverURL)) // Open the file file, err := os.Open(filePath) if err != nil { return "", "", fmt.Errorf("failed to open file: %w", err) } defer file.Close() // Get file info fileInfo, err := file.Stat() if err != nil { return "", "", fmt.Errorf("failed to get file info: %w", err) } fileSize := fileInfo.Size() // Calculate file hash before reading the file for the request body hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return "", "", fmt.Errorf("failed to calculate file hash: %w", err) } fileHash := hex.EncodeToString(hasher.Sum(nil)) // Reset file pointer to the beginning if _, err := file.Seek(0, 0); err != nil { return "", "", fmt.Errorf("failed to reset file: %w", err) } // Get enhanced content type contentType, err := getEnhancedContentType(filePath) if err != nil { return "", "", fmt.Errorf("failed to determine content type: %w", err) } u.logger.Info("File details", zap.String("filename", filepath.Base(filePath)), zap.String("contentType", contentType), zap.Int64("size", fileSize), zap.String("hash", fileHash)) // Create the request with the file as the raw body // This follows BUD-02 which states the endpoint must accept binary data in the body req, err := http.NewRequest("PUT", serverURL, file) if err != nil { return "", "", fmt.Errorf("failed to create request: %w", err) } // Set headers req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Length", fmt.Sprintf("%d", fileSize)) // Add additional headers that might help the server req.Header.Set("X-Content-Type", contentType) req.Header.Set("X-File-Name", filepath.Base(filePath)) req.Header.Set("X-File-Size", fmt.Sprintf("%d", fileSize)) req.Header.Set("X-File-Hash", fileHash) // If we have caption or alt text, add them as headers if caption != "" { req.Header.Set("X-Caption", caption) } if altText != "" { req.Header.Set("X-Alt", altText) } // Add authorization header if available if u.getAuthHeader != nil { authHeader, err := u.getAuthHeader(serverURL, "PUT") if err != nil { return "", "", fmt.Errorf("failed to create auth header: %w", err) } req.Header.Set("Authorization", authHeader) } // Create HTTP client with timeout client := &http.Client{ Timeout: 2 * time.Minute, } // Send the request u.logger.Info("Sending raw binary request to Blossom server", zap.String("contentType", contentType), zap.Int64("contentLength", fileSize)) resp, err := client.Do(req) if err != nil { return "", "", fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Read the response body for logging bodyBytes, _ := io.ReadAll(resp.Body) bodyStr := string(bodyBytes) // Log response details u.logger.Info("Received response from server", zap.Int("statusCode", resp.StatusCode), zap.String("body", bodyStr)) // Check response status - accept 200, 201, and 202 as success if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { errorMsg := fmt.Sprintf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr) // Add helpful diagnostics based on status code switch resp.StatusCode { case http.StatusUnauthorized, http.StatusForbidden: errorMsg += " - This may indicate an authentication error. Check that your keys are correct and have permission to upload." case http.StatusNotFound: errorMsg += " - The upload endpoint was not found. Ensure the server URL is correct and includes the '/upload' path." case http.StatusRequestEntityTooLarge: errorMsg += " - The file is too large for this server. Try a smaller file or check server limits." case http.StatusBadRequest: errorMsg += " - The server rejected the request. Check that the file format is supported." case http.StatusInternalServerError: errorMsg += " - The server encountered an error. This may be temporary; try again later." } return "", "", fmt.Errorf(errorMsg) } // Parse response var blossomResp BlossomResponse if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil { // Try to provide useful error message even if JSON parsing fails return "", "", fmt.Errorf("failed to parse server response: %w. Raw response: %s", err, bodyStr) } // Check for success if blossomResp.Status != "success" { return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message) } // Validate essential response fields if blossomResp.URL == "" { return "", "", fmt.Errorf("upload succeeded but server did not return a URL. Response: %s", bodyStr) } if blossomResp.SHA256 == "" { // If hash is missing, use our calculated hash u.logger.Warn("Server did not return a hash, using locally calculated hash", zap.String("local_hash", fileHash)) blossomResp.SHA256 = fileHash } // Log the successful response u.logger.Info("Upload successful", zap.String("url", blossomResp.URL), zap.String("hash", blossomResp.SHA256), zap.String("dimensions", blossomResp.Dim), zap.Int64("size", blossomResp.Size)) // Use the URL directly from the response return blossomResp.URL, blossomResp.SHA256, nil } // DeleteFile deletes a file from the Blossom server func (u *Uploader) DeleteFile(fileHash string) error { // Ensure the base URL is properly formed for Blossom API // For deletes, we need the base URL without the /upload part baseURL := u.serverURL if strings.HasSuffix(baseURL, "/upload") { baseURL = strings.TrimSuffix(baseURL, "/upload") u.logger.Info("Adjusting URL for deletion: removing /upload suffix", zap.String("original_url", u.serverURL), zap.String("adjusted_url", baseURL)) } // Create the delete URL deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash) u.logger.Info("Preparing to delete file from Blossom server", zap.String("fileHash", fileHash), zap.String("deleteURL", deleteURL)) // 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() // Read response for better error reporting bodyBytes, _ := io.ReadAll(resp.Body) bodyStr := string(bodyBytes) // Log the response u.logger.Info("Received delete response from server", zap.Int("statusCode", resp.StatusCode), zap.String("body", bodyStr)) // Check response status if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { errorMsg := fmt.Sprintf("server returned non-success status for delete: %d, body: %s", resp.StatusCode, bodyStr) // Add helpful diagnostics based on status code switch resp.StatusCode { case http.StatusUnauthorized, http.StatusForbidden: errorMsg += " - Authentication error. Check that your keys have delete permission." case http.StatusNotFound: errorMsg += " - File not found. It may have already been deleted or never existed." case http.StatusInternalServerError: errorMsg += " - Server error. This might be temporary; try again later." } return fmt.Errorf(errorMsg) } u.logger.Info("File successfully deleted", zap.String("fileHash", fileHash)) return nil } // CreateBlossomAuthHeader creates a Blossom authentication header func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, error) { // Parse the URL to extract the path for the endpoint parsed, err := url.Parse(fullURL) var endpoint string if err == nil && parsed.Path != "" { // Use the full path as the endpoint endpoint = parsed.Path // Remove leading slash if present endpoint = strings.TrimPrefix(endpoint, "/") } else { // Fallback to the full URL if parsing fails endpoint = fullURL } // Set an expiration 5 minutes in the future expiration := time.Now().Add(5 * time.Minute).Unix() // Set the operation type for BUD-02 compliance operation := "upload" if method == "DELETE" { operation = "delete" } else if strings.Contains(endpoint, "list") { operation = "list" } // Create the tags array with required Blossom tags tags := []nostr.Tag{ {"t", operation}, // Required tag for Blossom auth {"u", endpoint}, // URL endpoint {"method", method}, {"expiration", fmt.Sprintf("%d", expiration)}, } // Create the auth event authEvent := nostr.Event{ Kind: 24242, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, Content: "", } // Get the public key from the private key pubkey, err := nostr.GetPublicKey(privkey) if err != nil { return "", fmt.Errorf("failed to get public key: %w", err) } authEvent.PubKey = pubkey // Sign the event if err := authEvent.Sign(privkey); err != nil { return "", fmt.Errorf("failed to sign auth event: %w", err) } // Serialize the event eventJSON, err := json.Marshal(authEvent) if err != nil { return "", fmt.Errorf("failed to serialize auth event: %w", err) } // Encode as base64 encodedEvent := base64.StdEncoding.EncodeToString(eventJSON) // Return the authorization header return "Nostr " + encodedEvent, nil } // WithCustomURL creates a new uploader instance with the specified custom URL. func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { // Ensure the custom URL follows BUD-02 specification by having /upload endpoint if !strings.HasSuffix(customURL, "/upload") { customURL = strings.TrimSuffix(customURL, "/") + "/upload" u.logger.Info("Adding /upload endpoint to custom URL for BUD-02 compliance", zap.String("original_url", customURL), zap.String("adjusted_url", customURL)) } return &Uploader{ serverURL: customURL, logger: u.logger, getAuthHeader: u.getAuthHeader, } } // GetServerURL returns the server URL func (u *Uploader) GetServerURL() string { return u.serverURL }