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