diff --git a/nip96/nip96.go b/nip96/nip96.go new file mode 100644 index 0000000..0515afa --- /dev/null +++ b/nip96/nip96.go @@ -0,0 +1,127 @@ +package nip96 + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + + "github.com/nbd-wtf/go-nostr" +) + +// Upload uploads a file to the provided req.Host. +func Upload(ctx context.Context, req UploadRequest) (*UploadResponse, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + client := http.DefaultClient + if req.HTTPClient != nil { + client = req.HTTPClient + } + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + { + // Add the file + fileWriter, err := writer.CreateFormFile("file", req.Filename) + if err != nil { + return nil, fmt.Errorf("multipartWriter.CreateFormFile: %w", err) + } + if _, err := io.Copy(fileWriter, req.File); err != nil { + return nil, fmt.Errorf("io.Copy: %w", err) + } + + // Add the other fields + writer.WriteField("caption", req.Caption) + writer.WriteField("alt", req.Alt) + writer.WriteField("media_type", req.MediaType) + writer.WriteField("content_type", req.ContentType) + writer.WriteField("no_transform", fmt.Sprintf("%t", req.NoTransform)) + if req.Expiration == 0 { + writer.WriteField("expiration", "") + } else { + writer.WriteField("expiration", strconv.FormatInt(int64(req.Expiration), 10)) + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("multipartWriter.Close: %w", err) + } + } + + uploadReq, err := http.NewRequest("POST", req.Host, &requestBody) + if err != nil { + return nil, fmt.Errorf("http.NewRequest: %w", err) + } + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + + if req.SK != "" { + auth, err := generateAuthHeader(req.SK, req.Host) + if err != nil { + return nil, fmt.Errorf("generateAuthHeader: %w", err) + } + uploadReq.Header.Set("Authorization", auth) + } + + resp, err := client.Do(uploadReq) + if err != nil { + return nil, fmt.Errorf("httpclient.Do: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusRequestEntityTooLarge: + return nil, fmt.Errorf("File is too large") + + case http.StatusBadRequest: + return nil, fmt.Errorf("Bad request") + + case http.StatusForbidden: + return nil, fmt.Errorf("Unauthorized") + + case http.StatusPaymentRequired: + return nil, fmt.Errorf("Payment required") + + case http.StatusOK, http.StatusCreated, http.StatusAccepted: + var uploadResp UploadResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return nil, fmt.Errorf("Error decoding JSON: %w", err) + } + return &uploadResp, nil + + default: + return nil, fmt.Errorf("Unexpected error %v", resp.Status) + } +} + +func generateAuthHeader(sk, host string) (string, error) { + pk, err := nostr.GetPublicKey(sk) + if err != nil { + return "", fmt.Errorf("nostr.GetPublicKey: %w", err) + } + + event := nostr.Event{ + Kind: 27235, + PubKey: pk, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + nostr.Tag{"u", host}, + nostr.Tag{"method", "POST"}, + }, + } + event.Sign(sk) + + b, err := json.Marshal(event) + if err != nil { + return "", fmt.Errorf("json.Marshal: %w", err) + } + + payload := base64.StdEncoding.EncodeToString(b) + + return fmt.Sprintf("Nostr %s", payload), nil +} diff --git a/nip96/nip96_test.go b/nip96/nip96_test.go new file mode 100644 index 0000000..3b1c298 --- /dev/null +++ b/nip96/nip96_test.go @@ -0,0 +1,33 @@ +package nip96 + +import ( + "context" + "os" + "testing" + + "github.com/nbd-wtf/go-nostr" +) + +func TestUpload(t *testing.T) { + img, _ := os.Open("./testdata/image.png") + defer img.Close() + + ctx := context.Background() + resp, err := Upload(ctx, UploadRequest{ + Host: "https://nostr.build/api/v2/nip96/upload", + //Host: "https://nostrcheck.me/api/v2/media", + //Host: "https://nostrage.com/api/v2/media", + SK: nostr.GeneratePrivateKey(), + File: img, + Filename: "ostrich.png", + Caption: "nostr ostrich", + ContentType: "image/png", + NoTransform: true, + }) + if err != nil { + t.Fatal(err, "client.Upload") + } + + t.Logf("resp: %#v\n", *resp) + // nip96_test.go:28: resp: nip96.UploadResponse{Status:"success", Message:"Upload successful.", ProcessingURL:"", Nip94Event:struct { Tags nostr.Tags "json:\"tags\"" }{Tags:nostr.Tags{nostr.Tag{"url", "https://image.nostr.build/4ece05f1d77c9cb97d334ba9c0301b2960640df89bf5d75d6bffadefc4355673.jpg"}, nostr.Tag{"ox", "4ece05f1d77c9cb97d334ba9c0301b2960640df89bf5d75d6bffadefc4355673"}, nostr.Tag{"x", ""}, nostr.Tag{"m", "image/jpeg"}, nostr.Tag{"dim", "1125x750"}, nostr.Tag{"bh", "LLF=kB-;yH-;-;R#t7xKEZWA#_oM"}, nostr.Tag{"blurhash", "LLF=kB-;yH-;-;R#t7xKEZWA#_oM"}, nostr.Tag{"thumb", "https://image.nostr.build/thumb/4ece05f1d77c9cb97d334ba9c0301b2960640df89bf5d75d6bffadefc4355673.jpg"}}}} +} diff --git a/nip96/testdata/image.png b/nip96/testdata/image.png new file mode 100644 index 0000000..ee155e6 Binary files /dev/null and b/nip96/testdata/image.png differ diff --git a/nip96/types.go b/nip96/types.go new file mode 100644 index 0000000..376825b --- /dev/null +++ b/nip96/types.go @@ -0,0 +1,69 @@ +package nip96 + +import ( + "fmt" + "io" + "net/http" + + "github.com/nbd-wtf/go-nostr" +) + +// UploadRequest is a NIP96 upload request. +type UploadRequest struct { + // Host is the NIP96 server to upload to. + Host string + + // SK is a private key used to sign the NIP-98 Auth header. If not set + // the auth header will not be included in the upload. + SK string + + // File is the file to upload. + File io.Reader + + // Filename is the name of the file, e.g. image.png + Filename string + + // Caption is a loose description of the file. + Caption string + + // Alt is a strict description text for visibility-impaired users. + Alt string + + // MediaType is "avatar" or "banner". Informs the server if the file will be + // used as an avatar or banner. If absent, the server will interpret it as a + // normal upload, without special treatment. + MediaType string + + // ContentType is the mime type such as "image/jpeg". This is just a value the + // server can use to reject early if the mime type isn't supported. + ContentType string + + // NoTransform set to "true" asks server not to transform the file and serve + // the uploaded file as is, may be rejected. + NoTransform bool + + // Expiration is a UNIX timestamp in seconds. Empty if file should be stored + // forever. The server isn't required to honor this. + Expiration nostr.Timestamp + + // HTTPClient is an option to provide your own HTTP Client. + HTTPClient *http.Client +} + +func (r *UploadRequest) Validate() error { + if r.Host == "" { + return fmt.Errorf("Host must be set") + } + + return nil +} + +// UploadResponse is a NIP96 upload response. +type UploadResponse struct { + Status string `json:"status"` + Message string `json:"message"` + ProcessingURL string `json:"processing_url"` + Nip94Event struct { + Tags nostr.Tags `json:"tags"` + } `json:"nip94_event"` +}