2025-02-28 01:25:45 -08:00
|
|
|
// internal/media/upload/blossom/upload.go
|
|
|
|
package blossom
|
|
|
|
|
|
|
|
import (
|
|
|
|
"crypto/sha256"
|
|
|
|
"encoding/base64"
|
|
|
|
"encoding/hex"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
2025-03-01 22:53:36 -08:00
|
|
|
"net/url"
|
2025-02-28 01:25:45 -08:00
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2025-03-01 22:53:36 -08:00
|
|
|
"strings"
|
2025-02-28 01:25:45 -08:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/nbd-wtf/go-nostr"
|
2025-03-01 22:53:36 -08:00
|
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
2025-02-28 01:25:45 -08:00
|
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
)
|
|
|
|
|
|
|
|
// BlobDescriptor represents metadata about a blob stored with Blossom
|
|
|
|
type BlobDescriptor struct {
|
2025-03-01 22:53:36 -08:00
|
|
|
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"`
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|
|
|
|
|
2025-03-01 22:53:36 -08:00
|
|
|
// 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"`
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Uploader implements the media upload functionality for Blossom
|
|
|
|
type Uploader struct {
|
2025-03-01 22:53:36 -08:00
|
|
|
serverURL string
|
|
|
|
logger *zap.Logger
|
2025-02-28 01:25:45 -08:00
|
|
|
// 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()
|
|
|
|
}
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
return &Uploader{
|
2025-03-01 22:53:36 -08:00
|
|
|
serverURL: serverURL,
|
|
|
|
logger: logger,
|
2025-02-28 01:25:45 -08:00
|
|
|
getAuthHeader: getAuthHeader,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-01 22:53:36 -08:00
|
|
|
// 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
|
2025-02-28 01:25:45 -08:00
|
|
|
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
2025-03-01 22:53:36 -08:00
|
|
|
// 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
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Create the request
|
|
|
|
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to create delete request: %w", err)
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// 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)
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
req.Header.Set("Authorization", authHeader)
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Send the request
|
|
|
|
client := &http.Client{
|
|
|
|
Timeout: 30 * time.Second,
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
resp, err := client.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to send delete request: %w", err)
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// 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))
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateBlossomAuthHeader creates a Blossom authentication header
|
2025-03-01 22:53:36 -08:00
|
|
|
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
|
2025-02-28 01:25:45 -08:00
|
|
|
tags := []nostr.Tag{
|
2025-03-01 22:53:36 -08:00
|
|
|
{"t", operation}, // Required tag for Blossom auth
|
|
|
|
{"u", endpoint}, // URL endpoint
|
2025-02-28 01:25:45 -08:00
|
|
|
{"method", method},
|
2025-03-01 22:53:36 -08:00
|
|
|
{"expiration", fmt.Sprintf("%d", expiration)},
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Create the auth event
|
|
|
|
authEvent := nostr.Event{
|
2025-03-01 22:53:36 -08:00
|
|
|
Kind: 24242,
|
2025-02-28 01:25:45 -08:00
|
|
|
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
|
|
|
Tags: tags,
|
2025-03-01 22:53:36 -08:00
|
|
|
Content: "",
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
|
|
|
// Get the public key from the private key
|
2025-02-28 01:25:45 -08:00
|
|
|
pubkey, err := nostr.GetPublicKey(privkey)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to get public key: %w", err)
|
|
|
|
}
|
|
|
|
authEvent.PubKey = pubkey
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Sign the event
|
2025-03-01 22:53:36 -08:00
|
|
|
if err := authEvent.Sign(privkey); err != nil {
|
2025-02-28 01:25:45 -08:00
|
|
|
return "", fmt.Errorf("failed to sign auth event: %w", err)
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Serialize the event
|
|
|
|
eventJSON, err := json.Marshal(authEvent)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to serialize auth event: %w", err)
|
|
|
|
}
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Encode as base64
|
|
|
|
encodedEvent := base64.StdEncoding.EncodeToString(eventJSON)
|
2025-03-01 22:53:36 -08:00
|
|
|
|
2025-02-28 01:25:45 -08:00
|
|
|
// Return the authorization header
|
|
|
|
return "Nostr " + encodedEvent, nil
|
2025-03-01 22:53:36 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
}
|
2025-02-28 01:25:45 -08:00
|
|
|
}
|