Skip to content

Commit

Permalink
Merge branch 'master' of github.com:jzwolak/Flask-Tus
Browse files Browse the repository at this point in the history
  • Loading branch information
jzwolak committed May 5, 2020
2 parents 404c5c1 + 7ce8558 commit 9f93b25
Showing 1 changed file with 196 additions and 125 deletions.
321 changes: 196 additions & 125 deletions flask_tus.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,47 @@ def __init__(self, app=None, upload_url='/file-upload', upload_folder='uploads/'
self.upload_finish_cb = upload_finish_cb
self.upload_file_handler_cb = None
self.blueprint = Blueprint('tus-manager', __name__)

self.blueprint.add_url_rule(self.upload_url, 'file-upload', self.tus_file_upload,
methods=['OPTIONS', 'POST', 'GET'])
self.blueprint.add_url_rule('{}/<resource_id>'.format(self.upload_url), 'file-upload-chunk',
self.tus_file_upload_chunk,
methods=['HEAD', 'PATCH', 'DELETE'])

self._register_routes()

if app is not None:
self.init_app(app)


def _register_routes(self):

# Note on routing names and method names.
# Use '-' to separate words/parts in endpoint.
# Use '_" to separate words/parts in method name.
# Use "tus" to start name
# followed by "core" or the extension name
# followed by protocol version (ommitting any trailing ".0"s and replacing '.' with '-' or '_')
# followed by a brief human redable description of what this method does
#
# Use "proprietary" instead of "core" or extension name for any features that are proprietary to this
# implementation.

# routes without resource id
self.blueprint.add_url_rule(
self.upload_url, 'tus-proprietary-get-file-exists', self.tus_proprietary_get_file_exists, methods=['GET'])
self.blueprint.add_url_rule(
self.upload_url, 'tus-1-get-server-info', self.tus_1_get_server_info, methods=['OPTIONS'])
# Tus protocol docs Creation section 6.a.iii
self.blueprint.add_url_rule(
self.upload_url, 'tus-creation-1-create', self.tus_creation_1_create, methods=['POST'])

# routes with resource id
# Tus protocol docs Core section 5.c.i.
self.blueprint.add_url_rule(
f"{self.upload_url}/<resource_id>", 'tus-1-get-resume-info', self.tus_1_get_resume_info, methods=['HEAD'])
# Tus protocol docs Core section 5.c.ii
self.blueprint.add_url_rule(
f"{self.upload_url}/<resource_id>", 'tus-1-upload-chunk', self.tus_1_upload_chunk, methods=['PATCH'])
# Tus protocol docs Termination section 6.e.i
self.blueprint.add_url_rule(
f"{self.upload_url}/<resource_id>", 'tus-1-delete', self.tus_1_delete, methods=['DELETE'])


def init_app(self, app):
self.app = app
self.app.register_blueprint(self.blueprint)
Expand Down Expand Up @@ -64,75 +95,97 @@ def _parse_metadata(self):
metadata[key] = base64.b64decode(value).decode('utf-8')
return metadata

def tus_file_upload(self):

# Untested. Possibly unused.
def tus_proprietary_get_file_exists(self):
print('begin tus_proprietary_get_file_exists')
response = make_response("", 200)
if 'Upload-Metadata' in request.headers:
response.headers['Upload-Metadata'] = request.headers['Upload-Metadata']

if request.method == 'GET':
metadata = self._parse_metadata()
metadata = self._parse_metadata()

if metadata.get("filename", None) is None:
return make_response("metadata filename is not set", 404)

(filename_name, extension) = os.path.splitext(metadata.get("filename"))
if filename_name.upper() in [os.path.splitext(f)[0].upper() for f in
os.listdir(os.path.dirname(self.upload_folder))]:
response.headers['Tus-File-Name'] = metadata.get("filename")
response.headers['Tus-File-Exists'] = True
else:
response.headers['Tus-File-Exists'] = False
return response


def tus_creation_1_create(self):
"""Implements POST to create file according to Tus protocol Creation extension"""
print('begin tus_creation_1_create')
response = make_response("", 200)
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported

if metadata.get("filename", None) is None:
return make_response("metadata filename is not set", 404)
if 'Upload-Metadata' in request.headers:
response.headers['Upload-Metadata'] = request.headers['Upload-Metadata']

(filename_name, extension) = os.path.splitext(metadata.get("filename"))
if filename_name.upper() in [os.path.splitext(f)[0].upper() for f in
os.listdir(os.path.dirname(self.upload_folder))]:
response.headers['Tus-File-Name'] = metadata.get("filename")
response.headers['Tus-File-Exists'] = True
else:
response.headers['Tus-File-Exists'] = False
if request.headers.get("Tus-Resumable") is None:
self.app.logger.warning("Received File upload for unsupported file transfer protocol")
response.data = "Received File upload for unsupported file transfer protocol"
response.status_code = 500
return response

elif request.method == 'OPTIONS' and request.headers.get('Access-Control-Request-Method', None) is not None:
# CORS option request, return 200
# process upload metadata
metadata = self._parse_metadata()

if os.path.lexists(
os.path.join(self.upload_folder, metadata.get("filename"))) and self.file_overwrite is False:
response.status_code = 409
return response

if request.headers.get("Tus-Resumable") is not None:
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported
file_size = int(request.headers.get("Upload-Length", "0"))
resource_id = str(uuid.uuid4())

if request.method == 'OPTIONS':
response.headers['Tus-Extension'] = ",".join(self.tus_api_extensions)
response.headers['Tus-Max-Size'] = self.tus_max_file_size
p = self.redis_connection.pipeline()
p.setex("file-uploads/{}/filename".format(resource_id), 3600, "{}".format(metadata.get("filename")))
p.setex("file-uploads/{}/file_size".format(resource_id), 3600, file_size)
p.setex("file-uploads/{}/offset".format(resource_id), 3600, 0)
p.setex("file-uploads/{}/upload-metadata".format(resource_id), 3600, request.headers.get("Upload-Metadata"))
p.execute()

response.status_code = 204
return response
try:
open(os.path.join(self.upload_folder, resource_id), "w").close()
except IOError as e:
self.app.logger.error("Unable to create file: {}".format(e))
response.status_code = 500
return response

# process upload metadata
metadata = self._parse_metadata()
response.status_code = 201
response.headers['Location'] = '{}/{}/{}'.format(request.url_root, self.upload_url, resource_id)
response.headers['Tus-Temp-Filename'] = resource_id
response.autocorrect_location_header = False

if os.path.lexists(
os.path.join(self.upload_folder, metadata.get("filename"))) and self.file_overwrite is False:
response.status_code = 409
return response
return response

# Untested. should be part of protocol, identify sections and verify correctness
def tus_1_get_server_info(self):
print('begin tus_1_get_server_info')
response = make_response("", 200)
if 'Upload-Metadata' in request.headers:
response.headers['Upload-Metadata'] = request.headers['Upload-Metadata']

file_size = int(request.headers.get("Upload-Length", "0"))
resource_id = str(uuid.uuid4())
if request.headers.get('Access-Control-Request-Method', None) is not None:
# CORS option request, return 200
return response

p = self.redis_connection.pipeline()
p.setex("file-uploads/{}/filename".format(resource_id), 3600, "{}".format(metadata.get("filename")))
p.setex("file-uploads/{}/file_size".format(resource_id), 3600, file_size)
p.setex("file-uploads/{}/offset".format(resource_id), 3600, 0)
p.setex("file-uploads/{}/upload-metadata".format(resource_id), 3600, request.headers.get("Upload-Metadata"))
p.execute()
if request.headers.get("Tus-Resumable") is not None:
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported

try:
f = open(os.path.join(self.upload_folder, resource_id), "wb")
f.seek(file_size - 1)
# TODO: is this really necessary? Writing a null byte to end of file?
f.write(bytes('\0', 'utf-8'))
f.close()
except IOError as e:
self.app.logger.error("Unable to create file: {}".format(e))
response.status_code = 500
return response

response.status_code = 201
response.headers['Location'] = '{}/{}/{}'.format(request.url_root, self.upload_url, resource_id)
response.headers['Tus-Temp-Filename'] = resource_id
response.autocorrect_location_header = False
response.headers['Tus-Extension'] = ",".join(self.tus_api_extensions)
response.headers['Tus-Max-Size'] = self.tus_max_file_size

response.status_code = 204
return response

else:
self.app.logger.warning("Received File upload for unsupported file transfer protocol")
Expand All @@ -141,90 +194,108 @@ def tus_file_upload(self):

return response

def tus_file_upload_chunk(self, resource_id):

def tus_1_get_resume_info(self, resource_id):
"""Implements HEAD according to Tus Core protocol."""
response = make_response("", 204)
if 'Upload-Metadata' in request.headers:
response.headers['Upload-Metadata'] = request.headers['Upload-Metadata']
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported
response.headers['Cache-Control'] = 'no-store'

offset = self.redis_connection.get("file-uploads/{}/offset".format(resource_id))
upload_file_path = os.path.join(self.upload_folder, resource_id)
length = self.redis_connection.get("file-uploads/{}/file_size".format(resource_id))
metadata = self.redis_connection.get("file-uploads/{}/upload-metadata".format(resource_id))

if offset is None:
response.status_code = 404
return response

if request.method == 'HEAD':
length = self.redis_connection.get("file-uploads/{}/file_size".format(resource_id))
if offset is None:
response.status_code = 404
return response
response.status_code = 200
response.headers['Upload-Offset'] = offset
if length is not None:
response.headers['Upload-Length'] = length
if metadata is not None:
response.headers['Upload-Metadata'] = request.headers['Upload-Metadata']

else:
response.status_code = 200
response.headers['Upload-Offset'] = offset
if length is not None:
response.headers['Upload-Length'] = length
response.headers['Cache-Control'] = 'no-store'
return response

return response

if request.method == 'DELETE':
os.unlink(upload_file_path)
def tus_1_upload_chunk(self, resource_id):
"""Implements DELETE according to Tus Core protocol"""
response = make_response("", 204)
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported

p = self.redis_connection.pipeline()
p.delete("file-uploads/{}/filename".format(resource_id))
p.delete("file-uploads/{}/file_size".format(resource_id))
p.delete("file-uploads/{}/offset".format(resource_id))
p.delete("file-uploads/{}/upload-metadata".format(resource_id))
p.execute()
# TODO: update following variable names to reflect "ours" (from redis) and "supplied" from headers
filename = self.redis_connection.get("file-uploads/{}/filename".format(resource_id)).decode('utf-8')
file_size = int(self.redis_connection.get("file-uploads/{}/file_size".format(resource_id)))
redis_offset = self.redis_connection.get("file-uploads/{}/offset".format(resource_id)).decode('utf-8')

response.status_code = 204
return respose
file_offset = int(request.headers.get("Upload-Offset", 0))
chunk_size = int(request.headers.get("Content-Length", 0))
header_offset = request.headers.get("Upload-Offset")

if request.method == 'PATCH':
filename = self.redis_connection.get("file-uploads/{}/filename".format(resource_id)).decode('utf-8')
if filename is None or os.path.lexists(upload_file_path) is False:
self.app.logger.info("PATCH sent for resource_id that does not exist. {}".format(resource_id))
response.status_code = 410
return response
upload_file_path = os.path.join(self.upload_folder, resource_id)

if filename is None or os.path.lexists(upload_file_path) is False:
self.app.logger.info("PATCH sent for resource_id that does not exist. {}".format(resource_id))
response.status_code = 410
return response

file_offset = int(request.headers.get("Upload-Offset", 0))
chunk_size = int(request.headers.get("Content-Length", 0))
file_size = int(self.redis_connection.get("file-uploads/{}/file_size".format(resource_id)))
if header_offset != redis_offset:
response.status_code = 409 # HTTP 409 Conflict
return response

header_offset = request.headers.get("Upload-Offset")
redis_offset = self.redis_connection.get("file-uploads/{}/offset".format(resource_id)).decode('utf-8')
try:
f = open(upload_file_path, "r+b")
except IOError:
f = open(upload_file_path, "wb")
finally:
f.seek(file_offset)
f.write(request.data)
f.close()

if header_offset != redis_offset:
response.status_code = 409 # HTTP 409 Conflict
return response
new_offset = self.redis_connection.incrby("file-uploads/{}/offset".format(resource_id), chunk_size)
response.headers['Upload-Offset'] = new_offset
response.headers['Tus-Temp-Filename'] = resource_id

if file_size == new_offset: # file transfer complete, rename from resource id to actual filename
try:
f = open(upload_file_path, "r+b")
except IOError:
f = open(upload_file_path, "wb")
if self.upload_file_handler_cb is None:
os.rename(upload_file_path, os.path.join(self.upload_folder, filename))
else:
filename = self.upload_file_handler_cb(upload_file_path, filename)
finally:
f.seek(file_offset)
f.write(request.data)
f.close()

new_offset = self.redis_connection.incrby("file-uploads/{}/offset".format(resource_id), chunk_size)
response.headers['Upload-Offset'] = new_offset
response.headers['Tus-Temp-Filename'] = resource_id

if file_size == new_offset: # file transfer complete, rename from resource id to actual filename
try:
if self.upload_file_handler_cb is None:
os.rename(upload_file_path, os.path.join(self.upload_folder, filename))
else:
filename = self.upload_file_handler_cb(upload_file_path, filename)
finally:
p = self.redis_connection.pipeline()
p.delete("file-uploads/{}/filename".format(resource_id))
p.delete("file-uploads/{}/file_size".format(resource_id))
p.delete("file-uploads/{}/offset".format(resource_id))
p.delete("file-uploads/{}/upload-metadata".format(resource_id))
p.execute()

if self.upload_finish_cb is not None:
self.upload_finish_cb()
self._remove_resources(resource_id)

return response
if self.upload_finish_cb is not None:
self.upload_finish_cb()

return response


def _remove_resources(self, resource_id):
p = self.redis_connection.pipeline()
p.delete("file-uploads/{}/filename".format(resource_id))
p.delete("file-uploads/{}/file_size".format(resource_id))
p.delete("file-uploads/{}/offset".format(resource_id))
p.delete("file-uploads/{}/upload-metadata".format(resource_id))
p.execute()

upload_file_path = os.path.join(self.upload_folder, resource_id)
try:
os.remove(upload_file_path)
except FileNotFoundError:
pass


def tus_1_delete(self, resource_id):
"""Implements DELETE according to Tus Termination protocol"""
response = make_response("", 204)
response.headers['Tus-Resumable'] = self.tus_api_version
response.headers['Tus-Version'] = self.tus_api_version_supported

self._remove_resources(resource_id)

response.status_code = 204
return respose

0 comments on commit 9f93b25

Please sign in to comment.