Files
Rohan Verma 11cad3573f feat: add object tagging support
Implements object tagging capabilities (v0.14.0) including:
- Core APIs: PutObjectTagging, GetObjectTagging, DeleteObjectTagging
- Integration with FilePut, FileUpload, and CopyObject operations
- Helper functions for URL-encoded tag header formatting
- Comprehensive input validation (max 10 tags, required fields)

Addresses specific compatibility issues with MinIO and Cloudflare R2:
- Implements a 2-step workaround for CopyObject (copy first, then tag) to avoid SignatureDoesNotMatch errors caused by the x-amz-tagging-directive header on non-AWS providers.

Includes comprehensive integration tests and documentation updates.
2025-11-27 16:14:32 +05:30

310 lines
7.2 KiB
Go

// LICENSE BSD-2-Clause-FreeBSD
// Copyright (c) 2018, Rohan Verma <hello@rohanverma.net>
package simples3
import (
"bytes"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
)
// Tag represents a single S3 object tag with a key-value pair.
type Tag struct {
Key string `xml:"Key"`
Value string `xml:"Value"`
}
// tagging is the internal type for XML marshaling/unmarshaling.
type tagging struct {
XMLName xml.Name `xml:"Tagging"`
XMLNS string `xml:"xmlns,attr,omitempty"`
TagSet tagSet `xml:"TagSet"`
}
// tagSet is the internal type for XML marshaling/unmarshaling.
type tagSet struct {
Tags []Tag `xml:"Tag"`
}
// PutObjectTaggingInput is passed to PutObjectTagging as a parameter.
type PutObjectTaggingInput struct {
// Required: The name of the bucket
Bucket string
// Required: The object key
ObjectKey string
// Required: Tags to set on the object (max 10 tags)
Tags map[string]string
}
// GetObjectTaggingInput is passed to GetObjectTagging as a parameter.
type GetObjectTaggingInput struct {
// Required: The name of the bucket
Bucket string
// Required: The object key
ObjectKey string
}
// GetObjectTaggingOutput is returned by GetObjectTagging.
type GetObjectTaggingOutput struct {
Tags map[string]string
}
// DeleteObjectTaggingInput is passed to DeleteObjectTagging as a parameter.
type DeleteObjectTaggingInput struct {
// Required: The name of the bucket
Bucket string
// Required: The object key
ObjectKey string
}
// PutObjectTagging sets tags on an existing S3 object.
// Replaces all existing tags with the provided tags.
// S3 allows up to 10 tags per object.
func (s3 *S3) PutObjectTagging(input PutObjectTaggingInput) error {
// Validate required fields
if input.Bucket == "" {
return fmt.Errorf("bucket name is required")
}
if input.ObjectKey == "" {
return fmt.Errorf("object key is required")
}
if len(input.Tags) == 0 {
return fmt.Errorf("at least one tag is required")
}
if len(input.Tags) > 10 {
return fmt.Errorf("cannot set more than 10 tags per object")
}
// Renew IAM token if needed
if err := s3.renewIAMToken(); err != nil {
return err
}
// Build XML request body
tagsXML := tagging{
XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/",
TagSet: tagSet{
Tags: make([]Tag, 0, len(input.Tags)),
},
}
for k, v := range input.Tags {
tagsXML.TagSet.Tags = append(tagsXML.TagSet.Tags, Tag{Key: k, Value: v})
}
xmlBody, err := xml.Marshal(tagsXML)
if err != nil {
return err
}
// Calculate Content-MD5 (required by S3 for this operation)
md5Hash := md5.Sum(xmlBody)
contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:])
// Build URL with ?tagging query parameter
baseURL := s3.getURL(input.Bucket, input.ObjectKey)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return err
}
query := url.Values{}
query.Set("tagging", "")
parsedURL.RawQuery = query.Encode()
// Create PUT request
req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody))
if err != nil {
return err
}
// Set ContentLength BEFORE other headers
req.ContentLength = int64(len(xmlBody))
// Calculate content SHA256 for x-amz-content-sha256 header
h := sha256.New()
h.Write(xmlBody)
req.Header.Set("x-amz-content-sha256", fmt.Sprintf("%x", h.Sum(nil)))
req.Header.Set("Content-Type", "application/xml")
req.Header.Set("Content-MD5", contentMD5)
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(xmlBody)))
req.Header.Set("Host", req.URL.Host)
// Sign the request
if err := s3.signRequest(req); err != nil {
return err
}
// Execute request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
// Read response body for error messages
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
// Handle non-OK status codes
if res.StatusCode != http.StatusOK {
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
}
return nil
}
// GetObjectTagging retrieves the tags associated with an S3 object.
func (s3 *S3) GetObjectTagging(input GetObjectTaggingInput) (GetObjectTaggingOutput, error) {
// Validate required fields
if input.Bucket == "" {
return GetObjectTaggingOutput{}, fmt.Errorf("bucket name is required")
}
if input.ObjectKey == "" {
return GetObjectTaggingOutput{}, fmt.Errorf("object key is required")
}
// Renew IAM token if needed
if err := s3.renewIAMToken(); err != nil {
return GetObjectTaggingOutput{}, err
}
// Build URL with ?tagging query parameter
baseURL := s3.getURL(input.Bucket, input.ObjectKey)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return GetObjectTaggingOutput{}, err
}
parsedURL.RawQuery = "tagging"
// Create GET request
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
if err != nil {
return GetObjectTaggingOutput{}, err
}
// Sign the request
if err := s3.signRequest(req); err != nil {
return GetObjectTaggingOutput{}, err
}
// Execute request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return GetObjectTaggingOutput{}, err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
// Read response body
body, err := io.ReadAll(res.Body)
if err != nil {
return GetObjectTaggingOutput{}, err
}
// Handle non-OK status codes
if res.StatusCode != http.StatusOK {
return GetObjectTaggingOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
}
// Parse XML response
var result tagging
if err := xml.Unmarshal(body, &result); err != nil {
return GetObjectTaggingOutput{}, err
}
// Convert to map
output := GetObjectTaggingOutput{
Tags: make(map[string]string, len(result.TagSet.Tags)),
}
for _, tag := range result.TagSet.Tags {
output.Tags[tag.Key] = tag.Value
}
return output, nil
}
// DeleteObjectTagging removes all tags from an S3 object.
func (s3 *S3) DeleteObjectTagging(input DeleteObjectTaggingInput) error {
// Validate required fields
if input.Bucket == "" {
return fmt.Errorf("bucket name is required")
}
if input.ObjectKey == "" {
return fmt.Errorf("object key is required")
}
// Renew IAM token if needed
if err := s3.renewIAMToken(); err != nil {
return err
}
// Build URL with ?tagging query parameter
baseURL := s3.getURL(input.Bucket, input.ObjectKey)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return err
}
query := url.Values{}
query.Set("tagging", "")
parsedURL.RawQuery = query.Encode()
// Create DELETE request
req, err := http.NewRequest(http.MethodDelete, parsedURL.String(), nil)
if err != nil {
return err
}
// Sign the request
if err := s3.signRequest(req); err != nil {
return err
}
// Execute request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
// Read response body for error messages
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
// Handle non-success status codes
// AWS returns 204 No Content on successful deletion
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
}
return nil
}