Class: Everywhere::S3

Inherits:
Object
  • Object
show all
Defined in:
lib/everywhere/s3.rb

Overview

Minimal S3 client — just the four operations every publish needs (PUT / HEAD / GET / CopyObject), signed with AWS Signature V4, stdlib only.

Deliberately not aws-sdk-s3: the gem keeps a near-zero runtime dependency footprint, and SigV4 is a stable, testable ~150 lines. Works against any S3-compatible endpoint — AWS, Cloudflare R2, DigitalOcean Spaces, MinIO.

Addressing: virtual-host style for AWS proper, path style for custom endpoints (R2/Spaces/MinIO all accept it; MinIO requires it). Override with force_path_style.

Defined Under Namespace

Modules: Signer

Constant Summary collapse

Error =
Class.new(StandardError)
ALGORITHM =
"AWS4-HMAC-SHA256"
UNSIGNED =
"UNSIGNED-PAYLOAD"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(bucket:, region: "auto", endpoint: nil, force_path_style: nil, access_key_id:, secret_access_key:, session_token: nil) ⇒ S3

Returns a new instance of S3.

Raises:



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/everywhere/s3.rb', line 27

def initialize(bucket:, region: "auto", endpoint: nil, force_path_style: nil,
               access_key_id:, secret_access_key:, session_token: nil)
  @bucket = bucket
  @region = region.to_s.empty? ? "auto" : region
  @access_key_id = access_key_id
  @secret_access_key = secret_access_key
  @session_token = session_token

  if endpoint && !endpoint.empty?
    @endpoint = URI(endpoint)
    @path_style = force_path_style.nil? ? true : force_path_style
  else
    @endpoint = URI("https://s3.#{aws_region}.amazonaws.com")
    @path_style = force_path_style || false
  end
  raise Error, "endpoint must be http(s)" unless %w[http https].include?(@endpoint.scheme)
end

Instance Attribute Details

#bucketObject (readonly)

Returns the value of attribute bucket.



25
26
27
# File 'lib/everywhere/s3.rb', line 25

def bucket
  @bucket
end

Instance Method Details

#copy_object(source_key, dest_key, content_type: nil, cache_control: nil) ⇒ Object

Server-side copy within the bucket (how the -latest alias is refreshed without re-uploading the artifact).

Raises:



78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/everywhere/s3.rb', line 78

def copy_object(source_key, dest_key, content_type: nil, cache_control: nil)
  headers = { "x-amz-copy-source" => "/#{@bucket}/#{uri_encode_path(source_key)}" }
  if content_type || cache_control
    headers["x-amz-metadata-directive"] = "REPLACE"
    headers["content-type"] = content_type if content_type
    headers["cache-control"] = cache_control if cache_control
  end
  res = request(:put, dest_key, headers: headers)
  # CopyObject reports some failures inside a 200 body.
  raise Error, "copy #{source_key} -> #{dest_key} failed: #{res.body}" if res.body.to_s.include?("<Error>")

  true
end

#get_object(key) ⇒ Object

-> body String, or nil when the key doesn't exist.



71
72
73
74
# File 'lib/everywhere/s3.rb', line 71

def get_object(key)
  res = request(:get, key, allow_404: true)
  res.code == "404" ? nil : res.body
end

#head_object(key) ⇒ Object

-> { size:, etag: } or nil when the key doesn't exist.



63
64
65
66
67
68
# File 'lib/everywhere/s3.rb', line 63

def head_object(key)
  res = request(:head, key, allow_404: true)
  return nil if res.code == "404"

  { size: res["content-length"]&.to_i, etag: res["etag"]&.delete('"') }
end

#put_object(key, body, content_type: nil, cache_control: nil, sha256: nil, size: nil) ⇒ Object

body: String or IO (IO must respond to #size or be paired with size:). sha256: precomputed hex payload digest; IOs default to UNSIGNED-PAYLOAD.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/everywhere/s3.rb', line 47

def put_object(key, body, content_type: nil, cache_control: nil, sha256: nil, size: nil)
  headers = {}
  headers["content-type"] = content_type if content_type
  headers["cache-control"] = cache_control if cache_control
  if body.is_a?(String)
    sha256 ||= OpenSSL::Digest::SHA256.hexdigest(body)
    headers["content-length"] = body.bytesize.to_s
  else
    sha256 ||= UNSIGNED
    headers["content-length"] = (size || body.size).to_s
  end
  request(:put, key, headers: headers, body: body, payload_sha256: sha256)
  true
end