304 lines
8.2 KiB
Go

// internal/media/upload/blossom/upload.go
package blossom
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"github.com/nbd-wtf/go-nostr"
"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"`
}
// UploadResponse represents the response from a Blossom upload
type UploadResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Blob BlobDescriptor `json:"blob"`
}
// 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,
}
}
// UploadFile uploads a file to a Blossom server
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
// 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)
}
// Calculate file hash
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", "", fmt.Errorf("failed to calculate file hash: %w", err)
}
// Reset file pointer
if _, err := file.Seek(0, 0); err != nil {
return "", "", fmt.Errorf("failed to reset file: %w", err)
}
// Get hash as hex
fileHash := hex.EncodeToString(hasher.Sum(nil))
// Get content type
contentType, err := utils.GetFileContentType(filePath)
if err != nil {
return "", "", fmt.Errorf("failed to determine content type: %w", err)
}
// Create a buffer for the multipart form
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
// Add the file
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return "", "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return "", "", fmt.Errorf("failed to copy file to form: %w", err)
}
// Add caption if provided
if caption != "" {
if err := writer.WriteField("caption", caption); err != nil {
return "", "", fmt.Errorf("failed to add caption: %w", err)
}
}
// Add alt text if provided
if altText != "" {
if err := writer.WriteField("alt", altText); err != nil {
return "", "", fmt.Errorf("failed to add alt text: %w", err)
}
}
// Add file size information
if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil {
return "", "", fmt.Errorf("failed to add file size: %w", err)
}
// Add content type
if err := writer.WriteField("content_type", contentType); err != nil {
return "", "", fmt.Errorf("failed to add content type: %w", err)
}
// Add file hash (for integrity verification)
if err := writer.WriteField("hash", fileHash); err != nil {
return "", "", fmt.Errorf("failed to add file hash: %w", err)
}
// Close the writer
if err := writer.Close(); err != nil {
return "", "", fmt.Errorf("failed to close multipart writer: %w", err)
}
// Create the upload URL
uploadURL := fmt.Sprintf("%s/upload", u.serverURL)
// Create the request
req, err := http.NewRequest("PUT", uploadURL, &requestBody)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
// Set content type
req.Header.Set("Content-Type", writer.FormDataContentType())
// Add authorization header if available
if u.getAuthHeader != nil {
authHeader, err := u.getAuthHeader(uploadURL, "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
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes))
}
// Parse response
var uploadResp UploadResponse
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
return "", "", fmt.Errorf("failed to parse response: %w", err)
}
// Check for success
if uploadResp.Status != "success" {
return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message)
}
// Get the media URL
mediaURL := fmt.Sprintf("%s/%s", u.serverURL, uploadResp.Blob.SHA256)
// Verify the returned hash matches our calculated hash
if uploadResp.Blob.SHA256 != fileHash {
u.logger.Warn("Server returned different hash than calculated",
zap.String("calculated", fileHash),
zap.String("returned", uploadResp.Blob.SHA256))
}
return mediaURL, uploadResp.Blob.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
// BUD-01 requires a kind 24242 authorization event
func CreateBlossomAuthHeader(url, method string, privkey string) (string, error) {
// Create the event
tags := []nostr.Tag{
{"u", url},
{"method", method},
}
// Create the auth event
authEvent := nostr.Event{
Kind: 24242, // Blossom Authorization event
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: tags,
Content: "", // Empty content for auth events
}
// Get the public 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
err = authEvent.Sign(privkey)
if 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
}