Files
simples3/object.go
T
Rohan Verma 9219b63613 feat: add object versioning support
Implements object versioning capabilities (v0.15.0) including:
- Bucket APIs: PutBucketVersioning, GetBucketVersioning
- Object Listing: ListVersions (lists versions and delete markers)
- Object Operations: Updated FileDownload, FileDelete, and FileDetails to support VersionId
- Integration tests covering the full versioning workflow (enable, upload versions, list, download specific version, delete specific version)

Documentation updated in README.md and ROADMAP.md.

"I have implemented Object Versioning (v0.15.0) as per the roadmap."
2025-11-27 16:48:58 +05:30

780 lines
19 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"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
type DownloadInput struct {
Bucket string
ObjectKey string
VersionId string // Optional: Version ID of the object to download
}
// DetailsInput is passed to FileDetails as a parameter.
type DetailsInput struct {
Bucket string
ObjectKey string
VersionId string // Optional: Version ID of the object to retrieve details for
}
// DetailsResponse is returned by FileDetails.
type DetailsResponse struct {
ContentType string
ContentLength string
AcceptRanges string
Date string
Etag string
LastModified string
Server string
AmzID2 string
AmzRequestID string
AmzMeta map[string]string
ExtraHeaders map[string]string
}
// UploadInput is passed to FileUpload as a parameter.
type UploadInput struct {
// essential fields
Bucket string
ObjectKey string
FileName string
ContentType string
// optional fields
ContentDisposition string
ACL string
// Setting key/value pairs adds user-defined metadata
// keys to the object, prefixed with AMZMetaPrefix.
CustomMetadata map[string]string
// Setting key/value pairs adds tags to the object (max 10 tags)
Tags map[string]string
Body io.ReadSeeker
}
// UploadResponse receives the following XML
// in case of success, since we set a 201 response from S3.
// Sample response:
//
// <PostResponse>
// <Location>https://s3.amazonaws.com/link-to-the-file</Location>
// <Bucket>s3-bucket</Bucket>
// <Key>development/8614bd40-691b-4668-9241-3b342c6cf429/image.jpg</Key>
// <ETag>"32-bit-tag"</ETag>
// </PostResponse>
type UploadResponse struct {
Location string `xml:"Location"`
Bucket string `xml:"Bucket"`
Key string `xml:"Key"`
ETag string `xml:"ETag"`
}
// PutResponse is returned when the action is successful,
// and the service sends back an HTTP 200 response. The response
// returns ETag along with HTTP headers.
type PutResponse struct {
ETag string
Headers http.Header
}
// DeleteInput is passed to FileDelete as a parameter.
type DeleteInput struct {
Bucket string
ObjectKey string
VersionId string // Optional: Version ID of the object to delete
}
// FileDownload makes a GET call and returns a io.ReadCloser.
// After reading the response body, ensure closing the response.
func (s3 *S3) FileDownload(u DownloadInput) (io.ReadCloser, error) {
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
if u.VersionId != "" {
parsed, err := url.Parse(urlStr)
if err != nil {
return nil, err
}
q := parsed.Query()
q.Set("versionId", u.VersionId)
parsed.RawQuery = q.Encode()
urlStr = parsed.String()
}
req, err := http.NewRequest(
http.MethodGet, urlStr, nil,
)
if err != nil {
return nil, err
}
if err := s3.renewIAMToken(); err != nil {
return nil, err
}
if err := s3.signRequest(req); err != nil {
return nil, err
}
res, err := s3.getClient().Do(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %s", res.Status)
}
return res.Body, nil
}
// FilePut makes a PUT call to S3.
func (s3 *S3) FilePut(u UploadInput) (PutResponse, error) {
fSize, err := detectFileSize(u.Body)
if err != nil {
return PutResponse{}, err
}
content := make([]byte, fSize)
_, err = u.Body.Read(content)
if err != nil {
return PutResponse{}, err
}
u.Body.Seek(0, 0)
req, er := http.NewRequest(http.MethodPut, s3.getURL(u.Bucket, u.ObjectKey), u.Body)
if er != nil {
return PutResponse{}, err
}
if u.ContentType == "" {
u.ContentType = "application/octet-stream"
}
h := sha256.New()
h.Write(content)
req.Header.Set("x-amz-content-sha256", fmt.Sprintf("%x", h.Sum(nil)))
req.Header.Set("Content-Type", u.ContentType)
req.Header.Set("Content-Length", fmt.Sprintf("%d", fSize))
req.Header.Set("Host", req.URL.Host)
for k, v := range u.CustomMetadata {
req.Header.Set("x-amz-meta-"+k, v)
}
if u.ContentDisposition != "" {
req.Header.Set("Content-Disposition", u.ContentDisposition)
}
if u.ACL != "" {
req.Header.Set("x-amz-acl", u.ACL)
}
if len(u.Tags) > 0 {
req.Header.Set("x-amz-tagging", encodeTagsHeader(u.Tags))
}
req.ContentLength = fSize
if err := s3.renewIAMToken(); err != nil {
return PutResponse{}, err
}
if err := s3.signRequest(req); err != nil {
return PutResponse{}, err
}
// debug(httputil.DumpRequest(req, true))
// Submit the request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return PutResponse{}, err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
data, err := io.ReadAll(res.Body)
if err != nil {
return PutResponse{}, err
}
// Check the response
if res.StatusCode != http.StatusOK {
return PutResponse{}, fmt.Errorf("status code: %s: %q", res.Status, data)
}
return PutResponse{
ETag: res.Header.Get("ETag"),
Headers: res.Header.Clone(),
}, nil
}
// FileUpload makes a POST call with the file written as multipart
// and on successful upload, checks for 200 OK.
func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) {
fSize, err := detectFileSize(u.Body)
if err != nil {
return UploadResponse{}, err
}
uc := UploadConfig{
UploadURL: s3.getURL(u.Bucket),
BucketName: u.Bucket,
ObjectKey: u.ObjectKey,
ContentType: u.ContentType,
ContentDisposition: u.ContentDisposition,
ACL: u.ACL,
FileSize: fSize,
MetaData: map[string]string{
"success_action_status": "201", // returns XML doc on success
},
}
// Set custom metadata.
for k, v := range u.CustomMetadata {
if !strings.HasPrefix(k, AMZMetaPrefix) {
k = AMZMetaPrefix + k
}
uc.MetaData[k] = v
}
// Set tags if provided.
if len(u.Tags) > 0 {
uc.MetaData["x-amz-tagging"] = encodeTagsHeader(u.Tags)
}
if err := s3.renewIAMToken(); err != nil {
return UploadResponse{}, err
}
policies, err := s3.CreateUploadPolicies(uc)
if err != nil {
return UploadResponse{}, err
}
var b bytes.Buffer
w := multipart.NewWriter(&b)
for k, v := range policies.Form {
if err = w.WriteField(k, v); err != nil {
return UploadResponse{}, err
}
}
fw, err := w.CreateFormFile("file", u.FileName)
if err != nil {
return UploadResponse{}, err
}
if _, err = io.Copy(fw, u.Body); err != nil {
return UploadResponse{}, err
}
// Don't forget to close the multipart writer.
// If you don't close it, your request will be missing the terminating boundary.
if err := w.Close(); err != nil {
return UploadResponse{}, err
}
// Now that you have a form, you can submit it to your handler.
req, err := http.NewRequest(http.MethodPost, policies.URL, &b)
if err != nil {
return UploadResponse{}, err
}
// Don't forget to set the content type, this will contain the boundary.
req.Header.Set("Content-Type", w.FormDataContentType())
// Submit the request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return UploadResponse{}, err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
data, err := io.ReadAll(res.Body)
if err != nil {
return UploadResponse{}, err
}
// Check the response
if res.StatusCode != http.StatusCreated {
return UploadResponse{}, fmt.Errorf("status code: %s: %q", res.Status, data)
}
var ur UploadResponse
_ = xml.Unmarshal(data, &ur)
return ur, nil
}
// FileDelete makes a DELETE call with the file written as multipart
// and on successful upload, checks for 204 No Content.
func (s3 *S3) FileDelete(u DeleteInput) error {
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
if u.VersionId != "" {
parsed, err := url.Parse(urlStr)
if err != nil {
return err
}
q := parsed.Query()
q.Set("versionId", u.VersionId)
parsed.RawQuery = q.Encode()
urlStr = parsed.String()
}
req, err := http.NewRequest(
http.MethodDelete, urlStr, nil,
)
if err != nil {
return err
}
if err := s3.renewIAMToken(); err != nil {
return err
}
if err := s3.signRequest(req); err != nil {
return err
}
// Submit the 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)
}()
// Check the response
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("status code: %s", res.Status)
}
return nil
}
func (s3 *S3) FileDetails(u DetailsInput) (DetailsResponse, error) {
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
if u.VersionId != "" {
parsed, err := url.Parse(urlStr)
if err != nil {
return DetailsResponse{}, err
}
q := parsed.Query()
q.Set("versionId", u.VersionId)
parsed.RawQuery = q.Encode()
urlStr = parsed.String()
}
req, err := http.NewRequest(
http.MethodHead, urlStr, nil,
)
if err != nil {
return DetailsResponse{}, err
}
if err := s3.renewIAMToken(); err != nil {
return DetailsResponse{}, err
}
if err := s3.signRequest(req); err != nil {
return DetailsResponse{}, err
}
res, err := s3.getClient().Do(req)
if err != nil {
return DetailsResponse{}, err
}
defer func() {
res.Body.Close()
io.Copy(io.Discard, res.Body)
}()
if res.StatusCode != http.StatusOK {
return DetailsResponse{}, fmt.Errorf("status code: %s", res.Status)
}
var out DetailsResponse
for k, v := range res.Header {
lk := strings.ToLower(k)
switch lk {
case "content-type":
out.ContentType = getFirstString(v)
case "content-length":
out.ContentLength = getFirstString(v)
case "accept-ranges":
out.AcceptRanges = getFirstString(v)
case "date":
out.Date = getFirstString(v)
case "etag":
out.Etag = getFirstString(v)
case "last-modified":
out.LastModified = getFirstString(v)
case "server":
out.Server = getFirstString(v)
case "x-amz-id-2":
out.AmzID2 = getFirstString(v)
case "x-amz-request-id":
out.AmzRequestID = getFirstString(v)
default:
if strings.HasPrefix(lk, AMZMetaPrefix) {
if out.AmzMeta == nil {
out.AmzMeta = map[string]string{}
}
out.AmzMeta[k] = getFirstString(v)
} else {
if out.ExtraHeaders == nil {
out.ExtraHeaders = map[string]string{}
}
out.ExtraHeaders[k] = getFirstString(v)
}
}
}
return out, nil
}
// CopyObjectInput is passed to CopyObject as a parameter.
type CopyObjectInput struct {
// Required: Source bucket name
SourceBucket string
// Required: Source object key
SourceKey string
// Required: Destination bucket name
DestBucket string
// Required: Destination object key
DestKey string
// Optional: COPY (default) or REPLACE
// COPY - copies metadata from source
// REPLACE - replaces metadata with values specified in this input
MetadataDirective string
// Optional: Content type (only used when MetadataDirective = REPLACE)
ContentType string
// Optional: Custom metadata (only used when MetadataDirective = REPLACE)
CustomMetadata map[string]string
// Optional: Tags to set on the destination object (max 10 tags)
// When set, uses x-amz-tagging-directive: REPLACE
Tags map[string]string
}
// CopyObjectOutput is returned by CopyObject.
type CopyObjectOutput struct {
ETag string
LastModified time.Time
}
// copyObjectResult is the internal type for XML parsing.
type copyObjectResult struct {
ETag string `xml:"ETag"`
LastModified time.Time `xml:"LastModified"`
}
// CopyObject copies an object from source to destination.
// Can copy within the same bucket or across buckets.
// This operation is server-side, avoiding download/upload cycle.
func (s3 *S3) CopyObject(input CopyObjectInput) (CopyObjectOutput, error) {
// Validate required fields
if input.SourceBucket == "" || input.SourceKey == "" {
return CopyObjectOutput{}, fmt.Errorf("source bucket and key are required")
}
if input.DestBucket == "" || input.DestKey == "" {
return CopyObjectOutput{}, fmt.Errorf("destination bucket and key are required")
}
// Renew IAM token if needed
if err := s3.renewIAMToken(); err != nil {
return CopyObjectOutput{}, err
}
// Build destination URL
url := s3.getURL(input.DestBucket, input.DestKey)
// Create PUT request
req, err := http.NewRequest(http.MethodPut, url, nil)
if err != nil {
return CopyObjectOutput{}, err
}
// Set x-amz-copy-source header (URL-encoded)
copySource := "/" + input.SourceBucket + "/" + encodePath(input.SourceKey)
req.Header.Set("x-amz-copy-source", copySource)
// Optional metadata directive
if input.MetadataDirective != "" {
req.Header.Set("x-amz-metadata-directive", input.MetadataDirective)
}
// If REPLACE, set content-type and custom metadata
if input.MetadataDirective == "REPLACE" {
if input.ContentType != "" {
req.Header.Set("Content-Type", input.ContentType)
}
for k, v := range input.CustomMetadata {
req.Header.Set("x-amz-meta-"+k, v)
}
}
// Set tags if provided (with REPLACE directive to override source tags)
// Note: x-amz-tagging-directive causes signature errors in MinIO despite being supported in code
// See: https://github.com/minio/minio/pull/9711, https://github.com/minio/minio/pull/9478
// Without directive: tags are copied from source (default S3 behavior)
// With directive: signature mismatch error in MinIO (works on AWS S3)
//
// WORKAROUND: To support both MinIO and AWS without signature errors, we do not set
// the tagging headers here. Instead, we apply the tags using PutObjectTagging
// after the copy operation is complete.
// if len(input.Tags) > 0 {
// req.Header.Set("x-amz-tagging-directive", "REPLACE")
// req.Header.Set("x-amz-tagging", encodeTagsHeader(input.Tags))
// }
// Sign the request
if err := s3.signRequest(req); err != nil {
return CopyObjectOutput{}, err
}
// Execute request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return CopyObjectOutput{}, 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 CopyObjectOutput{}, err
}
// Handle non-OK status codes
if res.StatusCode != http.StatusOK {
return CopyObjectOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
}
// Parse XML response
var result copyObjectResult
if err := xml.Unmarshal(body, &result); err != nil {
return CopyObjectOutput{}, err
}
// Apply tags if provided (2-step workaround)
if len(input.Tags) > 0 {
err := s3.PutObjectTagging(PutObjectTaggingInput{
Bucket: input.DestBucket,
ObjectKey: input.DestKey,
Tags: input.Tags,
})
if err != nil {
return CopyObjectOutput{
ETag: result.ETag,
LastModified: result.LastModified,
}, fmt.Errorf("copy succeeded but tagging failed: %v", err)
}
}
return CopyObjectOutput{
ETag: result.ETag,
LastModified: result.LastModified,
}, nil
}
// DeleteObjectsInput is passed to DeleteObjects as a parameter.
type DeleteObjectsInput struct {
// Required: The name of the bucket
Bucket string
// Required: List of object keys to delete (max 1000)
Objects []string
// Optional: Quiet mode - only return errors, not successes
Quiet bool
}
// DeleteObjectsOutput is returned by DeleteObjects.
type DeleteObjectsOutput struct {
Deleted []DeletedObject
Errors []DeleteError
}
// DeletedObject represents a successfully deleted object.
type DeletedObject struct {
Key string `xml:"Key"`
}
// DeleteError represents a deletion error.
type DeleteError struct {
Key string `xml:"Key"`
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// deleteRequest is the internal type for XML marshaling of the request.
type deleteRequest struct {
XMLName xml.Name `xml:"Delete"`
XMLNS string `xml:"xmlns,attr"`
Quiet bool `xml:"Quiet"`
Objects []deleteObject `xml:"Object"`
}
// deleteObject represents an object to delete in the XML request.
type deleteObject struct {
Key string `xml:"Key"`
}
// deleteResult is the internal type for XML parsing of the response.
type deleteResult struct {
Deleted []DeletedObject `xml:"Deleted"`
Errors []DeleteError `xml:"Error"`
}
// DeleteObjects deletes multiple objects in a single request (max 1000).
// This is more efficient than calling FileDelete multiple times.
// Returns both successful deletions and errors.
func (s3 *S3) DeleteObjects(input DeleteObjectsInput) (DeleteObjectsOutput, error) {
// Validate required fields
if input.Bucket == "" {
return DeleteObjectsOutput{}, fmt.Errorf("bucket name is required")
}
if len(input.Objects) == 0 {
return DeleteObjectsOutput{}, fmt.Errorf("at least one object key is required")
}
if len(input.Objects) > 1000 {
return DeleteObjectsOutput{}, fmt.Errorf("cannot delete more than 1000 objects per request")
}
// Renew IAM token if needed
if err := s3.renewIAMToken(); err != nil {
return DeleteObjectsOutput{}, err
}
// Build XML request body
deleteReq := deleteRequest{
XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/",
Quiet: input.Quiet,
Objects: make([]deleteObject, len(input.Objects)),
}
for i, key := range input.Objects {
deleteReq.Objects[i] = deleteObject{Key: key}
}
xmlBody, err := xml.Marshal(deleteReq)
if err != nil {
return DeleteObjectsOutput{}, err
}
// Calculate Content-MD5 (required by S3 for this operation)
md5Hash := md5.Sum(xmlBody)
contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:])
// Build URL with ?delete query parameter
baseURL := s3.getURL(input.Bucket)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return DeleteObjectsOutput{}, err
}
// Add delete query parameter (no value, just "?delete")
parsedURL.RawQuery = "delete"
// Create POST request
req, err := http.NewRequest(http.MethodPost, parsedURL.String(), bytes.NewReader(xmlBody))
if err != nil {
return DeleteObjectsOutput{}, 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 DeleteObjectsOutput{}, err
}
// Execute request
client := s3.getClient()
res, err := client.Do(req)
if err != nil {
return DeleteObjectsOutput{}, 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 DeleteObjectsOutput{}, err
}
// Handle non-OK status codes
if res.StatusCode != http.StatusOK {
return DeleteObjectsOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
}
// Parse XML response
var result deleteResult
if err := xml.Unmarshal(body, &result); err != nil {
return DeleteObjectsOutput{}, err
}
return DeleteObjectsOutput{
Deleted: result.Deleted,
Errors: result.Errors,
}, nil
}