#!/usr/bin/python3

import os, sys, locale, datetime, hashlib, hmac, base64, re, configparser
import http.client, os.path

def s3request(accesskey, secretkey, httpverb, host, filename, body=None,
              host_has_bucket=False, ssl=True, port=None, headers={},
              content_type=""):
    if host_has_bucket:
        bucket_and_file = "/%s%s" % (host.split(".", 1)[0], filename)
    else:
        bucket_and_file = filename

    for query in [x for x in ("object-lock",)
                  if bucket_and_file.endswith("?%s" % x)]:
        bucket_and_file = bucket_and_file[0:-len(query)-1]

    headers_to_sign = []
    for name in sorted([x for x in headers.keys()
                        if x.lower().startswith("x-amz-")]):
        headers_to_sign.append("%s:%s\n" % (name.lower(),
                                            headers[name].strip()))

    locale.setlocale(locale.LC_TIME, "C")
    headers["Date"] = datetime.datetime.utcnow().strftime("%a, %d %b %Y " +
                                                          "%H:%M:%S GMT")
    headers["Content-Type"] = content_type

    if body is None:
        headers["Content-MD5"] = ""
    else:
        md5 = hashlib.md5()
        md5.update(body)
        headers["Content-MD5"] = base64.b64encode(md5.digest()).decode()

    string_to_sign = "%s\n%s\n%s\n%s\n%s%s" % (httpverb,
                                               headers["Content-MD5"],
                                               headers["Content-Type"],
                                               headers["Date"],
                                               "".join(headers_to_sign),
                                               bucket_and_file)
    sha1_hmac = hmac.new(secretkey.encode(), string_to_sign.encode(),
                         hashlib.sha1);
    headers["Authorization"] = "AWS %s:%s" % (accesskey,
                               base64.b64encode(sha1_hmac.digest()).decode())

    if ssl:
       conn = http.client.HTTPSConnection(host, port)
    else:
       conn = http.client.HTTPConnection(host, port)

    conn.request(httpverb, filename, body, headers)

    return conn.getresponse()

def usage():
    sys.exit("""s3file.py -- manipulate buckets and objects on S3 storages

Usage:
  s3file.py [-e] [-s] [-p<port>] [-h<header:value> ...] [-t<content-type>]
            [-t] [-i<inifile>] [-v]
            <GET|HEAD|PUT|DELETE> <host> /bucket/path/to/file [<local file>]
  s3file.py [-e] [-s] [-p<port>] [-h<header:value> ...] [-t<content-type>]
            [-t] [-i<inifile>] [-v]
            -b <GET|HEAD|PUT|DELETE> <bucket.host> /path/to/file [<local file>]
  s3file.py --version
  s3file.py --examples

Parameters:
  -e                Print the HTTP ETag header returned by the server
  -s                Use HTTP instead of HTTPS
  -p<port>          Connect to <port> instead of 443 or 80
  -h<header:value>  Add an additional HTTP header to the request; maybe given
                    multiple times
  -t<content_type>  Set the content type for a PUT request
  -t                Show the content type returned by GET or HEAD
  -i<inifile>       Read accesskey and secretkey from the [s3file] section of
                    the given ini file
  -v                Show all HTTP headers returned by the request
  -b                Hostname is prefixed by the name of the bucket
  --version         Display version number
  --examples        Show a tutorial
""")

def examples():
    sys.exit("""Examples:

1.  Create a bucket:
  $ export S3ACCESSKEY="someuser"
  $ export S3SECRETKEY="somekey"
  $ echo -n "" | s3file.py PUT s3.somehost.invalid /new-bucket

2.  Create a bucket, but read credentials from an ini file:
  $ echo '[s3file]
accesskey = someuser
secretkey = somekey' > s3file.ini
  $ echo -n "" | s3file.py -is3file.ini PUT s3.somehost.invalid /new-bucket

3.  Delete a bucket:
  $ s3file.py -is3file.ini DELETE s3.somehost.invalid /my-bucket

4.  Upload a local file named bar.txt and save it as foo.txt:
  $ s3file.py -is3file.ini PUT s3.somehost.invalid /my-bucket/foo.txt bar.txt

5.  Delete a file:
  $ s3file.py -is3file.ini DELETE s3.somehost.invalid /my-bucket/foo.txt

6.  Write the content of a remote file to stdout:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/readme.txt

7.  Read from stdin and save it as remote file:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/readme.txt
...some content...
<Ctrl+D>

8.  Copy a file from one bucket to another:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/readme.txt | \\
    s3file.py -iconfig2.ini PUT s3.otherhost.invalid /bucket2/cool.txt

9.  Download a remote file and save it locally, but assume the first part of
   the hostname is the name of the bucket: 
  $ s3file.py -is3file.ini GET my-bucket.s3.somehost.invalid /read.me ohey.txt

10. Create a bucket with object locking:
  $ echo -n "" | s3file.py -is3file.ini \\
    -hx-amz-bucket-object-lock-enabled:True PUT s3.somehost.invalid /newbucket

11. Show whether a bucket has object locking enabled (HTTP error 404 means
    object locking is disabled):
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/?object-lock

12. Enable object locking in compliance mode for a bucket and set the default
    retention to 100 days:
  $ echo '<?xml version="1.0""?>
<ObjectLockConfiguration>
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
<Rule>
<DefaultRetention>
<Mode>COMPLIANCE</Mode>
<Days>100</Days>
</DefaultRetention>
</Rule>
</ObjectLockConfiguration>' | \\
    s3file.py -is3file.ini PUT s3.somehost.invalid /my-bucket/?object-lock

13. Enable object locking in governance mode for a bucket and set the default
    retention to 2 years:
  $ echo '<?xml version="1.0""?>
<ObjectLockConfiguration>
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
<Rule>
<DefaultRetention>
<Mode>GOVERNANCE</Mode>
<Years>2</Years>
</DefaultRetention>
</Rule>
</ObjectLockConfiguration>' | \\
    s3file.py -is3file.ini PUT s3.somehost.invalid /my-bucket/?object-lock

14. Enable object versioning for a bucket:
  $ echo '<?xml version="1.0"?>
<VersioningConfiguration>
<Status>Enabled</Status>
<MfaDelete>Disabled</MfaDelete>
</VersioningConfiguration>' | \\
    s3file.py -is3file.ini PUT s3.somehost.invalid /my-bucket/?versioning

15. Show whether a bucket has object versioning enabled:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/?versioning

16. List all versioned objects:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/?versions

17. Disable object versioning for a bucket:
  $ echo '<?xml version="1.0"?>
<VersioningConfiguration>
<Status>Disabled</Status>
<MfaDelete>Disabled</MfaDelete>
</VersioningConfiguration>' | \\
    s3file.py -is3file.ini PUT s3.somehost.invalid /my-bucket/?versioning

18. Show whether a bucket has access logging enabled:
  $ s3file.py -is3file.ini GET s3.somehost.invalid /my-bucket/?logging
""")


host_has_bucket = False
ssl = True
port = None
show_etag = False
headers = {}
content_type = ""
show_content_type = False
inifile = None
show_all_headers = False

arg_pos = 1
while arg_pos < len(sys.argv) and sys.argv[arg_pos].startswith("-"):
    if sys.argv[arg_pos] == "-b":
        host_has_bucket = True
    elif sys.argv[arg_pos] == "-s":
        ssl = False
    elif sys.argv[arg_pos] == "-e":
        show_etag = True
    elif not re.match(r"-p\d+$", sys.argv[arg_pos]) is None:
        port = int(sys.argv[arg_pos][2:])
    elif not re.match(r"-h[^:]+:.+$", sys.argv[arg_pos]) is None:
        name, value = sys.argv[arg_pos][2:].split(":", 1)
        headers[name] = value
    elif not re.match(r"-t.+$", sys.argv[arg_pos]) is None:
        content_type = sys.argv[arg_pos][2:]
    elif sys.argv[arg_pos] == "-t":
        show_content_type = True
    elif sys.argv[arg_pos] == "--examples":
        examples()
    elif not re.match(r"-i.+$", sys.argv[arg_pos]) is None:
        inifile = os.path.expanduser(sys.argv[arg_pos][2:])
    elif sys.argv[arg_pos] == "-v":
        show_all_headers = True
    elif sys.argv[arg_pos] == "--version":
        sys.exit("20220219")
    else:
        usage()
    arg_pos += 1

if (len(sys.argv) - arg_pos) < 3 or (len(sys.argv) - arg_pos) > 4:
    usage()

httpverb = sys.argv[arg_pos]
arg_pos += 1
host = sys.argv[arg_pos]
arg_pos += 1
filename = sys.argv[arg_pos]
arg_pos += 1
if len(sys.argv) == (arg_pos + 1):
    local_file = sys.argv[arg_pos]
else:
    local_file = None

if inifile is None:
    accesskey = os.getenv("S3ACCESSKEY")
    if accesskey is None:
        sys.exit("no S3ACCESSKEY environment variable")

    secretkey = os.getenv("S3SECRETKEY")
    if secretkey is None:
        sys.exit("no S3SECRETKEY environment variable")
else:
    config = configparser.ConfigParser()
    config.read(inifile)

    if not "s3file" in config:
        sys.exit("no s3file section in %s" % inifile)
    if not "accesskey" in config["s3file"]:
        sys.exit("no accesskey in s3file section of %s" % inifile)
    if not "secretkey" in config["s3file"]:
        sys.exit("no secretkey in s3file section of %s" % inifile)

    accesskey = config["s3file"]["accesskey"]
    secretkey = config["s3file"]["secretkey"]

output = os.fdopen(sys.stdout.fileno(), "wb")

if httpverb == "GET":
    body = None
    expected_response = 200
elif httpverb == "HEAD":
    if not local_file is None:
        sys.exit("HEAD and local file are mutual exclusive")
    body = None
    expected_response = 200
elif httpverb == "PUT":
    if local_file is None:
        body = os.fdopen(sys.stdin.fileno(), "rb").read()
    else:
        body = open(local_file, "rb").read()
    expected_response = 200
elif httpverb == "DELETE":
    if not local_file is None:
        sys.exit("DELETE and local file are mutual exclusive")
    body = None
    expected_response = 204
else:
    usage()

response = s3request(accesskey, secretkey, httpverb, host, filename, body,
                     host_has_bucket, ssl, port, headers, content_type)
if response.status != expected_response:
    sys.exit(str(response.status) + " " + response.reason)

content_length = response.getheader("Content-Length")
if content_length is None:
    sys.stderr.write("warning: no Content-Length\n")

if show_etag:
    etag = response.getheader("ETag")
    if etag is None:
        sys.stderr.write("warning: no ETag header\n")
    else:
        sys.stderr.write("ETag: %s\n" % etag)

if show_content_type:
    content_type = response.getheader("Content-Type")
    if content_type is None:
        sys.stderr.write("warning: no Content-Type header\n")
    else:
        sys.stderr.write("Content-Type: %s\n" % content_type)

if show_all_headers:
    for k,v in response.getheaders():
        sys.stderr.write("%s: %s\n" % (k, v))

if httpverb == "GET":
    if not local_file is None:
        output = open(local_file, "wb")

CHUNK_LENGTH = 10 * 1024 * 1024
read_bytes = 0

while True:
    chunk = response.read(CHUNK_LENGTH)
    output.write(chunk)
    read_bytes += len(chunk)
    if len(chunk) != CHUNK_LENGTH:
        break

if not content_length is None and str(read_bytes) != content_length:
    sys.exit("expected %sB, got %i" % (content_length, read_bytes))
