diff options
Diffstat (limited to 'onionshare/web/send_base_mode.py')
-rw-r--r-- | onionshare/web/send_base_mode.py | 305 |
1 files changed, 305 insertions, 0 deletions
diff --git a/onionshare/web/send_base_mode.py b/onionshare/web/send_base_mode.py new file mode 100644 index 00000000..86d34016 --- /dev/null +++ b/onionshare/web/send_base_mode.py @@ -0,0 +1,305 @@ +import os +import sys +import tempfile +import mimetypes +import gzip +from flask import Response, request, render_template, make_response + +from .. import strings + + +class SendBaseModeWeb: + """ + All of the web logic shared between share and website mode (modes where the user sends files) + """ + + def __init__(self, common, web): + super(SendBaseModeWeb, self).__init__() + self.common = common + self.web = web + + # Information about the file to be shared + self.is_zipped = False + self.download_filename = None + self.download_filesize = None + self.gzip_filename = None + self.gzip_filesize = None + self.zip_writer = None + + # If "Stop After First Download" is checked (stay_open == False), only allow + # one download at a time. + self.download_in_progress = False + + # This tracks the history id + self.cur_history_id = 0 + + self.define_routes() + self.init() + + def set_file_info(self, filenames, processed_size_callback=None): + """ + Build a data structure that describes the list of files + """ + # If there's just one folder, replace filenames with a list of files inside that folder + if len(filenames) == 1 and os.path.isdir(filenames[0]): + filenames = [ + os.path.join(filenames[0], x) for x in os.listdir(filenames[0]) + ] + + # Re-initialize + self.files = {} # Dictionary mapping file paths to filenames on disk + self.root_files = ( + {} + ) # This is only the root files and dirs, as opposed to all of them + self.cleanup_filenames = [] + self.cur_history_id = 0 + self.file_info = {"files": [], "dirs": []} + self.gzip_individual_files = {} + self.init() + + # Build the file list + for filename in filenames: + basename = os.path.basename(filename.rstrip("/")) + + # If it's a filename, add it + if os.path.isfile(filename): + self.files[basename] = filename + self.root_files[basename] = filename + + # If it's a directory, add it recursively + elif os.path.isdir(filename): + self.root_files[basename + "/"] = filename + + for root, _, nested_filenames in os.walk(filename): + # Normalize the root path. So if the directory name is "/home/user/Documents/some_folder", + # and it has a nested folder foobar, the root is "/home/user/Documents/some_folder/foobar". + # The normalized_root should be "some_folder/foobar" + normalized_root = os.path.join( + basename, root[len(filename) :].lstrip("/") + ).rstrip("/") + + # Add the dir itself + self.files[normalized_root + "/"] = root + + # Add the files in this dir + for nested_filename in nested_filenames: + self.files[ + os.path.join(normalized_root, nested_filename) + ] = os.path.join(root, nested_filename) + + self.set_file_info_custom(filenames, processed_size_callback) + + def directory_listing(self, filenames, path="", filesystem_path=None): + # Tell the GUI about the directory listing + history_id = self.cur_history_id + self.cur_history_id += 1 + self.web.add_request( + self.web.REQUEST_INDIVIDUAL_FILE_STARTED, + "/{}".format(path), + {"id": history_id, "method": request.method, "status_code": 200}, + ) + + breadcrumbs = [("☗", "/")] + parts = path.split("/")[:-1] + for i in range(len(parts)): + breadcrumbs.append( + ("{}".format(parts[i]), "/{}/".format("/".join(parts[0 : i + 1]))) + ) + breadcrumbs_leaf = breadcrumbs.pop()[0] + + # If filesystem_path is None, this is the root directory listing + files, dirs = self.build_directory_listing(filenames, filesystem_path) + r = self.directory_listing_template( + path, files, dirs, breadcrumbs, breadcrumbs_leaf + ) + return self.web.add_security_headers(r) + + def build_directory_listing(self, filenames, filesystem_path): + files = [] + dirs = [] + + for filename in filenames: + if filesystem_path: + this_filesystem_path = os.path.join(filesystem_path, filename) + else: + this_filesystem_path = self.files[filename] + + is_dir = os.path.isdir(this_filesystem_path) + + if is_dir: + dirs.append({"basename": filename}) + else: + size = os.path.getsize(this_filesystem_path) + size_human = self.common.human_readable_filesize(size) + files.append({"basename": filename, "size_human": size_human}) + return files, dirs + + def stream_individual_file(self, filesystem_path): + """ + Return a flask response that's streaming the download of an individual file, and gzip + compressing it if the browser supports it. + """ + use_gzip = self.should_use_gzip() + + # gzip compress the individual file, if it hasn't already been compressed + if use_gzip: + if filesystem_path not in self.gzip_individual_files: + gzip_filename = tempfile.mkstemp("wb+")[1] + self._gzip_compress(filesystem_path, gzip_filename, 6, None) + self.gzip_individual_files[filesystem_path] = gzip_filename + + # Make sure the gzip file gets cleaned up when onionshare stops + self.cleanup_filenames.append(gzip_filename) + + file_to_download = self.gzip_individual_files[filesystem_path] + filesize = os.path.getsize(self.gzip_individual_files[filesystem_path]) + else: + file_to_download = filesystem_path + filesize = os.path.getsize(filesystem_path) + + path = request.path + + # Tell GUI the individual file started + history_id = self.cur_history_id + self.cur_history_id += 1 + self.web.add_request( + self.web.REQUEST_INDIVIDUAL_FILE_STARTED, + path, + {"id": history_id, "filesize": filesize}, + ) + + # Only GET requests are allowed, any other method should fail + if request.method != "GET": + return self.web.error405() + + def generate(): + chunk_size = 102400 # 100kb + + fp = open(file_to_download, "rb") + done = False + while not done: + chunk = fp.read(chunk_size) + if chunk == b"": + done = True + else: + try: + yield chunk + + # Tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / filesize) * 100 + if ( + not self.web.is_gui + or self.common.platform == "Linux" + or self.common.platform == "BSD" + ): + sys.stdout.write( + "\r{0:s}, {1:.2f}% ".format( + self.common.human_readable_filesize( + downloaded_bytes + ), + percent, + ) + ) + sys.stdout.flush() + + self.web.add_request( + self.web.REQUEST_INDIVIDUAL_FILE_PROGRESS, + path, + { + "id": history_id, + "bytes": downloaded_bytes, + "filesize": filesize, + }, + ) + done = False + except: + # Looks like the download was canceled + done = True + + # Tell the GUI the individual file was canceled + self.web.add_request( + self.web.REQUEST_INDIVIDUAL_FILE_CANCELED, + path, + {"id": history_id}, + ) + + fp.close() + + if self.common.platform != "Darwin": + sys.stdout.write("\n") + + basename = os.path.basename(filesystem_path) + + r = Response(generate()) + if use_gzip: + r.headers.set("Content-Encoding", "gzip") + r.headers.set("Content-Length", filesize) + r.headers.set("Content-Disposition", "inline", filename=basename) + r = self.web.add_security_headers(r) + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set("Content-Type", content_type) + return r + + def should_use_gzip(self): + """ + Should we use gzip for this browser? + """ + return (not self.is_zipped) and ( + "gzip" in request.headers.get("Accept-Encoding", "").lower() + ) + + def _gzip_compress( + self, input_filename, output_filename, level, processed_size_callback=None + ): + """ + Compress a file with gzip, without loading the whole thing into memory + Thanks: https://stackoverflow.com/questions/27035296/python-how-to-gzip-a-large-text-file-without-memoryerror + """ + bytes_processed = 0 + blocksize = 1 << 16 # 64kB + with open(input_filename, "rb") as input_file: + output_file = gzip.open(output_filename, "wb", level) + while True: + if processed_size_callback is not None: + processed_size_callback(bytes_processed) + + block = input_file.read(blocksize) + if len(block) == 0: + break + output_file.write(block) + bytes_processed += blocksize + + output_file.close() + + def init(self): + """ + Inherited class will implement this + """ + pass + + def define_routes(self): + """ + Inherited class will implement this + """ + pass + + def directory_listing_template(self): + """ + Inherited class will implement this. It should call render_template and return + the response. + """ + pass + + def set_file_info_custom(self, filenames, processed_size_callback): + """ + Inherited class will implement this. + """ + pass + + def render_logic(self, path=""): + """ + Inherited class will implement this. + """ + pass |