package cloudflare import ( "bytes" "context" "errors" "fmt" "io" "mime/multipart" "net/http" "time" "github.com/goccy/go-json" ) var ( ErrInvalidImagesAPIVersion = errors.New("invalid images API version") ErrMissingImageID = errors.New("required image ID missing") ) type ImagesAPIVersion string const ( ImagesAPIVersionV1 ImagesAPIVersion = "v1" ImagesAPIVersionV2 ImagesAPIVersion = "v2" ) // Image represents a Cloudflare Image. type Image struct { ID string `json:"id"` Filename string `json:"filename"` Meta map[string]interface{} `json:"meta,omitempty"` RequireSignedURLs bool `json:"requireSignedURLs"` Variants []string `json:"variants"` Uploaded time.Time `json:"uploaded"` } // UploadImageParams is the data required for an Image Upload request. type UploadImageParams struct { File io.ReadCloser URL string Name string RequireSignedURLs bool Metadata map[string]interface{} } // write writes the image upload data to a multipart writer, so // it can be used in an HTTP request. func (b UploadImageParams) write(mpw *multipart.Writer) error { if b.File == nil && b.URL == "" { return errors.New("a file or url to upload must be specified") } if b.File != nil { name := b.Name part, err := mpw.CreateFormFile("file", name) if err != nil { return err } _, err = io.Copy(part, b.File) if err != nil { _ = b.File.Close() return err } _ = b.File.Close() } if b.URL != "" { err := mpw.WriteField("url", b.URL) if err != nil { return err } } // According to the Cloudflare docs, this field defaults to false. // For simplicity, we will only send it if the value is true, however // if the default is changed to true, this logic will need to be updated. if b.RequireSignedURLs { err := mpw.WriteField("requireSignedURLs", "true") if err != nil { return err } } if b.Metadata != nil { part, err := mpw.CreateFormField("metadata") if err != nil { return err } err = json.NewEncoder(part).Encode(b.Metadata) if err != nil { return err } } return nil } // UpdateImageParams is the data required for an UpdateImage request. type UpdateImageParams struct { ID string `json:"-"` RequireSignedURLs bool `json:"requireSignedURLs"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // CreateImageDirectUploadURLParams is the data required for a CreateImageDirectUploadURL request. type CreateImageDirectUploadURLParams struct { Version ImagesAPIVersion `json:"-"` Expiry *time.Time `json:"expiry,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` RequireSignedURLs *bool `json:"requireSignedURLs,omitempty"` } // ImageDirectUploadURLResponse is the API response for a direct image upload url. type ImageDirectUploadURLResponse struct { Result ImageDirectUploadURL `json:"result"` Response } // ImageDirectUploadURL . type ImageDirectUploadURL struct { ID string `json:"id"` UploadURL string `json:"uploadURL"` } // ImagesListResponse is the API response for listing all images. type ImagesListResponse struct { Result struct { Images []Image `json:"images"` } `json:"result"` Response } // ImageDetailsResponse is the API response for getting an image's details. type ImageDetailsResponse struct { Result Image `json:"result"` Response } // ImagesStatsResponse is the API response for image stats. type ImagesStatsResponse struct { Result struct { Count ImagesStatsCount `json:"count"` } `json:"result"` Response } // ImagesStatsCount is the stats attached to a ImagesStatsResponse. type ImagesStatsCount struct { Current int64 `json:"current"` Allowed int64 `json:"allowed"` } type ListImagesParams struct { ResultInfo } // UploadImage uploads a single image. // // API Reference: https://api.cloudflare.com/#cloudflare-images-upload-an-image-using-a-single-http-request func (api *API) UploadImage(ctx context.Context, rc *ResourceContainer, params UploadImageParams) (Image, error) { if rc.Level != AccountRouteLevel { return Image{}, ErrRequiredAccountLevelResourceContainer } if params.File != nil && params.URL != "" { return Image{}, errors.New("file and url uploads are mutually exclusive and can only be performed individually") } uri := fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier) body := &bytes.Buffer{} w := multipart.NewWriter(body) if err := params.write(w); err != nil { _ = w.Close() return Image{}, fmt.Errorf("error writing multipart body: %w", err) } _ = w.Close() res, err := api.makeRequestContextWithHeaders( ctx, http.MethodPost, uri, body, http.Header{ "Accept": []string{"application/json"}, "Content-Type": []string{w.FormDataContentType()}, }, ) if err != nil { return Image{}, err } var imageDetailsResponse ImageDetailsResponse err = json.Unmarshal(res, &imageDetailsResponse) if err != nil { return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imageDetailsResponse.Result, nil } // UpdateImage updates an existing image's metadata. // // API Reference: https://api.cloudflare.com/#cloudflare-images-update-image func (api *API) UpdateImage(ctx context.Context, rc *ResourceContainer, params UpdateImageParams) (Image, error) { if rc.Level != AccountRouteLevel { return Image{}, ErrRequiredAccountLevelResourceContainer } uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, params.ID) res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) if err != nil { return Image{}, err } var imageDetailsResponse ImageDetailsResponse err = json.Unmarshal(res, &imageDetailsResponse) if err != nil { return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imageDetailsResponse.Result, nil } var imagesMultipartBoundary = "----CloudflareImagesGoClientBoundary" // CreateImageDirectUploadURL creates an authenticated direct upload url. // // API Reference: https://api.cloudflare.com/#cloudflare-images-create-authenticated-direct-upload-url func (api *API) CreateImageDirectUploadURL(ctx context.Context, rc *ResourceContainer, params CreateImageDirectUploadURLParams) (ImageDirectUploadURL, error) { if rc.Level != AccountRouteLevel { return ImageDirectUploadURL{}, ErrRequiredAccountLevelResourceContainer } if params.Version != "" && params.Version != ImagesAPIVersionV1 && params.Version != ImagesAPIVersionV2 { return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion } var err error var uri string var res []byte switch params.Version { case ImagesAPIVersionV2: uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, params.Version) body := &bytes.Buffer{} writer := multipart.NewWriter(body) if err := writer.SetBoundary(imagesMultipartBoundary); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error setting multipart boundary") } if *params.RequireSignedURLs { if err = writer.WriteField("requireSignedURLs", "true"); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error writing requireSignedURLs field: %w", err) } } if !params.Expiry.IsZero() { if err = writer.WriteField("expiry", params.Expiry.Format(time.RFC3339)); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error writing expiry field: %w", err) } } if params.Metadata != nil { var metadataBytes []byte if metadataBytes, err = json.Marshal(params.Metadata); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error marshalling metadata to JSON: %w", err) } if err = writer.WriteField("metadata", string(metadataBytes)); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error writing metadata field: %w", err) } } if err = writer.Close(); err != nil { return ImageDirectUploadURL{}, fmt.Errorf("error closing multipart writer: %w", err) } res, err = api.makeRequestContextWithHeaders( ctx, http.MethodPost, uri, body, http.Header{ "Accept": []string{"application/json"}, "Content-Type": []string{writer.FormDataContentType()}, }, ) case ImagesAPIVersionV1: case "": uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, ImagesAPIVersionV1) res, err = api.makeRequestContext(ctx, http.MethodPost, uri, params) default: return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion } if err != nil { return ImageDirectUploadURL{}, err } var imageDirectUploadURLResponse ImageDirectUploadURLResponse err = json.Unmarshal(res, &imageDirectUploadURLResponse) if err != nil { return ImageDirectUploadURL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imageDirectUploadURLResponse.Result, nil } // ListImages lists all images. // // API Reference: https://api.cloudflare.com/#cloudflare-images-list-images func (api *API) ListImages(ctx context.Context, rc *ResourceContainer, params ListImagesParams) ([]Image, error) { uri := buildURI(fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier), params) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { return []Image{}, err } var imagesListResponse ImagesListResponse err = json.Unmarshal(res, &imagesListResponse) if err != nil { return []Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imagesListResponse.Result.Images, nil } // GetImage gets the details of an uploaded image. // // API Reference: https://api.cloudflare.com/#cloudflare-images-image-details func (api *API) GetImage(ctx context.Context, rc *ResourceContainer, id string) (Image, error) { if rc.Level != AccountRouteLevel { return Image{}, ErrRequiredAccountLevelResourceContainer } uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { return Image{}, err } var imageDetailsResponse ImageDetailsResponse err = json.Unmarshal(res, &imageDetailsResponse) if err != nil { return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imageDetailsResponse.Result, nil } // GetBaseImage gets the base image used to derive variants. // // API Reference: https://api.cloudflare.com/#cloudflare-images-base-image func (api *API) GetBaseImage(ctx context.Context, rc *ResourceContainer, id string) ([]byte, error) { if rc.Level != AccountRouteLevel { return []byte{}, ErrRequiredAccountLevelResourceContainer } uri := fmt.Sprintf("/accounts/%s/images/v1/%s/blob", rc.Identifier, id) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { return nil, err } return res, nil } // DeleteImage deletes an image. // // API Reference: https://api.cloudflare.com/#cloudflare-images-delete-image func (api *API) DeleteImage(ctx context.Context, rc *ResourceContainer, id string) error { if rc.Level != AccountRouteLevel { return ErrRequiredAccountLevelResourceContainer } uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id) _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) if err != nil { return err } return nil } // GetImagesStats gets an account's statistics for Cloudflare Images. // // API Reference: https://api.cloudflare.com/#cloudflare-images-images-usage-statistics func (api *API) GetImagesStats(ctx context.Context, rc *ResourceContainer) (ImagesStatsCount, error) { if rc.Level != AccountRouteLevel { return ImagesStatsCount{}, ErrRequiredAccountLevelResourceContainer } uri := fmt.Sprintf("/accounts/%s/images/v1/stats", rc.Identifier) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { return ImagesStatsCount{}, err } var imagesStatsResponse ImagesStatsResponse err = json.Unmarshal(res, &imagesStatsResponse) if err != nil { return ImagesStatsCount{}, fmt.Errorf("%s: %w", errUnmarshalError, err) } return imagesStatsResponse.Result.Count, nil }