discourse/backend/ipfs/service.go
2025-03-25 03:52:30 -04:00

259 lines
7.0 KiB
Go

package ipfs
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const (
// PinataPinFileURL is the Pinata API endpoint for pinning files
PinataPinFileURL = "https://api.pinata.cloud/pinning/pinFileToIPFS"
// PinataPinJSONURL is the Pinata API endpoint for pinning JSON
PinataPinJSONURL = "https://api.pinata.cloud/pinning/pinJSONToIPFS"
// PinataTimeout is the timeout for Pinata API calls
PinataTimeout = 30 * time.Second
)
// IPFSService handles interactions with IPFS via Pinata
type IPFSService struct {
apiKey string
apiSecret string
jwt string
client *http.Client
}
// PinataResponse represents a response from the Pinata API
type PinataResponse struct {
IpfsHash string `json:"IpfsHash"`
PinSize int `json:"PinSize"`
Timestamp string `json:"Timestamp"`
}
// PinataMetadata represents metadata for a file pinned to IPFS
type PinataMetadata struct {
Name string `json:"name"`
KeyValues map[string]string `json:"keyvalues"`
}
// PinataJSONRequest represents a request to pin JSON to IPFS
type PinataJSONRequest struct {
PinataMetadata PinataMetadata `json:"pinataMetadata"`
PinataContent interface{} `json:"pinataContent"`
}
// NewIPFSService creates a new IPFS service
func NewIPFSService() (*IPFSService, error) {
apiKey := os.Getenv("PINATA_API_KEY")
apiSecret := os.Getenv("PINATA_API_SECRET")
jwt := os.Getenv("PINATA_JWT")
if (apiKey == "" || apiSecret == "") && jwt == "" {
return nil, errors.New("either PINATA_API_KEY and PINATA_API_SECRET or PINATA_JWT must be set")
}
client := &http.Client{
Timeout: PinataTimeout,
}
return &IPFSService{
apiKey: apiKey,
apiSecret: apiSecret,
jwt: jwt,
client: client,
}, nil
}
// PinFile uploads a file to IPFS via Pinata
func (s *IPFSService) PinFile(filePath string, metadata map[string]string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
return s.PinFileContent(file, filepath.Base(filePath), metadata)
}
// PinFileContent uploads file content to IPFS via Pinata
func (s *IPFSService) PinFileContent(fileContent io.Reader, fileName string, metadata map[string]string) (string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add the file
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return "", fmt.Errorf("failed to create form file: %v", err)
}
_, err = io.Copy(part, fileContent)
if err != nil {
return "", fmt.Errorf("failed to copy file content: %v", err)
}
// Add metadata if provided
if metadata != nil && len(metadata) > 0 {
metadataJSON, err := json.Marshal(PinataMetadata{
Name: fileName,
KeyValues: metadata,
})
if err != nil {
return "", fmt.Errorf("failed to marshal metadata: %v", err)
}
err = writer.WriteField("pinataMetadata", string(metadataJSON))
if err != nil {
return "", fmt.Errorf("failed to write metadata field: %v", err)
}
}
err = writer.Close()
if err != nil {
return "", fmt.Errorf("failed to close multipart writer: %v", err)
}
req, err := http.NewRequest("POST", PinataPinFileURL, body)
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set authentication headers
if s.jwt != "" {
req.Header.Set("Authorization", "Bearer "+s.jwt)
} else {
req.Header.Set("pinata_api_key", s.apiKey)
req.Header.Set("pinata_secret_api_key", s.apiSecret)
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to pin file: %s, status: %d", string(body), resp.StatusCode)
}
var pinataResp PinataResponse
err = json.NewDecoder(resp.Body).Decode(&pinataResp)
if err != nil {
return "", fmt.Errorf("failed to decode response: %v", err)
}
return pinataResp.IpfsHash, nil
}
// PinJSON uploads JSON data to IPFS via Pinata
func (s *IPFSService) PinJSON(data interface{}, name string, metadata map[string]string) (string, error) {
pinataMetadata := PinataMetadata{
Name: name,
KeyValues: metadata,
}
pinataRequest := PinataJSONRequest{
PinataMetadata: pinataMetadata,
PinataContent: data,
}
jsonData, err := json.Marshal(pinataRequest)
if err != nil {
return "", fmt.Errorf("failed to marshal JSON: %v", err)
}
req, err := http.NewRequest("POST", PinataPinJSONURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
// Set authentication headers
if s.jwt != "" {
req.Header.Set("Authorization", "Bearer "+s.jwt)
} else {
req.Header.Set("pinata_api_key", s.apiKey)
req.Header.Set("pinata_secret_api_key", s.apiSecret)
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to pin JSON: %s, status: %d", string(body), resp.StatusCode)
}
var pinataResp PinataResponse
err = json.NewDecoder(resp.Body).Decode(&pinataResp)
if err != nil {
return "", fmt.Errorf("failed to decode response: %v", err)
}
return pinataResp.IpfsHash, nil
}
// GetIPFSGatewayURL returns a public gateway URL for an IPFS hash
func (s *IPFSService) GetIPFSGatewayURL(ipfsHash string) string {
// Use Pinata gateway or other public gateways
return fmt.Sprintf("https://gateway.pinata.cloud/ipfs/%s", ipfsHash)
}
// MockIPFSService is a mock implementation for testing and development
type MockIPFSService struct{}
// NewMockIPFSService creates a new mock IPFS service
func NewMockIPFSService() *MockIPFSService {
return &MockIPFSService{}
}
// PinFile mocks uploading a file to IPFS
func (s *MockIPFSService) PinFile(filePath string, metadata map[string]string) (string, error) {
log.Printf("Mock pinning file: %s with metadata: %v", filePath, metadata)
// Generate a deterministic hash based on the file path for testing
hashBase := strings.ReplaceAll(filepath.Base(filePath), ".", "")
if len(hashBase) > 10 {
hashBase = hashBase[:10]
}
ipfsHash := fmt.Sprintf("Qm%s123456789abcdef", hashBase)
return ipfsHash, nil
}
// PinJSON mocks uploading JSON data to IPFS
func (s *MockIPFSService) PinJSON(data interface{}, name string, metadata map[string]string) (string, error) {
log.Printf("Mock pinning JSON with name: %s and metadata: %v", name, metadata)
// Generate a deterministic hash based on the name for testing
hashBase := name
if len(hashBase) > 10 {
hashBase = hashBase[:10]
}
ipfsHash := fmt.Sprintf("Qm%s123456789abcdef", hashBase)
return ipfsHash, nil
}
// GetIPFSGatewayURL mocks a public gateway URL for an IPFS hash
func (s *MockIPFSService) GetIPFSGatewayURL(ipfsHash string) string {
return fmt.Sprintf("https://mock-gateway.pinata.cloud/ipfs/%s", ipfsHash)
}