add nipb0/blossom helpers.

This commit is contained in:
fiatjaf
2025-03-04 12:42:24 -03:00
parent 5bfaed2740
commit 5bafd1c778
8 changed files with 366 additions and 0 deletions

22
nipb0/blossom/check.go Normal file
View File

@@ -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
}

54
nipb0/blossom/client.go Normal file
View File

@@ -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
}

24
nipb0/blossom/delete.go Normal file
View File

@@ -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
}

70
nipb0/blossom/download.go Normal file
View File

@@ -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
}

89
nipb0/blossom/http.go Normal file
View File

@@ -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)
}

35
nipb0/blossom/list.go Normal file
View File

@@ -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
}

22
nipb0/blossom/types.go Normal file
View File

@@ -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)
}

50
nipb0/blossom/upload.go Normal file
View File

@@ -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
}