// 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) { // Log information about the upload u.logger.Info("Uploading file to Blossom server", zap.String("filePath", filePath), zap.String("serverURL", u.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", u.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(u.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 { return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr) } // Parse response var blossomResp BlossomResponse if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil { return "", "", fmt.Errorf("failed to parse response: %w", err) } // Check for success if blossomResp.Status != "success" { return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message) } // 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 { // 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 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 { return &Uploader{ serverURL: customURL, logger: u.logger, getAuthHeader: u.getAuthHeader, } }