FILENAME = 'filename' ID = 'id'  # storage name SIZE = 'size' TYPE = 'type' TRANSACTION_ID = 'Transaction-ID'  # keep in sync with bepasty-cli def get_maxlife(data, underscore):
    unit_key = 'maxlife_unit' if underscore else 'maxlife-unit'
    unit_default = 'MONTHS'
    unit = data.get(unit_key, unit_default).upper()
    value_key = 'maxlife_value' if underscore else 'maxlife-value'
    value_default = '1'
    try:
        value = int(data.get(value_key, value_default))
    except (ValueError, TypeError):
        raise BadRequest(description=f'{value_key} header is incorrect')
    try:
        return time_unit_to_sec(value, unit)
    except KeyError:
        raise BadRequest(description=f'{unit_key} header is incorrect') class ContentRange(collections.namedtuple('ContentRange', ('begin', 'end', 'complete'))):
    """
    Work with Content-Range headers.
    """
    __slots__ = ()

    @classmethod
    def parse(cls, content_range):
        """
        Parse Content-Range header.
        Format is "bytes 0-524287/2000000".
        """
        try:
            range_type, range_count = content_range.split(' ', 1)
        except ValueError:
            raise BadRequest(description='Content-Range Header is incorrect')
        # There are no other types then "bytes"
        if range_type != 'bytes':
            raise BadRequest

        try:
            range_count, range_complete = range_count.split('/', 1)
        except ValueError:
            raise BadRequest(description='Content-Range Header is incorrect')

        try:
            # For now, */2000000 format is not supported
            range_begin, range_end = range_count.split('-', 1)

            range_begin = int(range_begin)
            range_end = int(range_end)
        except ValueError:
            raise BadRequest(description='Content-Range Header has invalid range')

        # For now, 0-10/* format is not supported
        try:
            range_complete = int(range_complete)
        except ValueError:
            raise BadRequest(description='Content-Range Header has invalid length')

        if range_begin <= range_end < range_complete:
            return ContentRange(range_begin, range_end, range_complete)

        raise BadRequest

    @classmethod
    def from_request(cls):
        """
        Read Content-Range from request and parse it
        """
        content_range = request.headers.get('Content-Range')
        if content_range is not None:
            return cls.parse(content_range)

    @property
    def is_complete(self):
        return self.complete == self.end + 1

    @property
    def size(self):
        return self.end - self.begin + 1 class DownloadRange(collections.namedtuple('DownloadRange', ('begin', 'end'))):
    """
    Work with Range headers.
    """
    __slots__ = ()

    @classmethod
    def parse(cls, content_range):
        """
        Parse Range header.
        Format is "bytes=0-524287".
        """
        try:
            range_type, range_count = content_range.split('=', 1)
        except ValueError:
            raise BadRequest(description='Range Header is incorrect')
        # There are no other types than "bytes"
        if range_type != 'bytes':
            raise BadRequest(description='Range Header is incorrect')

        try:
            range_begin, range_end = range_count.split('-', 1)
        except ValueError:
            raise BadRequest(description='Range Header is incorrect')

        try:
            range_begin = int(range_begin)
        except ValueError:
            raise BadRequest(description='Range Header has invalid first')

        if range_end:
            # For now, set of ranges (e.g. 0-1,2-10) is not supported
            try:
                range_end = int(range_end)
            except ValueError:
                raise BadRequest(description='Range Header has invalid last')
        else:
            range_end = -1

        if range_begin <= range_end or range_end == -1:
            return DownloadRange(range_begin, range_end)

        raise BadRequest(description='Range Header is incorrect')

    @classmethod
    def from_request(cls):
        """
        Read Content-Range from request and parse it
        """
        download_range = request.headers.get('Range')
        if download_range is not None:
            return cls.parse(download_range)

    @property
    def size(self):
        return self.end - self.begin + 1 class ItemName(str):
    def __new__(cls, uid):
        return str(uid)

    @classmethod
    def create(cls, storage, length=ID_LENGTH, max_length=2 * ID_LENGTH, max_tries=10):
        """
        create a unique item name in storage, wanted name length is <length>.

        we try at most <max_tries> times to find a unique name of a specific length -
        if we do not succeed, we increase name length and try again.
        if we can't find a unique name even for longer lengths up to max_length,
        we'll raise RuntimeError.
        """
        name = None  # avoid false alarm about reference before assignment
        while length <= max_length:
            tries = 0
            while tries < max_tries:
                name = make_id(length)
                if name not in storage:
                    break
                tries += 1
            if tries < max_tries:
                # we found a name, break out of outer while also
                break
            length += 1
        if length > max_length:
            raise RuntimeError("no unique names available")
        return cls(name) CREATE = 'create' LIST = 'list' def may(permission):
    """
    check whether the current user has the permission <permission>
    """
    return permission in flaskg.permissions class Upload:
    _filename_re = re.compile(r'[^a-zA-Z0-9 *+:;.,_-]+')
    _type_re = re.compile(r'[^a-zA-Z0-9/+.-]+')

    @classmethod
    def filter_size(cls, i):
        """
        Filter size.
        Check for advertised size.
        """
        try:
            i = int(i)
        except (ValueError, TypeError):
            raise BadRequest(description='Size is invalid')
        if i > current_app.config['MAX_ALLOWED_FILE_SIZE']:
            raise RequestEntityTooLarge()
        return i

    @classmethod
    def filter_filename(cls, filename, storage_name, content_type, content_type_hint):
        """
        Filter filename.
        Only allow some basic characters and shorten to 50 characters.
        """
        # Make up filename if we don't have one
        if not filename:
            if not content_type:
                content_type = content_type_hint
            # note: stdlib mimetypes.guess_extension is total crap
            if content_type.startswith("text/"):
                ext = ".txt"
            else:
                ext = ".bin"
            filename = storage_name + ext
        return cls._filename_re.sub('', filename)[:MAX_FILENAME_LENGTH]

    @classmethod
    def filter_type(cls, ct, ct_hint, filename=None):
        """
        Filter Content-Type
        Only allow some basic characters and shorten to 50 characters.

        Return value:
        tuple[0] - content-type string
        tuple[1] - whether tuple[0] is hint or not
                   True:  content-type is just a hint
                   False: content-type is not a hint, was specified by user
        """
        if not ct and filename:
            ct, encoding = mimetypes.guess_type(filename)
        if not ct:
            return ct_hint, True
        return cls._type_re.sub('', ct)[:50], False

    @classmethod
    def meta_new(cls, item, input_size, input_filename, input_type,
                 input_type_hint, storage_name, maxlife_stamp=FOREVER):
        item.meta[FILENAME] = cls.filter_filename(
            input_filename, storage_name, input_type, input_type_hint
        )
        item.meta[SIZE] = cls.filter_size(input_size)
        ct, hint = cls.filter_type(input_type, input_type_hint, input_filename)
        item.meta[TYPE] = ct
        item.meta[TYPE_HINT] = hint
        item.meta[TIMESTAMP_UPLOAD] = int(time.time())
        item.meta[TIMESTAMP_DOWNLOAD] = 0
        item.meta[LOCKED] = current_app.config['UPLOAD_LOCKED']
        item.meta[COMPLETE] = False
        item.meta[HASH] = ''
        item.meta[TIMESTAMP_MAX_LIFE] = maxlife_stamp

    @classmethod
    def meta_complete(cls, item, file_hash):
        # update TYPE by python-magic if not decided yet
        if item.meta.pop(TYPE_HINT, False):
            if magic and current_app.config.get('USE_PYTHON_MAGIC', False):
                if item.meta[TYPE] == 'application/octet-stream':
                    item.meta[TYPE] = magic.from_buffer(item.data.read(magic_bufsz, 0))
        item.meta[COMPLETE] = True
        item.meta[HASH] = file_hash

    @staticmethod
    def data(item, f, size_input, offset=0):
        """
        Copy data from temp file into storage.
        """
        read_length = 16 * 1024
        size_written = 0
        hasher = hash_new()

        while True:
            read_length = min(read_length, size_input)
            if size_input == 0:
                break

            buf = f.read(read_length)
            if not buf:
                # Should not happen, we already checked the size
                raise RuntimeError

            item.data.write(buf, offset + size_written)
            hasher.update(buf)

            len_buf = len(buf)
            size_written += len_buf
            size_input -= len_buf

        return size_written, hasher.hexdigest() def filter_internal(meta):
    """
    filter internal meta data out.
    """
    return {k: v for k, v in meta.items() if k not in internal_meta} @threaded
def background_compute_hash(storage, name):
    with storage.openwrite(name) as item:
        size = item.meta[SIZE]
        file_hash = compute_hash(item.data, size)
        item.meta[HASH] = file_hash def file_infos(names=None):
    """
    iterates over storage files metadata.
    note: we put the storage name into the metadata as ID

    :param names: None means "all items"
                  otherwise give a list of storage item names
    """
    storage = current_app.storage
    if names is None:
        names = list(storage)
    for name in names:
        try:
            with storage.open(name) as item:
                meta = dict(item.meta)
                if not meta:
                    # we got empty metadata, this happens for 0-byte .meta files.
                    # ignore it for now.
                    continue
                if delete_if_lifetime_over(item, name):
                    continue
                meta[ID] = name
                yield meta
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise
        except pickle.UnpicklingError:
            # corrupted meta file, just ignore it for now
            pass class DeleteView(MethodView):
    def error(self, item, error):
        return render_template('error.html', heading=item.meta[FILENAME], body=error), 409

    def response(self, name):
        return redirect_next_referrer('bepasty.index')

    def post(self, name):
        if not may(DELETE):
            raise Forbidden()
        try:
            with current_app.storage.open(name) as item:
                if not item.meta[COMPLETE] and not may(ADMIN):
                    error = 'Upload incomplete. Try again later.'
                    self.error(item, error)

                if item.meta[LOCKED] and not may(ADMIN):
                    raise Forbidden()

            current_app.storage.remove(name)

        except OSError as e:
            if e.errno == errno.ENOENT:
                raise NotFound()
            raise

        return self.response(name) class DownloadView(MethodView):
    content_disposition = 'attachment'  # to trigger download

    def err_incomplete(self, item, error):
        return render_template('error.html', heading=item.meta[FILENAME], body=error), 409

    def stream(self, item, start, limit):
        with item as _item:
            # Stream content from storage
            offset = max(0, start)
            while offset < limit:
                buf = _item.data.read(min(limit - offset, 16 * 1024), offset)
                offset += len(buf)
                yield buf
            item.meta[TIMESTAMP_DOWNLOAD] = int(time.time())

    def response(self, item, name):
        ct = item.meta[TYPE]
        dispo = self.content_disposition
        if dispo != 'attachment':
            # no simple download, so we must be careful about XSS
            if ct.startswith("text/"):
                ct = 'text/plain'  # only send simple plain text

        ret = Response(stream_with_context(self.stream(item, 0, item.data.size)))
        ret.headers['Content-Disposition'] = '{}; filename="{}"'.format(
            dispo, item.meta[FILENAME])
        ret.headers['Content-Length'] = item.meta[SIZE]
        ret.headers['Content-Type'] = ct
        ret.headers['X-Content-Type-Options'] = 'nosniff'  # yes, we really mean it
        return ret

    def get(self, name):
        if not may(READ):
            raise Forbidden()
        try:
            item = current_app.storage.openwrite(name)
        except OSError as e:
            if e.errno == errno.ENOENT:
                raise NotFound()
            raise

        try:
            need_close = True
            if not item.meta[COMPLETE]:
                return self.err_incomplete(item, 'Upload incomplete. Try again later.')

            if item.meta[LOCKED] and not may(ADMIN):
                raise Forbidden()

            if delete_if_lifetime_over(item, name):
                raise NotFound()
            need_close = False
        finally:
            if need_close:
                item.close()

        return self.response(item, name) class ModifyView(MethodView):
    def error(self, item, error):
        return render_template('error.html', heading=item.meta[FILENAME], body=error), 409

    def response(self, name):
        return redirect_next_referrer('bepasty.display', name=name)

    def get_params(self):
        return {
            FILENAME: request.form.get('filename'),
            TYPE: request.form.get('contenttype'),
        }

    def post(self, name):
        if not may(CREATE):
            raise Forbidden()

        try:
            with current_app.storage.openwrite(name) as item:
                if not item.meta[COMPLETE] and not may(ADMIN):
                    error = 'Upload incomplete. Try again later.'
                    return self.error(item, error)

                if item.meta[LOCKED] and not may(ADMIN):
                    raise Forbidden()

                if delete_if_lifetime_over(item, name):
                    raise NotFound()

                params = self.get_params()
                if params[FILENAME]:
                    item.meta[FILENAME] = Upload.filter_filename(
                        params[FILENAME], name, params[TYPE], item.meta[TYPE]
                    )
                if params[TYPE]:
                    item.meta[TYPE], _ = Upload.filter_type(
                        params[TYPE], item.meta[TYPE]
                    )

                return self.response(name)

        except OSError as e:
            if e.errno == errno.ENOENT:
                raise NotFound()
            raise class LockView(SetKeyValueView):
    REQUIRED_PERMISSION = ADMIN
    KEY = LOCKED
    NEXT_VALUE = True class UnlockView(SetKeyValueView):
    REQUIRED_PERMISSION = ADMIN
    KEY = LOCKED
    NEXT_VALUE = False
import errno
import base64
import time
from io import BytesIO
from flask import Response, make_response, url_for, jsonify, stream_with_context, request, current_app
from flask.views import MethodView
from werkzeug.exceptions import HTTPException, BadRequest, Conflict, Forbidden, InternalServerError, MethodNotAllowed
from ..constants import FILENAME, ID, SIZE, TYPE, TRANSACTION_ID
from ..utils.date_funcs import get_maxlife
from ..utils.http import ContentRange, DownloadRange
from ..utils.name import ItemName
from ..utils.permissions import CREATE, LIST, may
from ..utils.upload import Upload, filter_internal, background_compute_hash
from ..views.filelist import file_infos
from ..views.delete import DeleteView
from ..views.download import DownloadView
from ..views.modify import ModifyView
from ..views.setkv import LockView, UnlockView
        response.headers['Content-Type'] = 'application/json'
        response.data = '{}'
        name_b = name if isinstance(name, bytes) else name.encode()
        trans_id_b = base64.b64encode(name_b)
        trans_id_s = trans_id_b if isinstance(trans_id_b, str) else trans_id_b.decode()
        response.headers[TRANSACTION_ID] = trans_id_s

        # Check if file is completely uploaded and set meta
        if file_range.is_complete:
            Upload.meta_complete(item, '')
            item.meta[SIZE] = item.data.size
            item.close()
            background_compute_hash(current_app.storage, name)
            # Set status 'successful' and return the new URL for the uploaded file
            response.status = '201'
            response.headers["Content-Location"] = url_for('bepasty_apis.items_detail', name=name)
        else:
            item.close()
            response.status = '200'

        return response

    @rest_errorhandler
    def post(self):
        """
        Upload file via REST-API. Chunked Upload is supported.

        HTTP Headers that need to be given:
        * Content-Type: The type of the file that is being uploaded.
            If this is not given filetype will be 'application/octet-stream'
        * Content-Length: The total size of the file to be uploaded.
        * Content-Filename: The filename of the file. This will be used when downloading.
        * Content-Range: The Content-Range of the Chunk that is currently being uploaded.
            Follows the HTTP-Header Specifications.
        * Transaction-ID: The Transaction-ID for Chunked Uploads.
            Needs to be delivered when uploading in chunks (after the first chunk).

        To start an upload, the HTTP Headers need to be delivered.
        The body of the request needs to be the base64 encoded file contents.
        Content-Length is the original file size before base64 encoding.
        Content-Range follows the same logic.
        After the first chunk is uploaded, bepasty will return the Transaction-ID to continue the upload.
        Deliver the Transaction-ID and the correct Content-Range to continue upload.
        After the file is completely uploaded, the file will be marked as complete and
        a 201 HTTP Status will be returned.
        The Content-Location Header will contain the api url to the uploaded Item.

        If the file size exceeds the permitted size, the upload will be aborted. This will be checked twice.
        The first check is the provided Content-Length. The second is the actual file size on the server.
        """
        if not may(CREATE):
            raise Forbidden()

        # Collect all expected data from the Request
        file_type = request.headers.get("Content-Type")
        file_size = request.headers.get("Content-Length")
        file_name = request.headers.get("Content-Filename")

        # Check the file size from Request
        Upload.filter_size(file_size)

        # Check if Transaction-ID is available for continued upload
        if not request.headers.get(TRANSACTION_ID):
            # Create ItemName and empty file in Storage
            name = ItemName.create(current_app.storage)
            item = current_app.storage.create(name, 0)

            # set max lifetime
            maxtime = get_maxlife(request.headers, underscore=False)
            maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime
            # Fill meta with data from Request
            Upload.meta_new(item, 0, file_name, file_type,
                            'application/octet-stream',
                            name, maxlife_stamp=maxlife_timestamp)
            new_item = True
        else:
            # Get file name from Transaction-ID and open from Storage
            trans_id_s = request.headers.get(TRANSACTION_ID)
            trans_id_b = trans_id_s if isinstance(trans_id_s, bytes) else trans_id_s.encode()
            try:
                name_b = base64.b64decode(trans_id_b)
            except (base64.binascii.Error, TypeError):
                raise BadRequest(description=f'Could not decode {TRANSACTION_ID}')
            name = name_b if isinstance(name_b, str) else name_b.decode()
            try:
                item = current_app.storage.openwrite(name)
            except OSError as e:
                if e.errno == errno.ENOENT:
                    raise BadRequest(description='Could not find storage item for transaction id')
                raise
            new_item = False

        response = None
        try:
            response = self.update_item(item, name)
            return response
        finally:
            # If error response or exception on a new item path, remove item
            if new_item and not isinstance(response, Response):
                current_app.storage.remove(name)

    @rest_errorhandler
    def get(self):
        """
        Return the list of all files in bepasty, including metadata in the form:

            {
                "<name>": {
                    "file-meta": { <name.metadata> },
                    "uri" : "/apis/rest/items/<name>"
                },
                ...
            }
        """
        if not may(LIST):
            raise Forbidden()
        ret = {}
        for meta in file_infos():
            name = meta.pop(ID)
            ret[name] = {'uri': url_for('bepasty_apis.items_detail', name=name),

                         'file-meta': filter_internal(meta)}