// Package storage provides a Go SDK for Synor Storage operations. // // Decentralized storage, pinning, and content retrieval on the Synor network. // // Example: // // client := storage.NewClient("your-api-key") // result, err := client.Upload(ctx, []byte("Hello, World!"), nil) // fmt.Println("CID:", result.CID) // // // Get gateway URL // gateway := client.GetGatewayURL(result.CID, "") // fmt.Println("URL:", gateway.URL) package storage import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/url" "time" ) // Version of the SDK. const Version = "0.1.0" // Default endpoints. const ( DefaultEndpoint = "https://storage.synor.cc/api/v1" DefaultGateway = "https://gateway.synor.cc" ) // PinStatus represents the status of a pin. type PinStatus string const ( Queued PinStatus = "queued" Pinning PinStatus = "pinning" Pinned PinStatus = "pinned" Failed PinStatus = "failed" Unpinned PinStatus = "unpinned" ) // HashAlgorithm represents a hash algorithm. type HashAlgorithm string const ( SHA256 HashAlgorithm = "sha2-256" BLAKE3 HashAlgorithm = "blake3" ) // EntryType represents a directory entry type. type EntryType string const ( FileType EntryType = "file" DirectoryType EntryType = "directory" ) // MatchType represents a pin matching type. type MatchType string const ( Exact MatchType = "exact" IExact MatchType = "iexact" Partial MatchType = "partial" IPartial MatchType = "ipartial" ) // Config holds client configuration. type Config struct { APIKey string Endpoint string Gateway string PinningService string ChunkSize int Timeout time.Duration Debug bool } // DefaultConfig returns a default configuration. func DefaultConfig(apiKey string) Config { return Config{ APIKey: apiKey, Endpoint: DefaultEndpoint, Gateway: DefaultGateway, ChunkSize: 262144, // 256KB Timeout: 30 * time.Second, Debug: false, } } // Client is the Synor Storage client. type Client struct { config Config httpClient *http.Client } // NewClient creates a new client with the given API key. func NewClient(apiKey string) *Client { return NewClientWithConfig(DefaultConfig(apiKey)) } // NewClientWithConfig creates a new client with custom configuration. func NewClientWithConfig(config Config) *Client { return &Client{ config: config, httpClient: &http.Client{ Timeout: config.Timeout, }, } } // UploadOptions contains options for uploading content. type UploadOptions struct { Pin bool WrapWithDirectory bool CIDVersion int HashAlgorithm HashAlgorithm } // UploadResponse contains the result of an upload. type UploadResponse struct { CID string `json:"cid"` Size int64 `json:"size"` Name string `json:"name,omitempty"` Hash string `json:"hash,omitempty"` } // DownloadOptions contains options for downloading content. type DownloadOptions struct { Offset int64 Length int64 } // Pin represents pin information. type Pin struct { CID string `json:"cid"` Status PinStatus `json:"status"` Name string `json:"name,omitempty"` Size int64 `json:"size,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` ExpiresAt int64 `json:"expiresAt,omitempty"` Delegates []string `json:"delegates,omitempty"` } // PinRequest contains options for pinning content. type PinRequest struct { CID string `json:"cid"` Name string `json:"name,omitempty"` Duration int64 `json:"duration,omitempty"` Origins []string `json:"origins,omitempty"` } // ListPinsOptions contains options for listing pins. type ListPinsOptions struct { Status []PinStatus Match MatchType Name string Limit int Offset int } // ListPinsResponse contains the result of listing pins. type ListPinsResponse struct { Pins []Pin `json:"pins"` Total int `json:"total"` HasMore bool `json:"hasMore"` } // GatewayURL represents a gateway URL. type GatewayURL struct { URL string CID string Path string } // CarBlock represents a CAR block. type CarBlock struct { CID string `json:"cid"` Data string `json:"data"` Size int64 `json:"size,omitempty"` } // CarFile represents a CAR file. type CarFile struct { Version int `json:"version"` Roots []string `json:"roots"` Blocks []CarBlock `json:"blocks,omitempty"` Size int64 `json:"size,omitempty"` } // FileEntry represents a file entry for directory creation. type FileEntry struct { Name string Content []byte CID string } // DirectoryEntry represents a directory entry. type DirectoryEntry struct { Name string `json:"name"` CID string `json:"cid"` Size int64 `json:"size,omitempty"` Type EntryType `json:"type"` } // ImportCarResponse contains the result of importing a CAR file. type ImportCarResponse struct { Roots []string `json:"roots"` BlocksImported int `json:"blocksImported"` } // StorageStats represents storage statistics. type StorageStats struct { TotalSize int64 `json:"totalSize"` PinCount int `json:"pinCount"` Bandwidth struct { Upload int64 `json:"upload"` Download int64 `json:"download"` } `json:"bandwidth,omitempty"` } // Upload uploads content to storage. func (c *Client) Upload(ctx context.Context, data []byte, opts *UploadOptions) (*UploadResponse, error) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) part, err := writer.CreateFormFile("file", "file") if err != nil { return nil, fmt.Errorf("failed to create form file: %w", err) } if _, err := part.Write(data); err != nil { return nil, fmt.Errorf("failed to write data: %w", err) } if err := writer.Close(); err != nil { return nil, fmt.Errorf("failed to close writer: %w", err) } params := url.Values{} if opts != nil { if opts.Pin { params.Set("pin", "true") } if opts.WrapWithDirectory { params.Set("wrapWithDirectory", "true") } if opts.CIDVersion != 0 { params.Set("cidVersion", fmt.Sprintf("%d", opts.CIDVersion)) } if opts.HashAlgorithm != "" { params.Set("hashAlgorithm", string(opts.HashAlgorithm)) } } path := "/upload" if len(params) > 0 { path += "?" + params.Encode() } reqURL := c.config.Endpoint + path req, err := http.NewRequestWithContext(ctx, "POST", reqURL, &buf) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) req.Header.Set("Content-Type", writer.FormDataContentType()) if c.config.Debug { fmt.Printf("[SynorStorage] POST %s\n", path) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, c.parseError(resp) } var result UploadResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &result, nil } // Download downloads content by CID. func (c *Client) Download(ctx context.Context, cid string, opts *DownloadOptions) ([]byte, error) { params := url.Values{} if opts != nil { if opts.Offset > 0 { params.Set("offset", fmt.Sprintf("%d", opts.Offset)) } if opts.Length > 0 { params.Set("length", fmt.Sprintf("%d", opts.Length)) } } path := fmt.Sprintf("/content/%s", cid) if len(params) > 0 { path += "?" + params.Encode() } reqURL := c.config.Endpoint + path req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, c.parseError(resp) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } return data, nil } // DownloadReader returns a reader for downloading content. func (c *Client) DownloadReader(ctx context.Context, cid string, opts *DownloadOptions) (io.ReadCloser, error) { params := url.Values{} if opts != nil { if opts.Offset > 0 { params.Set("offset", fmt.Sprintf("%d", opts.Offset)) } if opts.Length > 0 { params.Set("length", fmt.Sprintf("%d", opts.Length)) } } path := fmt.Sprintf("/content/%s/stream", cid) if len(params) > 0 { path += "?" + params.Encode() } reqURL := c.config.Endpoint + path req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } if resp.StatusCode >= 400 { defer resp.Body.Close() return nil, c.parseError(resp) } return resp.Body, nil } // Pin pins content by CID. func (c *Client) Pin(ctx context.Context, req PinRequest) (*Pin, error) { var result Pin if err := c.request(ctx, "POST", "/pins", req, &result); err != nil { return nil, err } return &result, nil } // Unpin unpins content by CID. func (c *Client) Unpin(ctx context.Context, cid string) error { return c.request(ctx, "DELETE", "/pins/"+cid, nil, nil) } // GetPinStatus gets the pin status for a CID. func (c *Client) GetPinStatus(ctx context.Context, cid string) (*Pin, error) { var result Pin if err := c.request(ctx, "GET", "/pins/"+cid, nil, &result); err != nil { return nil, err } return &result, nil } // ListPins lists pins. func (c *Client) ListPins(ctx context.Context, opts *ListPinsOptions) (*ListPinsResponse, error) { path := "/pins" if opts != nil { params := url.Values{} if len(opts.Status) > 0 { var statuses []string for _, s := range opts.Status { statuses = append(statuses, string(s)) } params.Set("status", joinStrings(statuses, ",")) } if opts.Match != "" { params.Set("match", string(opts.Match)) } if opts.Name != "" { params.Set("name", opts.Name) } if opts.Limit > 0 { params.Set("limit", fmt.Sprintf("%d", opts.Limit)) } if opts.Offset > 0 { params.Set("offset", fmt.Sprintf("%d", opts.Offset)) } if len(params) > 0 { path += "?" + params.Encode() } } var result ListPinsResponse if err := c.request(ctx, "GET", path, nil, &result); err != nil { return nil, err } return &result, nil } // GetGatewayURL returns the gateway URL for content. func (c *Client) GetGatewayURL(cid string, path string) GatewayURL { fullPath := "/" + cid if path != "" { fullPath += "/" + path } return GatewayURL{ URL: c.config.Gateway + "/ipfs" + fullPath, CID: cid, Path: path, } } // CreateCar creates a CAR file from files. func (c *Client) CreateCar(ctx context.Context, files []FileEntry) (*CarFile, error) { type fileData struct { Name string `json:"name"` Content string `json:"content,omitempty"` CID string `json:"cid,omitempty"` } var entries []fileData for _, f := range files { entry := fileData{Name: f.Name} if len(f.Content) > 0 { entry.Content = base64.StdEncoding.EncodeToString(f.Content) } if f.CID != "" { entry.CID = f.CID } entries = append(entries, entry) } var result CarFile if err := c.request(ctx, "POST", "/car/create", map[string]interface{}{"files": entries}, &result); err != nil { return nil, err } return &result, nil } // ImportCar imports a CAR file. func (c *Client) ImportCar(ctx context.Context, carData []byte, pin bool) (*ImportCarResponse, error) { encoded := base64.StdEncoding.EncodeToString(carData) body := map[string]interface{}{ "car": encoded, "pin": pin, } var result ImportCarResponse if err := c.request(ctx, "POST", "/car/import", body, &result); err != nil { return nil, err } return &result, nil } // ExportCar exports content as a CAR file. func (c *Client) ExportCar(ctx context.Context, cid string) ([]byte, error) { reqURL := c.config.Endpoint + "/car/export/" + cid req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, c.parseError(resp) } return io.ReadAll(resp.Body) } // CreateDirectory creates a directory from files. func (c *Client) CreateDirectory(ctx context.Context, files []FileEntry) (*UploadResponse, error) { type fileData struct { Name string `json:"name"` Content string `json:"content,omitempty"` CID string `json:"cid,omitempty"` } var entries []fileData for _, f := range files { entry := fileData{Name: f.Name} if len(f.Content) > 0 { entry.Content = base64.StdEncoding.EncodeToString(f.Content) } if f.CID != "" { entry.CID = f.CID } entries = append(entries, entry) } var result UploadResponse if err := c.request(ctx, "POST", "/directory", map[string]interface{}{"files": entries}, &result); err != nil { return nil, err } return &result, nil } // ListDirectory lists directory contents. func (c *Client) ListDirectory(ctx context.Context, cid string, path string) ([]DirectoryEntry, error) { apiPath := fmt.Sprintf("/directory/%s", cid) if path != "" { apiPath += "?path=" + url.QueryEscape(path) } var resp struct { Entries []DirectoryEntry `json:"entries"` } if err := c.request(ctx, "GET", apiPath, nil, &resp); err != nil { return nil, err } return resp.Entries, nil } // GetStats gets storage statistics. func (c *Client) GetStats(ctx context.Context) (*StorageStats, error) { var result StorageStats if err := c.request(ctx, "GET", "/stats", nil, &result); err != nil { return nil, err } return &result, nil } // Exists checks if content exists. func (c *Client) Exists(ctx context.Context, cid string) (bool, error) { reqURL := c.config.Endpoint + "/content/" + cid req, err := http.NewRequestWithContext(ctx, "HEAD", reqURL, nil) if err != nil { return false, err } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) resp, err := c.httpClient.Do(req) if err != nil { return false, nil } defer resp.Body.Close() return resp.StatusCode == 200, nil } // GetMetadata gets content metadata. func (c *Client) GetMetadata(ctx context.Context, cid string) (map[string]interface{}, error) { var result map[string]interface{} if err := c.request(ctx, "GET", "/content/"+cid+"/metadata", nil, &result); err != nil { return nil, err } return result, nil } func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error { reqURL := c.config.Endpoint + path var bodyReader io.Reader if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } bodyReader = bytes.NewReader(bodyBytes) } req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.APIKey) req.Header.Set("Content-Type", "application/json") if c.config.Debug { fmt.Printf("[SynorStorage] %s %s\n", method, path) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return c.parseError(resp) } if result != nil && resp.StatusCode != 204 { if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } } return nil } func (c *Client) parseError(resp *http.Response) error { var errResp struct { Message string `json:"message"` Code string `json:"code"` } json.NewDecoder(resp.Body).Decode(&errResp) return &StorageError{ Message: errResp.Message, StatusCode: resp.StatusCode, Code: errResp.Code, } } func joinStrings(strs []string, sep string) string { if len(strs) == 0 { return "" } result := strs[0] for i := 1; i < len(strs); i++ { result += sep + strs[i] } return result } // StorageError represents an API error. type StorageError struct { Message string StatusCode int Code string } func (e *StorageError) Error() string { return fmt.Sprintf("storage: %s (status %d)", e.Message, e.StatusCode) }