diff --git a/nipb0/blossom/check.go b/nipb0/blossom/check.go new file mode 100644 index 0000000..37b2f35 --- /dev/null +++ b/nipb0/blossom/check.go @@ -0,0 +1,22 @@ +package blossom + +import ( + "context" + "fmt" + + "github.com/nbd-wtf/go-nostr" +) + +// Check checks if a file exists on the media server by its hash +func (c *Client) Check(ctx context.Context, hash string) error { + if !nostr.IsValid32ByteHex(hash) { + return fmt.Errorf("%s is not a valid 32-byte hex string", hash) + } + + err := c.httpCall(ctx, "HEAD", c.mediaserver+"/"+hash, "", nil, nil, 0, nil) + if err != nil { + return fmt.Errorf("failed to check for %s: %w", hash, err) + } + + return nil +} diff --git a/nipb0/blossom/client.go b/nipb0/blossom/client.go new file mode 100644 index 0000000..b20bae4 --- /dev/null +++ b/nipb0/blossom/client.go @@ -0,0 +1,54 @@ +package blossom + +import ( + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/valyala/fasthttp" +) + +// Client represents a Blossom client for interacting with a media server +type Client struct { + mediaserver string + httpClient *fasthttp.Client + signer nostr.Signer +} + +// NewClient creates a new Blossom client +func NewClient(mediaserver string, signer nostr.Signer) *Client { + return &Client{ + mediaserver: mediaserver, + httpClient: createHTTPClient(), + signer: signer, + } +} + +// createHTTPClient creates a properly configured HTTP client +func createHTTPClient() *fasthttp.Client { + readTimeout, _ := time.ParseDuration("10s") + writeTimeout, _ := time.ParseDuration("10s") + maxIdleConnDuration, _ := time.ParseDuration("1h") + return &fasthttp.Client{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + MaxIdleConnDuration: maxIdleConnDuration, + NoDefaultUserAgentHeader: true, // Don't send: User-Agent: fasthttp + DisableHeaderNamesNormalizing: true, // If you set the case on your headers correctly you can enable this + DisablePathNormalizing: true, + // increase DNS cache time to an hour instead of default minute + Dial: (&fasthttp.TCPDialer{ + Concurrency: 4096, + DNSCacheDuration: time.Hour, + }).Dial, + } +} + +// GetSigner returns the client's signer +func (c *Client) GetSigner() nostr.Signer { + return c.signer +} + +// GetMediaServer returns the client's media server URL +func (c *Client) GetMediaServer() string { + return c.mediaserver +} diff --git a/nipb0/blossom/delete.go b/nipb0/blossom/delete.go new file mode 100644 index 0000000..dcdf64d --- /dev/null +++ b/nipb0/blossom/delete.go @@ -0,0 +1,24 @@ +package blossom + +import ( + "context" + "fmt" + + "github.com/nbd-wtf/go-nostr" +) + +// Delete deletes a file from the media server by its hash +func (c *Client) Delete(ctx context.Context, hash string) error { + err := c.httpCall(ctx, "DELETE", c.mediaserver+"/"+hash, "", func() string { + return c.authorizationHeader(ctx, func(evt *nostr.Event) { + evt.Tags = append(evt.Tags, nostr.Tag{"t", "delete"}) + evt.Tags = append(evt.Tags, nostr.Tag{"x", hash}) + }) + }, nil, 0, nil) + + if err != nil { + return fmt.Errorf("failed to delete %s: %w", hash, err) + } + + return nil +} diff --git a/nipb0/blossom/download.go b/nipb0/blossom/download.go new file mode 100644 index 0000000..0cf09b3 --- /dev/null +++ b/nipb0/blossom/download.go @@ -0,0 +1,70 @@ +package blossom + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "github.com/nbd-wtf/go-nostr" +) + +// Download downloads a file from the media server by its hash +func (c *Client) Download(ctx context.Context, hash string) ([]byte, error) { + if !nostr.IsValid32ByteHex(hash) { + return nil, fmt.Errorf("%s is not a valid 32-byte hex string", hash) + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call %s for %s: %w", c.mediaserver, hash, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s is not present in %s: %d", hash, c.mediaserver, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// DownloadToFile downloads a file from the media server and saves it to the specified path +func (c *Client) DownloadToFile(ctx context.Context, hash string, filePath string) error { + if !nostr.IsValid32ByteHex(hash) { + return fmt.Errorf("%s is not a valid 32-byte hex string", hash) + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.mediaserver+"/"+hash, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to call %s for %s: %w", c.mediaserver, hash, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("%s is not present in %s: %d", hash, c.mediaserver, resp.StatusCode) + } + + file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create file %s for %s: %w", filePath, hash, err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write to file %s for %s: %w", filePath, hash, err) + } + + return nil +} diff --git a/nipb0/blossom/http.go b/nipb0/blossom/http.go new file mode 100644 index 0000000..61efbb7 --- /dev/null +++ b/nipb0/blossom/http.go @@ -0,0 +1,89 @@ +package blossom + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "strconv" + + "github.com/nbd-wtf/go-nostr" + "github.com/valyala/fasthttp" +) + +// httpCall makes an HTTP request to the media server +func (c *Client) httpCall( + ctx context.Context, + method string, + url string, + contentType string, + addAuthorization func() string, + body io.Reader, + contentSize int64, + result any, +) error { + _ = ctx + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(url) + req.Header.SetMethod(method) + req.Header.SetContentType(contentType) + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + if addAuthorization != nil { + auth := addAuthorization() + if auth != "" { + req.Header.Add("Authorization", auth) + } + } + + if body != nil { + req.SetBodyStream(body, int(contentSize)) + } + + err := c.httpClient.Do(req, resp) + if err != nil { + return fmt.Errorf("failed to call %s: %w\n", url, err) + } + if resp.Header.StatusCode() >= 300 { + reason := resp.Header.Peek("X-Reason") + return fmt.Errorf("%s returned an error (%d): %s", url, resp.StatusCode(), string(reason)) + } + + if result != nil { + return json.Unmarshal(resp.Body(), &result) + } + + return nil +} + +// authorizationHeader creates a Nostr-signed authorization header +func (c *Client) authorizationHeader( + ctx context.Context, + modify func(*nostr.Event), +) string { + evt := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 24242, + Content: "blossom stuff", + Tags: nostr.Tags{ + nostr.Tag{"expiration", strconv.FormatInt(int64(nostr.Now())+60, 10)}, + }, + } + + if modify != nil { + modify(&evt) + } + + if err := c.signer.SignEvent(ctx, &evt); err != nil { + return "" + } + + jevt, _ := json.Marshal(evt) + return "Nostr " + base64.StdEncoding.EncodeToString(jevt) +} diff --git a/nipb0/blossom/list.go b/nipb0/blossom/list.go new file mode 100644 index 0000000..98f7dc0 --- /dev/null +++ b/nipb0/blossom/list.go @@ -0,0 +1,35 @@ +package blossom + +import ( + "context" + "fmt" + + "github.com/nbd-wtf/go-nostr" +) + +// List retrieves a list of blobs from a specific pubkey +func (c *Client) List(ctx context.Context, pubkey string) ([]BlobDescriptor, error) { + if pubkey == "" { + var err error + pubkey, err = c.signer.GetPublicKey(ctx) + if err != nil { + return nil, fmt.Errorf("could not get pubkey: %w", err) + } + } + + if !nostr.IsValidPublicKey(pubkey) { + return nil, fmt.Errorf("pubkey %s is not valid", pubkey) + } + + bds := make([]BlobDescriptor, 0, 100) + err := c.httpCall(ctx, "GET", c.mediaserver+"/list/"+pubkey, "", func() string { + return c.authorizationHeader(ctx, func(evt *nostr.Event) { + evt.Tags = append(evt.Tags, nostr.Tag{"t", "list"}) + }) + }, nil, 0, &bds) + if err != nil { + return nil, fmt.Errorf("failed to list blobs: %w", err) + } + + return bds, nil +} diff --git a/nipb0/blossom/types.go b/nipb0/blossom/types.go new file mode 100644 index 0000000..7e3ae66 --- /dev/null +++ b/nipb0/blossom/types.go @@ -0,0 +1,22 @@ +package blossom + +import ( + "encoding/json" + + "github.com/nbd-wtf/go-nostr" +) + +// BlobDescriptor represents metadata about a blob stored on a media server +type BlobDescriptor struct { + URL string `json:"url"` + SHA256 string `json:"sha256"` + Size int `json:"size"` + Type string `json:"type"` + Uploaded nostr.Timestamp `json:"uploaded"` +} + +// String returns a JSON string representation of the BlobDescriptor +func (bd BlobDescriptor) String() string { + j, _ := json.Marshal(bd) + return string(j) +} diff --git a/nipb0/blossom/upload.go b/nipb0/blossom/upload.go new file mode 100644 index 0000000..975a83f --- /dev/null +++ b/nipb0/blossom/upload.go @@ -0,0 +1,50 @@ +package blossom + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "mime" + "os" + "path/filepath" + + "github.com/nbd-wtf/go-nostr" +) + +// UploadFile uploads a file to the media server +func (c *Client) UploadFile(ctx context.Context, filePath string) (*BlobDescriptor, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", filePath, err) + } + defer file.Close() + + sha := sha256.New() + size, err := io.Copy(sha, file) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", filePath, err) + } + hash := sha.Sum(nil) + + _, err = file.Seek(0, 0) + if err != nil { + return nil, fmt.Errorf("failed to reset file position: %w", err) + } + + contentType := mime.TypeByExtension(filepath.Ext(filePath)) + + bd := BlobDescriptor{} + err = c.httpCall(ctx, "PUT", c.mediaserver+"/upload", contentType, func() string { + return c.authorizationHeader(ctx, func(evt *nostr.Event) { + evt.Tags = append(evt.Tags, nostr.Tag{"t", "upload"}) + evt.Tags = append(evt.Tags, nostr.Tag{"x", hex.EncodeToString(hash[:])}) + }) + }, file, size, &bd) + if err != nil { + return nil, fmt.Errorf("failed to upload %s: %w", filePath, err) + } + + return &bd, nil +}