12345678module remote910import net.http11import time1213pub const s3_service = 's3'14pub const retry_attempts = 31516pub struct S3 {17pub:18 endpoint string19 bucket string20 region string21 access_key string22 secret_key string23mut:24 signer Signer25 root string 26}2728pub fn new_s3(endpoint string, bucket string, region string, access_key string, secret_key string) !S3 {29 if endpoint == '' || bucket == '' || region == '' {30 return error('the S3 remote needs an endpoint, a bucket and a region')31 }32 if access_key == '' || secret_key == '' {33 return error('the S3 remote needs an access key and a secret key')34 }35 if !endpoint.starts_with('http://') && !endpoint.starts_with('https://') {36 return error('the S3 endpoint should start with https:// — got "${endpoint}"')37 }38 root := endpoint.trim_right('/')39 return S3{40 endpoint: root41 bucket: bucket42 region: region43 access_key: access_key44 secret_key: secret_key45 signer: Signer{46 access_key: access_key47 secret_key: secret_key48 region: region49 service: s3_service50 sign_payload: true51 }52 root: root53 }54}5556pub fn (mut s S3) get(key string) !Object {57 response := s.send('GET', key, '', map[string]string{}, []u8{})!58 match response.status_code {59 404 {60 return Object{}61 }62 200 {63 return Object{64 found: true65 body: response.body.bytes()66 tag: clean_etag(header_of(response, 'ETag'))67 }68 }69 else {70 return error('s3 GET ${key} failed: ${response.status_code} ${short_body(response)}')71 }72 }73}7475pub fn (mut s S3) list(prefix string) ![]string {76 mut keys := []string{}77 mut token := ''78 for {79 80 81 mut query := 'list-type=2&prefix=${prefix}'82 if token != '' {83 query += '&continuation-token=${token}'84 }85 response := s.send('GET', '', query, map[string]string{}, []u8{})!86 if response.status_code != 200 {87 return error('s3 list failed: ${response.status_code} ${short_body(response)}')88 }89 keys << elements(response.body, 'Key')90 if !response.body.contains('<IsTruncated>true</IsTruncated>') {91 break92 }93 token = first_element(response.body, 'NextContinuationToken') or { break }94 }95 return keys96}9798pub fn (mut s S3) put_if_absent(key string, body []u8) !bool {99 response := s.send('PUT', key, '', {100 'If-None-Match': '*'101 }, body)!102 if response.status_code == 412 {103 return false104 }105 if !success(response.status_code) {106 return error('s3 PUT ${key} failed: ${response.status_code} ${short_body(response)}')107 }108 return true109}110111pub fn (mut s S3) put_if_match(key string, body []u8, tag string) !bool {112 mut headers := map[string]string{}113 if tag == '' {114 115 headers['If-None-Match'] = '*'116 } else {117 headers['If-Match'] = tag118 }119 response := s.send('PUT', key, '', headers, body)!120 if response.status_code == 412 {121 return false122 }123 if !success(response.status_code) {124 return error('s3 PUT ${key} failed: ${response.status_code} ${short_body(response)}')125 }126 return true127}128129pub fn (mut s S3) delete(key string) ! {130 response := s.send('DELETE', key, '', map[string]string{}, []u8{})!131 if !success(response.status_code) && response.status_code != 404 {132 return error('s3 DELETE ${key} failed: ${response.status_code} ${short_body(response)}')133 }134}135136137138fn (mut s S3) send(method string, key string, query string, headers map[string]string, body []u8) !http.Response {139 url := s.url_for(key, query)140 mut last := ''141 for attempt in 1 .. retry_attempts + 1 {142 signed := s.signer.sign(method, url, query, headers, body, time.now().local_to_utc())143 mut outbound := http.new_header()144 for name, value in signed {145 outbound.add_custom(header_case(name), value) or {}146 }147 response := http.fetch(148 url: url149 method: method_from(method)150 header: outbound151 data: body.bytestr()152 ) or {153 last = err.msg()154 time.sleep(backoff(attempt))155 continue156 }157 if response.status_code >= 500 {158 last = '${response.status_code}'159 time.sleep(backoff(attempt))160 continue161 }162 return response163 }164 return error('s3 ${method} ${key} failed after ${retry_attempts} attempts: ${last}')165}166167168169fn (s &S3) url_for(key string, query string) string {170 mut url := '${s.root}/${encode(s.bucket, false)}'171 if key != '' {172 url += '/${encode(key, true)}'173 }174 if query != '' {175 url += '?${canonical_query(query)}'176 }177 return url178}179180fn backoff(attempt int) time.Duration {181 return time.Duration(attempt) * 200 * time.millisecond182}183184fn method_from(method string) http.Method {185 return match method {186 'GET' { .get }187 'PUT' { .put }188 'POST' { .post }189 'DELETE' { .delete }190 'HEAD' { .head }191 else { .get }192 }193}194195fn success(status int) bool {196 return status >= 200 && status < 300197}198199fn header_of(response http.Response, name string) string {200 return response.header.get_custom(name, http.HeaderQueryConfig{}) or { '' }201}202203204205pub fn clean_etag(value string) string {206 return value.trim_space().trim('"')207}208209fn header_case(name string) string {210 return match name {211 'authorization' { 'Authorization' }212 'host' { 'Host' }213 'x-amz-date' { 'X-Amz-Date' }214 'x-amz-content-sha256' { 'X-Amz-Content-Sha256' }215 'if-none-match' { 'If-None-Match' }216 'if-match' { 'If-Match' }217 else { name }218 }219}220221fn short_body(response http.Response) string {222 body := response.body223 if body.len <= 200 {224 return body225 }226 return body[..200]227}228229230231232pub fn elements(xml string, name string) []string {233 open := '<${name}>'234 close := '</${name}>'235 mut out := []string{}236 mut rest := xml237 for {238 start := rest.index(open) or { break }239 after := rest[start + open.len..]240 end := after.index(close) or { break }241 out << unescape(after[..end])242 rest = after[end + close.len..]243 }244 return out245}246247fn first_element(xml string, name string) ?string {248 values := elements(xml, name)249 if values.len == 0 {250 return none251 }252 return values[0]253}254255fn unescape(text string) string {256 return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"',257 '"').replace(''', "'")258}