486 lines
13 KiB
Go
486 lines
13 KiB
Go
// internal/media/upload/nip94/upload.go
|
|
package nip94
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/nbd-wtf/go-nostr"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// NIP96ServerConfig represents the configuration for a NIP-96 server
|
|
type NIP96ServerConfig struct {
|
|
APIURL string `json:"api_url"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
DelegatedToURL string `json:"delegated_to_url,omitempty"`
|
|
SupportedContentTypes []string `json:"content_types,omitempty"`
|
|
}
|
|
|
|
// NIP96UploadResponse represents the response from a NIP-96 server upload
|
|
type NIP96UploadResponse struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
ProcessingURL string `json:"processing_url,omitempty"`
|
|
NIP94Event struct {
|
|
Tags [][]string `json:"tags"`
|
|
Content string `json:"content"`
|
|
} `json:"nip94_event"`
|
|
}
|
|
|
|
// Uploader implements the media upload functionality for NIP-94/96
|
|
type Uploader struct {
|
|
serverURL string
|
|
downloadURL string
|
|
supportedTypes []string
|
|
logger *zap.Logger
|
|
// Function to get a signed auth header (for NIP-98)
|
|
getAuthHeader func(url, method string, payload []byte) (string, error)
|
|
}
|
|
|
|
// NewUploader creates a new NIP-94/96 uploader
|
|
func NewUploader(
|
|
serverURL string,
|
|
downloadURL string,
|
|
supportedTypes []string,
|
|
logger *zap.Logger,
|
|
getAuthHeader func(url, method string, payload []byte) (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,
|
|
downloadURL: downloadURL,
|
|
supportedTypes: supportedTypes,
|
|
logger: logger,
|
|
getAuthHeader: getAuthHeader,
|
|
}
|
|
}
|
|
|
|
// DiscoverServer discovers a NIP-96 server's configuration
|
|
func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) {
|
|
// Make sure we have the base URL without path
|
|
baseURL := serverURL
|
|
|
|
// Fetch the well-known JSON file
|
|
resp, err := http.Get(baseURL + "/.well-known/nostr/nip96.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch server configuration: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("server returned non-OK status: %d", resp.StatusCode)
|
|
}
|
|
|
|
// Parse the response
|
|
var config NIP96ServerConfig
|
|
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
|
return nil, fmt.Errorf("failed to parse server configuration: %w", err)
|
|
}
|
|
|
|
// If the server delegates to another URL, follow the delegation
|
|
if config.APIURL == "" && config.DownloadURL == "" {
|
|
delegatedURL := config.DelegatedToURL
|
|
if delegatedURL == "" {
|
|
return nil, errors.New("server configuration missing both api_url and delegated_to_url")
|
|
}
|
|
|
|
return DiscoverServer(delegatedURL)
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
// UploadFile uploads a file to a NIP-96 compatible 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 content type
|
|
if err := writer.WriteField("content_type", contentType); err != nil {
|
|
return "", "", fmt.Errorf("failed to add content type: %w", err)
|
|
}
|
|
|
|
// Add file size
|
|
if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil {
|
|
return "", "", fmt.Errorf("failed to add file size: %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 request
|
|
req, err := http.NewRequest("POST", u.serverURL, &requestBody)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set content type
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
// Get the request body for the NIP-98 auth
|
|
bodyBytes := requestBody.Bytes()
|
|
|
|
// Add NIP-98 auth header if available
|
|
if u.getAuthHeader != nil {
|
|
// Calculate SHA-256 of the entire request body for more comprehensive authentication
|
|
bodyHasher := sha256.New()
|
|
bodyHasher.Write(bodyBytes)
|
|
bodyHash := bodyHasher.Sum(nil)
|
|
|
|
// Use the body hash for authentication
|
|
authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash)
|
|
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 && resp.StatusCode != http.StatusAccepted {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Parse response
|
|
var uploadResp NIP96UploadResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
|
|
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
// Check if we need to wait for processing
|
|
if uploadResp.Status == "processing" && uploadResp.ProcessingURL != "" {
|
|
// Wait for processing to complete
|
|
return u.waitForProcessing(uploadResp.ProcessingURL)
|
|
}
|
|
|
|
// Check for success
|
|
if uploadResp.Status != "success" {
|
|
return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message)
|
|
}
|
|
|
|
// Extract URL and hash from the NIP-94 event
|
|
var mediaURL string
|
|
var mediaHash string
|
|
|
|
for _, tag := range uploadResp.NIP94Event.Tags {
|
|
if len(tag) >= 2 {
|
|
if tag[0] == "url" {
|
|
mediaURL = tag[1]
|
|
} else if tag[0] == "ox" || tag[0] == "x" {
|
|
mediaHash = tag[1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if mediaURL == "" {
|
|
return "", "", errors.New("missing URL in response")
|
|
}
|
|
|
|
// Verify the hash matches what we calculated
|
|
if mediaHash != "" && mediaHash != fileHash {
|
|
u.logger.Warn("Server returned different hash than calculated",
|
|
zap.String("calculated", fileHash),
|
|
zap.String("returned", mediaHash))
|
|
}
|
|
|
|
return mediaURL, mediaHash, nil
|
|
}
|
|
|
|
// waitForProcessing waits for a file processing to complete
|
|
func (u *Uploader) waitForProcessing(processingURL string) (string, string, error) {
|
|
// Create HTTP client
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
// Try several times
|
|
maxAttempts := 10
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
// Wait before retry
|
|
if attempt > 1 {
|
|
time.Sleep(time.Duration(attempt) * time.Second)
|
|
}
|
|
|
|
// Check processing status
|
|
resp, err := client.Get(processingURL)
|
|
if err != nil {
|
|
u.logger.Warn("Failed to check processing status",
|
|
zap.String("url", processingURL),
|
|
zap.Error(err),
|
|
zap.Int("attempt", attempt))
|
|
continue
|
|
}
|
|
|
|
// Read response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
u.logger.Warn("Failed to read processing response",
|
|
zap.Error(err),
|
|
zap.Int("attempt", attempt))
|
|
continue
|
|
}
|
|
|
|
// Check if processing is complete
|
|
if resp.StatusCode == http.StatusCreated {
|
|
// Parse the complete response
|
|
var uploadResp NIP96UploadResponse
|
|
if err := json.Unmarshal(body, &uploadResp); err != nil {
|
|
return "", "", fmt.Errorf("failed to parse processing completion response: %w", err)
|
|
}
|
|
|
|
// Extract URL and hash from the NIP-94 event
|
|
var mediaURL string
|
|
var mediaHash string
|
|
|
|
for _, tag := range uploadResp.NIP94Event.Tags {
|
|
if len(tag) >= 2 {
|
|
if tag[0] == "url" {
|
|
mediaURL = tag[1]
|
|
} else if tag[0] == "ox" || tag[0] == "x" {
|
|
mediaHash = tag[1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if mediaURL == "" {
|
|
return "", "", errors.New("missing URL in processing completion response")
|
|
}
|
|
|
|
return mediaURL, mediaHash, nil
|
|
}
|
|
|
|
// Parse the processing status
|
|
var statusResp struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Percentage int `json:"percentage"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &statusResp); err != nil {
|
|
u.logger.Warn("Failed to parse processing status response",
|
|
zap.Error(err),
|
|
zap.String("body", string(body)),
|
|
zap.Int("attempt", attempt))
|
|
continue
|
|
}
|
|
|
|
// Check if processing failed
|
|
if statusResp.Status == "error" {
|
|
return "", "", fmt.Errorf("processing failed: %s", statusResp.Message)
|
|
}
|
|
|
|
// Log progress
|
|
u.logger.Info("File processing in progress",
|
|
zap.String("url", processingURL),
|
|
zap.Int("percentage", statusResp.Percentage),
|
|
zap.Int("attempt", attempt))
|
|
}
|
|
|
|
return "", "", errors.New("processing timed out")
|
|
}
|
|
|
|
// DeleteFile deletes a file from the 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 NIP-98 auth header if available
|
|
if u.getAuthHeader != nil {
|
|
authHeader, err := u.getAuthHeader(deleteURL, "DELETE", nil)
|
|
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
|
|
}
|
|
|
|
// CreateNIP98AuthHeader creates a NIP-98 authorization header
|
|
func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (string, error) {
|
|
// Create the event
|
|
tags := []nostr.Tag{
|
|
{"u", url},
|
|
{"method", method},
|
|
}
|
|
|
|
// Add payload hash if provided
|
|
if payload != nil {
|
|
payloadHasher := sha256.New()
|
|
payloadHasher.Write(payload)
|
|
payloadHash := hex.EncodeToString(payloadHasher.Sum(nil))
|
|
tags = append(tags, nostr.Tag{"payload", payloadHash})
|
|
}
|
|
|
|
// Create the auth event
|
|
authEvent := nostr.Event{
|
|
Kind: 27235, // NIP-98 HTTP Auth
|
|
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
|
|
}
|
|
|
|
// WithCustomURL creates a new uploader instance with the specified custom URL
|
|
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
|
// Create a new uploader with the same configuration but a different URL
|
|
return &Uploader{
|
|
serverURL: customURL,
|
|
downloadURL: u.downloadURL,
|
|
supportedTypes: u.supportedTypes,
|
|
logger: u.logger,
|
|
getAuthHeader: u.getAuthHeader,
|
|
}
|
|
} |