470 lines
15 KiB
Go

// 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
}