From 017ee5250d68d2b8f4f9b50f2ccc58d15016b69f Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 09:14:56 -0700 Subject: [PATCH 01/15] When there is only 1 file being shared, don't zip it --- onionshare/__init__.py | 4 +- onionshare/web.py | 68 ++++++++++++++++----------- onionshare_gui/share_mode/threads.py | 2 +- screenshots/server.png | Bin 45923 -> 0 bytes share/templates/send.html | 24 +++++----- 5 files changed, 55 insertions(+), 43 deletions(-) delete mode 100644 screenshots/server.png diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 51210b6b..e04836b7 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -120,13 +120,13 @@ def main(cwd=None): print(strings._("preparing_files")) try: web.set_file_info(filenames) - app.cleanup_filenames.append(web.zip_filename) + app.cleanup_filenames.append(web.download_filename) except OSError as e: print(e.strerror) sys.exit(1) # Warn about sending large files over Tor - if web.zip_filesize >= 157286400: # 150mb + if web.download_filesize >= 157286400: # 150mb print('') print(strings._("large_filesize")) print('') diff --git a/onionshare/web.py b/onionshare/web.py index 067c5e07..2575230f 100644 --- a/onionshare/web.py +++ b/onionshare/web.py @@ -102,8 +102,9 @@ class Web(object): # Information about the file self.file_info = [] - self.zip_filename = None - self.zip_filesize = None + self.is_zipped = False + self.download_filename = None + self.download_filesize = None self.zip_writer = None self.security_headers = [ @@ -182,17 +183,19 @@ class Web(object): 'send.html', slug=self.slug, file_info=self.file_info, - filename=os.path.basename(self.zip_filename), - filesize=self.zip_filesize, - filesize_human=self.common.human_readable_filesize(self.zip_filesize))) + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) else: # If download is allowed to continue, serve download page r = make_response(render_template( 'send.html', file_info=self.file_info, - filename=os.path.basename(self.zip_filename), - filesize=self.zip_filesize, - filesize_human=self.common.human_readable_filesize(self.zip_filesize))) + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) return self.add_security_headers(r) @self.app.route("//download") @@ -231,8 +234,8 @@ class Web(object): 'id': download_id} ) - dirname = os.path.dirname(self.zip_filename) - basename = os.path.basename(self.zip_filename) + dirname = os.path.dirname(self.download_filename) + basename = os.path.basename(self.download_filename) def generate(): # The user hasn't canceled the download @@ -244,7 +247,7 @@ class Web(object): chunk_size = 102400 # 100kb - fp = open(self.zip_filename, 'rb') + fp = open(self.download_filename, 'rb') self.done = False canceled = False while not self.done: @@ -264,7 +267,7 @@ class Web(object): # tell GUI the progress downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / self.zip_filesize) * 100 + percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) if not self.gui_mode or self.common.platform == 'Linux' or self.common.platform == 'BSD': @@ -308,7 +311,7 @@ class Web(object): pass r = Response(generate()) - r.headers.set('Content-Length', self.zip_filesize) + r.headers.set('Content-Length', self.download_filesize) r.headers.set('Content-Disposition', 'attachment', filename=basename) r = self.add_security_headers(r) # guess content type @@ -517,8 +520,9 @@ class Web(object): page will need to display. This includes zipping up the file in order to get the zip file's name and size. """ + self.common.log("Web", "set_file_info") self.cancel_compression = False - + # build file info list self.file_info = {'files': [], 'dirs': []} for filename in filenames: @@ -537,22 +541,30 @@ class Web(object): self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) - # Zip up the files and folders - self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) - self.zip_filename = self.zip_writer.zip_filename - for info in self.file_info['files']: - self.zip_writer.add_file(info['filename']) - # Canceling early? - if self.cancel_compression: - self.zip_writer.close() - return False + # Check if there's only 1 file and no folders + if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: + self.is_zipped = False + self.download_filename = self.file_info['files'][0]['filename'] + self.download_filesize = self.file_info['files'][0]['size'] + else: + # Zip up the files and folders + self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) + self.download_filename = self.zip_writer.zip_filename + for info in self.file_info['files']: + self.zip_writer.add_file(info['filename']) + # Canceling early? + if self.cancel_compression: + self.zip_writer.close() + return False - for info in self.file_info['dirs']: - if not self.zip_writer.add_dir(info['filename']): - return False + for info in self.file_info['dirs']: + if not self.zip_writer.add_dir(info['filename']): + return False + + self.zip_writer.close() + self.download_filesize = os.path.getsize(self.download_filename) + self.is_zipped = True - self.zip_writer.close() - self.zip_filesize = os.path.getsize(self.zip_filename) return True def _safe_select_jinja_autoescape(self, filename): diff --git a/onionshare_gui/share_mode/threads.py b/onionshare_gui/share_mode/threads.py index 50789049..9cda76b1 100644 --- a/onionshare_gui/share_mode/threads.py +++ b/onionshare_gui/share_mode/threads.py @@ -47,7 +47,7 @@ class CompressThread(QtCore.QThread): # Cancelled pass - self.mode.app.cleanup_filenames.append(self.mode.web.zip_filename) + self.mode.app.cleanup_filenames.append(self.mode.web.download_filename) except OSError as e: self.error.emit(e.strerror) diff --git a/screenshots/server.png b/screenshots/server.png deleted file mode 100644 index 8bdf29719f9e03b139b323383fdb649eada21e94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45923 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 Bxw8NO diff --git a/share/templates/send.html b/share/templates/send.html index df1d3563..e7e1fde0 100644 --- a/share/templates/send.html +++ b/share/templates/send.html @@ -10,18 +10,18 @@

- - -

OnionShare

+
+
    +
  • Total size: {{ filesize_human }} {% if is_zipped %} (compressed){% endif %}
  • + {% if slug %} +
  • Download Files
  • + {% else %} +
  • Download Files
  • + {% endif %} +
+
+ +

OnionShare

From 574ef19515f64eefbc2877ba48247606f19b08a7 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 12:25:07 -0700 Subject: [PATCH 02/15] Change more references to web.zip_filesize to be refer to web.download_filesize --- onionshare_gui/share_mode/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/onionshare_gui/share_mode/__init__.py b/onionshare_gui/share_mode/__init__.py index a9c6e8d7..aec32305 100644 --- a/onionshare_gui/share_mode/__init__.py +++ b/onionshare_gui/share_mode/__init__.py @@ -177,7 +177,7 @@ class ShareMode(Mode): self._zip_progress_bar = None # Warn about sending large files over Tor - if self.web.zip_filesize >= 157286400: # 150mb + if self.web.download_filesize >= 157286400: # 150mb self.filesize_warning.setText(strings._("large_filesize", True)) self.filesize_warning.show() @@ -229,7 +229,7 @@ class ShareMode(Mode): """ Handle REQUEST_STARTED event. """ - self.downloads.add(event["data"]["id"], self.web.zip_filesize) + self.downloads.add(event["data"]["id"], self.web.download_filesize) self.downloads_in_progress += 1 self.update_downloads_in_progress() @@ -242,7 +242,7 @@ class ShareMode(Mode): self.downloads.update(event["data"]["id"], event["data"]["bytes"]) # Is the download complete? - if event["data"]["bytes"] == self.web.zip_filesize: + if event["data"]["bytes"] == self.web.download_filesize: self.system_tray.showMessage(strings._('systray_download_completed_title', True), strings._('systray_download_completed_message', True)) # Update the total 'completed downloads' info From 0ea31d39f778d22ab5fe8a4f600c6c8bcfbc8586 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 23:18:17 -0700 Subject: [PATCH 03/15] Only add the download_filename to cleanup_filenames (which get deleted) if the file is zipped up. Otherwise, OnionShare deletes the original file --- onionshare/__init__.py | 3 ++- onionshare_gui/share_mode/threads.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index e04836b7..2f57ccf2 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -120,7 +120,8 @@ def main(cwd=None): print(strings._("preparing_files")) try: web.set_file_info(filenames) - app.cleanup_filenames.append(web.download_filename) + if web.is_zipped: + app.cleanup_filenames.append(web.download_filename) except OSError as e: print(e.strerror) sys.exit(1) diff --git a/onionshare_gui/share_mode/threads.py b/onionshare_gui/share_mode/threads.py index 9cda76b1..dc43bf0a 100644 --- a/onionshare_gui/share_mode/threads.py +++ b/onionshare_gui/share_mode/threads.py @@ -47,7 +47,8 @@ class CompressThread(QtCore.QThread): # Cancelled pass - self.mode.app.cleanup_filenames.append(self.mode.web.download_filename) + if self.mode.web.is_zipped: + self.mode.app.cleanup_filenames.append(self.mode.web.download_filename) except OSError as e: self.error.emit(e.strerror) From fe0657128bf96a7d28093280f8e65159fcd0d8aa Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 23:31:52 -0700 Subject: [PATCH 04/15] Make web a module, so I can split it into multiple files --- onionshare/{web.py => web/__init__.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename onionshare/{web.py => web/__init__.py} (99%) diff --git a/onionshare/web.py b/onionshare/web/__init__.py similarity index 99% rename from onionshare/web.py rename to onionshare/web/__init__.py index 2575230f..5a7b297f 100644 --- a/onionshare/web.py +++ b/onionshare/web/__init__.py @@ -40,8 +40,8 @@ from flask import ( ) from werkzeug.utils import secure_filename -from . import strings -from .common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable +from .. import strings +from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable # Stub out flask's show_server_banner function, to avoiding showing warnings that From 48ec4ad583f69034c824f7abc0bc7cf1f4d9ffbe Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 23:43:04 -0700 Subject: [PATCH 05/15] Split the web module into different files for receive mode and share mode logic --- onionshare/web/__init__.py | 857 +-------------------------------- onionshare/web/receive_mode.py | 156 ++++++ onionshare/web/share_mode.py | 60 +++ onionshare/web/web.py | 647 +++++++++++++++++++++++++ 4 files changed, 864 insertions(+), 856 deletions(-) create mode 100644 onionshare/web/receive_mode.py create mode 100644 onionshare/web/share_mode.py create mode 100644 onionshare/web/web.py diff --git a/onionshare/web/__init__.py b/onionshare/web/__init__.py index 5a7b297f..d45b4983 100644 --- a/onionshare/web/__init__.py +++ b/onionshare/web/__init__.py @@ -18,859 +18,4 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import hmac -import logging -import mimetypes -import os -import queue -import socket -import sys -import tempfile -import zipfile -import re -import io -from distutils.version import LooseVersion as Version -from urllib.request import urlopen -from datetime import datetime - -import flask -from flask import ( - Flask, Response, Request, request, render_template, abort, make_response, - flash, redirect, __version__ as flask_version -) -from werkzeug.utils import secure_filename - -from .. import strings -from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable - - -# Stub out flask's show_server_banner function, to avoiding showing warnings that -# are not applicable to OnionShare -def stubbed_show_server_banner(env, debug, app_import_path, eager_loading): - pass - -flask.cli.show_server_banner = stubbed_show_server_banner - - -class Web(object): - """ - The Web object is the OnionShare web server, powered by flask - """ - REQUEST_LOAD = 0 - REQUEST_STARTED = 1 - REQUEST_PROGRESS = 2 - REQUEST_OTHER = 3 - REQUEST_CANCELED = 4 - REQUEST_RATE_LIMIT = 5 - REQUEST_CLOSE_SERVER = 6 - REQUEST_UPLOAD_FILE_RENAMED = 7 - REQUEST_UPLOAD_FINISHED = 8 - REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE = 9 - REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE = 10 - - def __init__(self, common, gui_mode, receive_mode=False): - self.common = common - - # The flask app - self.app = Flask(__name__, - static_folder=self.common.get_resource_path('static'), - template_folder=self.common.get_resource_path('templates')) - self.app.secret_key = self.common.random_string(8) - - # Debug mode? - if self.common.debug: - self.debug_mode() - - # Are we running in GUI mode? - self.gui_mode = gui_mode - - # Are we using receive mode? - self.receive_mode = receive_mode - if self.receive_mode: - # Use custom WSGI middleware, to modify environ - self.app.wsgi_app = ReceiveModeWSGIMiddleware(self.app.wsgi_app, self) - # Use a custom Request class to track upload progess - self.app.request_class = ReceiveModeRequest - - # Starting in Flask 0.11, render_template_string autoescapes template variables - # by default. To prevent content injection through template variables in - # earlier versions of Flask, we force autoescaping in the Jinja2 template - # engine if we detect a Flask version with insecure default behavior. - if Version(flask_version) < Version('0.11'): - # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc - Flask.select_jinja_autoescape = self._safe_select_jinja_autoescape - - # Information about the file - self.file_info = [] - self.is_zipped = False - self.download_filename = None - self.download_filesize = None - self.zip_writer = None - - self.security_headers = [ - ('Content-Security-Policy', 'default-src \'self\'; style-src \'self\'; script-src \'self\'; img-src \'self\' data:;'), - ('X-Frame-Options', 'DENY'), - ('X-Xss-Protection', '1; mode=block'), - ('X-Content-Type-Options', 'nosniff'), - ('Referrer-Policy', 'no-referrer'), - ('Server', 'OnionShare') - ] - - self.q = queue.Queue() - - self.slug = None - - self.download_count = 0 - self.upload_count = 0 - - self.error404_count = 0 - - # If "Stop After First Download" is checked (stay_open == False), only allow - # one download at a time. - self.download_in_progress = False - - self.done = False - - # If the client closes the OnionShare window while a download is in progress, - # it should immediately stop serving the file. The client_cancel global is - # used to tell the download function that the client is canceling the download. - self.client_cancel = False - - # shutting down the server only works within the context of flask, so the easiest way to do it is over http - self.shutdown_slug = self.common.random_string(16) - - # Keep track if the server is running - self.running = False - - # Define the ewb app routes - self.common_routes() - if self.receive_mode: - self.receive_routes() - else: - self.send_routes() - - def send_routes(self): - """ - The web app routes for sharing files - """ - @self.app.route("/") - def index(slug_candidate): - self.check_slug_candidate(slug_candidate) - return index_logic() - - @self.app.route("/") - def index_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return index_logic() - - def index_logic(slug_candidate=''): - """ - Render the template for the onionshare landing page. - """ - self.add_request(Web.REQUEST_LOAD, request.path) - - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not self.stay_open and self.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return self.add_security_headers(r) - - # If download is allowed to continue, serve download page - if self.slug: - r = make_response(render_template( - 'send.html', - slug=self.slug, - file_info=self.file_info, - filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, - filesize_human=self.common.human_readable_filesize(self.download_filesize), - is_zipped=self.is_zipped)) - else: - # If download is allowed to continue, serve download page - r = make_response(render_template( - 'send.html', - file_info=self.file_info, - filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, - filesize_human=self.common.human_readable_filesize(self.download_filesize), - is_zipped=self.is_zipped)) - return self.add_security_headers(r) - - @self.app.route("//download") - def download(slug_candidate): - self.check_slug_candidate(slug_candidate) - return download_logic() - - @self.app.route("/download") - def download_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return download_logic() - - def download_logic(slug_candidate=''): - """ - Download the zip file. - """ - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not self.stay_open and self.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return self.add_security_headers(r) - - # Each download has a unique id - download_id = self.download_count - self.download_count += 1 - - # Prepare some variables to use inside generate() function below - # which is outside of the request context - shutdown_func = request.environ.get('werkzeug.server.shutdown') - path = request.path - - # Tell GUI the download started - self.add_request(Web.REQUEST_STARTED, path, { - 'id': download_id} - ) - - dirname = os.path.dirname(self.download_filename) - basename = os.path.basename(self.download_filename) - - def generate(): - # The user hasn't canceled the download - self.client_cancel = False - - # Starting a new download - if not self.stay_open: - self.download_in_progress = True - - chunk_size = 102400 # 100kb - - fp = open(self.download_filename, 'rb') - self.done = False - canceled = False - while not self.done: - # The user has canceled the download, so stop serving the file - if self.client_cancel: - self.add_request(Web.REQUEST_CANCELED, path, { - 'id': download_id - }) - break - - chunk = fp.read(chunk_size) - if chunk == b'': - self.done = True - else: - try: - yield chunk - - # tell GUI the progress - downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 - - # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - if not self.gui_mode 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.add_request(Web.REQUEST_PROGRESS, path, { - 'id': download_id, - 'bytes': downloaded_bytes - }) - self.done = False - except: - # looks like the download was canceled - self.done = True - canceled = True - - # tell the GUI the download has canceled - self.add_request(Web.REQUEST_CANCELED, path, { - 'id': download_id - }) - - fp.close() - - if self.common.platform != 'Darwin': - sys.stdout.write("\n") - - # Download is finished - if not self.stay_open: - self.download_in_progress = False - - # Close the server, if necessary - if not self.stay_open and not canceled: - print(strings._("closing_automatically")) - self.running = False - try: - if shutdown_func is None: - raise RuntimeError('Not running with the Werkzeug Server') - shutdown_func() - except: - pass - - r = Response(generate()) - r.headers.set('Content-Length', self.download_filesize) - r.headers.set('Content-Disposition', 'attachment', filename=basename) - r = self.add_security_headers(r) - # guess content type - (content_type, _) = mimetypes.guess_type(basename, strict=False) - if content_type is not None: - r.headers.set('Content-Type', content_type) - return r - - def receive_routes(self): - """ - The web app routes for receiving files - """ - def index_logic(): - self.add_request(Web.REQUEST_LOAD, request.path) - - if self.common.settings.get('public_mode'): - upload_action = '/upload' - close_action = '/close' - else: - upload_action = '/{}/upload'.format(self.slug) - close_action = '/{}/close'.format(self.slug) - - r = make_response(render_template( - 'receive.html', - upload_action=upload_action, - close_action=close_action, - receive_allow_receiver_shutdown=self.common.settings.get('receive_allow_receiver_shutdown'))) - return self.add_security_headers(r) - - @self.app.route("/") - def index(slug_candidate): - self.check_slug_candidate(slug_candidate) - return index_logic() - - @self.app.route("/") - def index_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return index_logic() - - - def upload_logic(slug_candidate=''): - """ - Upload files. - """ - # Make sure downloads_dir exists - valid = True - try: - self.common.validate_downloads_dir() - except DownloadsDirErrorCannotCreate: - self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) - print(strings._('error_cannot_create_downloads_dir').format(self.common.settings.get('downloads_dir'))) - valid = False - except DownloadsDirErrorNotWritable: - self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) - print(strings._('error_downloads_dir_not_writable').format(self.common.settings.get('downloads_dir'))) - valid = False - if not valid: - flash('Error uploading, please inform the OnionShare user', 'error') - if self.common.settings.get('public_mode'): - return redirect('/') - else: - return redirect('/{}'.format(slug_candidate)) - - files = request.files.getlist('file[]') - filenames = [] - print('') - for f in files: - if f.filename != '': - # Automatically rename the file, if a file of the same name already exists - filename = secure_filename(f.filename) - filenames.append(filename) - local_path = os.path.join(self.common.settings.get('downloads_dir'), filename) - if os.path.exists(local_path): - if '.' in filename: - # Add "-i", e.g. change "foo.txt" to "foo-2.txt" - parts = filename.split('.') - name = parts[:-1] - ext = parts[-1] - - i = 2 - valid = False - while not valid: - new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) - local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - else: - # If no extension, just add "-i", e.g. change "foo" to "foo-2" - i = 2 - valid = False - while not valid: - new_filename = '{}-{}'.format(filename, i) - local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - - basename = os.path.basename(local_path) - if f.filename != basename: - # Tell the GUI that the file has changed names - self.add_request(Web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { - 'id': request.upload_id, - 'old_filename': f.filename, - 'new_filename': basename - }) - - self.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) - print(strings._('receive_mode_received_file').format(local_path)) - f.save(local_path) - - # Note that flash strings are on English, and not translated, on purpose, - # to avoid leaking the locale of the OnionShare user - if len(filenames) == 0: - flash('No files uploaded', 'info') - else: - for filename in filenames: - flash('Sent {}'.format(filename), 'info') - - if self.common.settings.get('public_mode'): - return redirect('/') - else: - return redirect('/{}'.format(slug_candidate)) - - @self.app.route("//upload", methods=['POST']) - def upload(slug_candidate): - self.check_slug_candidate(slug_candidate) - return upload_logic(slug_candidate) - - @self.app.route("/upload", methods=['POST']) - def upload_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return upload_logic() - - - def close_logic(slug_candidate=''): - if self.common.settings.get('receive_allow_receiver_shutdown'): - self.force_shutdown() - r = make_response(render_template('closed.html')) - self.add_request(Web.REQUEST_CLOSE_SERVER, request.path) - return self.add_security_headers(r) - else: - return redirect('/{}'.format(slug_candidate)) - - @self.app.route("//close", methods=['POST']) - def close(slug_candidate): - self.check_slug_candidate(slug_candidate) - return close_logic(slug_candidate) - - @self.app.route("/close", methods=['POST']) - def close_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return close_logic() - - def common_routes(self): - """ - Common web app routes between sending and receiving - """ - @self.app.errorhandler(404) - def page_not_found(e): - """ - 404 error page. - """ - return self.error404() - - @self.app.route("//shutdown") - def shutdown(slug_candidate): - """ - Stop the flask web server, from the context of an http request. - """ - self.check_shutdown_slug_candidate(slug_candidate) - self.force_shutdown() - return "" - - def error404(self): - self.add_request(Web.REQUEST_OTHER, request.path) - if request.path != '/favicon.ico': - self.error404_count += 1 - - # In receive mode, with public mode enabled, skip rate limiting 404s - if not self.common.settings.get('public_mode'): - if self.error404_count == 20: - self.add_request(Web.REQUEST_RATE_LIMIT, request.path) - self.force_shutdown() - print(strings._('error_rate_limit')) - - r = make_response(render_template('404.html'), 404) - return self.add_security_headers(r) - - def add_security_headers(self, r): - """ - Add security headers to a request - """ - for header, value in self.security_headers: - r.headers.set(header, value) - return r - - def set_file_info(self, filenames, processed_size_callback=None): - """ - Using the list of filenames being shared, fill in details that the web - page will need to display. This includes zipping up the file in order to - get the zip file's name and size. - """ - self.common.log("Web", "set_file_info") - self.cancel_compression = False - - # build file info list - self.file_info = {'files': [], 'dirs': []} - for filename in filenames: - info = { - 'filename': filename, - 'basename': os.path.basename(filename.rstrip('/')) - } - if os.path.isfile(filename): - info['size'] = os.path.getsize(filename) - info['size_human'] = self.common.human_readable_filesize(info['size']) - self.file_info['files'].append(info) - if os.path.isdir(filename): - info['size'] = self.common.dir_size(filename) - info['size_human'] = self.common.human_readable_filesize(info['size']) - self.file_info['dirs'].append(info) - self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) - self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) - - # Check if there's only 1 file and no folders - if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: - self.is_zipped = False - self.download_filename = self.file_info['files'][0]['filename'] - self.download_filesize = self.file_info['files'][0]['size'] - else: - # Zip up the files and folders - self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) - self.download_filename = self.zip_writer.zip_filename - for info in self.file_info['files']: - self.zip_writer.add_file(info['filename']) - # Canceling early? - if self.cancel_compression: - self.zip_writer.close() - return False - - for info in self.file_info['dirs']: - if not self.zip_writer.add_dir(info['filename']): - return False - - self.zip_writer.close() - self.download_filesize = os.path.getsize(self.download_filename) - self.is_zipped = True - - return True - - def _safe_select_jinja_autoescape(self, filename): - if filename is None: - return True - return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) - - def add_request(self, request_type, path, data=None): - """ - Add a request to the queue, to communicate with the GUI. - """ - self.q.put({ - 'type': request_type, - 'path': path, - 'data': data - }) - - def generate_slug(self, persistent_slug=None): - self.common.log('Web', 'generate_slug', 'persistent_slug={}'.format(persistent_slug)) - if persistent_slug != None and persistent_slug != '': - self.slug = persistent_slug - self.common.log('Web', 'generate_slug', 'persistent_slug sent, so slug is: "{}"'.format(self.slug)) - else: - self.slug = self.common.build_slug() - self.common.log('Web', 'generate_slug', 'built random slug: "{}"'.format(self.slug)) - - def debug_mode(self): - """ - Turn on debugging mode, which will log flask errors to a debug file. - """ - temp_dir = tempfile.gettempdir() - log_handler = logging.FileHandler( - os.path.join(temp_dir, 'onionshare_server.log')) - log_handler.setLevel(logging.WARNING) - self.app.logger.addHandler(log_handler) - - def check_slug_candidate(self, slug_candidate): - self.common.log('Web', 'check_slug_candidate: slug_candidate={}'.format(slug_candidate)) - if self.common.settings.get('public_mode'): - abort(404) - if not hmac.compare_digest(self.slug, slug_candidate): - abort(404) - - def check_shutdown_slug_candidate(self, slug_candidate): - self.common.log('Web', 'check_shutdown_slug_candidate: slug_candidate={}'.format(slug_candidate)) - if not hmac.compare_digest(self.shutdown_slug, slug_candidate): - abort(404) - - def force_shutdown(self): - """ - Stop the flask web server, from the context of the flask app. - """ - # Shutdown the flask service - try: - func = request.environ.get('werkzeug.server.shutdown') - if func is None: - raise RuntimeError('Not running with the Werkzeug Server') - func() - except: - pass - self.running = False - - def start(self, port, stay_open=False, public_mode=False, persistent_slug=None): - """ - Start the flask web server. - """ - self.common.log('Web', 'start', 'port={}, stay_open={}, public_mode={}, persistent_slug={}'.format(port, stay_open, public_mode, persistent_slug)) - if not public_mode: - self.generate_slug(persistent_slug) - - self.stay_open = stay_open - - # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) - if os.path.exists('/usr/share/anon-ws-base-files/workstation'): - host = '0.0.0.0' - else: - host = '127.0.0.1' - - self.running = True - self.app.run(host=host, port=port, threaded=True) - - def stop(self, port): - """ - Stop the flask web server by loading /shutdown. - """ - - # If the user cancels the download, let the download function know to stop - # serving the file - self.client_cancel = True - - # To stop flask, load http://127.0.0.1://shutdown - if self.running: - try: - s = socket.socket() - s.connect(('127.0.0.1', port)) - s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(self.shutdown_slug)) - except: - try: - urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, self.shutdown_slug)).read() - except: - pass - - -class ZipWriter(object): - """ - ZipWriter accepts files and directories and compresses them into a zip file - with. If a zip_filename is not passed in, it will use the default onionshare - filename. - """ - def __init__(self, common, zip_filename=None, processed_size_callback=None): - self.common = common - self.cancel_compression = False - - if zip_filename: - self.zip_filename = zip_filename - else: - self.zip_filename = '{0:s}/onionshare_{1:s}.zip'.format(tempfile.mkdtemp(), self.common.random_string(4, 6)) - - self.z = zipfile.ZipFile(self.zip_filename, 'w', allowZip64=True) - self.processed_size_callback = processed_size_callback - if self.processed_size_callback is None: - self.processed_size_callback = lambda _: None - self._size = 0 - self.processed_size_callback(self._size) - - def add_file(self, filename): - """ - Add a file to the zip archive. - """ - self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) - self._size += os.path.getsize(filename) - self.processed_size_callback(self._size) - - def add_dir(self, filename): - """ - Add a directory, and all of its children, to the zip archive. - """ - dir_to_strip = os.path.dirname(filename.rstrip('/'))+'/' - for dirpath, dirnames, filenames in os.walk(filename): - for f in filenames: - # Canceling early? - if self.cancel_compression: - return False - - full_filename = os.path.join(dirpath, f) - if not os.path.islink(full_filename): - arc_filename = full_filename[len(dir_to_strip):] - self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) - self._size += os.path.getsize(full_filename) - self.processed_size_callback(self._size) - - return True - - def close(self): - """ - Close the zip archive. - """ - self.z.close() - - -class ReceiveModeWSGIMiddleware(object): - """ - Custom WSGI middleware in order to attach the Web object to environ, so - ReceiveModeRequest can access it. - """ - def __init__(self, app, web): - self.app = app - self.web = web - - def __call__(self, environ, start_response): - environ['web'] = self.web - return self.app(environ, start_response) - - -class ReceiveModeTemporaryFile(object): - """ - A custom TemporaryFile that tells ReceiveModeRequest every time data gets - written to it, in order to track the progress of uploads. - """ - def __init__(self, filename, write_func, close_func): - self.onionshare_filename = filename - self.onionshare_write_func = write_func - self.onionshare_close_func = close_func - - # Create a temporary file - self.f = tempfile.TemporaryFile('wb+') - - # Make all the file-like methods and attributes actually access the - # TemporaryFile, except for write - attrs = ['closed', 'detach', 'fileno', 'flush', 'isatty', 'mode', - 'name', 'peek', 'raw', 'read', 'read1', 'readable', 'readinto', - 'readinto1', 'readline', 'readlines', 'seek', 'seekable', 'tell', - 'truncate', 'writable', 'writelines'] - for attr in attrs: - setattr(self, attr, getattr(self.f, attr)) - - def write(self, b): - """ - Custom write method that calls out to onionshare_write_func - """ - bytes_written = self.f.write(b) - self.onionshare_write_func(self.onionshare_filename, bytes_written) - - def close(self): - """ - Custom close method that calls out to onionshare_close_func - """ - self.f.close() - self.onionshare_close_func(self.onionshare_filename) - - -class ReceiveModeRequest(Request): - """ - A custom flask Request object that keeps track of how much data has been - uploaded for each file, for receive mode. - """ - def __init__(self, environ, populate_request=True, shallow=False): - super(ReceiveModeRequest, self).__init__(environ, populate_request, shallow) - self.web = environ['web'] - - # Is this a valid upload request? - self.upload_request = False - if self.method == 'POST': - if self.path == '/{}/upload'.format(self.web.slug): - self.upload_request = True - else: - if self.web.common.settings.get('public_mode'): - if self.path == '/upload': - self.upload_request = True - - if self.upload_request: - # A dictionary that maps filenames to the bytes uploaded so far - self.progress = {} - - # Create an upload_id, attach it to the request - self.upload_id = self.web.upload_count - self.web.upload_count += 1 - - # Figure out the content length - try: - self.content_length = int(self.headers['Content-Length']) - except: - self.content_length = 0 - - print("{}: {}".format( - datetime.now().strftime("%b %d, %I:%M%p"), - strings._("receive_mode_upload_starting").format(self.web.common.human_readable_filesize(self.content_length)) - )) - - # Tell the GUI - self.web.add_request(Web.REQUEST_STARTED, self.path, { - 'id': self.upload_id, - 'content_length': self.content_length - }) - - self.previous_file = None - - def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None): - """ - This gets called for each file that gets uploaded, and returns an file-like - writable stream. - """ - if self.upload_request: - self.progress[filename] = { - 'uploaded_bytes': 0, - 'complete': False - } - - return ReceiveModeTemporaryFile(filename, self.file_write_func, self.file_close_func) - - def close(self): - """ - Closing the request. - """ - super(ReceiveModeRequest, self).close() - if self.upload_request: - # Inform the GUI that the upload has finished - self.web.add_request(Web.REQUEST_UPLOAD_FINISHED, self.path, { - 'id': self.upload_id - }) - - def file_write_func(self, filename, length): - """ - This function gets called when a specific file is written to. - """ - if self.upload_request: - self.progress[filename]['uploaded_bytes'] += length - - if self.previous_file != filename: - if self.previous_file is not None: - print('') - self.previous_file = filename - - print('\r=> {:15s} {}'.format( - self.web.common.human_readable_filesize(self.progress[filename]['uploaded_bytes']), - filename - ), end='') - - # Update the GUI on the upload progress - self.web.add_request(Web.REQUEST_PROGRESS, self.path, { - 'id': self.upload_id, - 'progress': self.progress - }) - - def file_close_func(self, filename): - """ - This function gets called when a specific file is closed. - """ - self.progress[filename]['complete'] = True +from .web import Web diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py new file mode 100644 index 00000000..90accc8c --- /dev/null +++ b/onionshare/web/receive_mode.py @@ -0,0 +1,156 @@ +import tempfile +from datetime import datetime +from flask import Request + +from .. import strings + + +class ReceiveModeWSGIMiddleware(object): + """ + Custom WSGI middleware in order to attach the Web object to environ, so + ReceiveModeRequest can access it. + """ + def __init__(self, app, web): + self.app = app + self.web = web + + def __call__(self, environ, start_response): + environ['web'] = self.web + return self.app(environ, start_response) + + +class ReceiveModeTemporaryFile(object): + """ + A custom TemporaryFile that tells ReceiveModeRequest every time data gets + written to it, in order to track the progress of uploads. + """ + def __init__(self, filename, write_func, close_func): + self.onionshare_filename = filename + self.onionshare_write_func = write_func + self.onionshare_close_func = close_func + + # Create a temporary file + self.f = tempfile.TemporaryFile('wb+') + + # Make all the file-like methods and attributes actually access the + # TemporaryFile, except for write + attrs = ['closed', 'detach', 'fileno', 'flush', 'isatty', 'mode', + 'name', 'peek', 'raw', 'read', 'read1', 'readable', 'readinto', + 'readinto1', 'readline', 'readlines', 'seek', 'seekable', 'tell', + 'truncate', 'writable', 'writelines'] + for attr in attrs: + setattr(self, attr, getattr(self.f, attr)) + + def write(self, b): + """ + Custom write method that calls out to onionshare_write_func + """ + bytes_written = self.f.write(b) + self.onionshare_write_func(self.onionshare_filename, bytes_written) + + def close(self): + """ + Custom close method that calls out to onionshare_close_func + """ + self.f.close() + self.onionshare_close_func(self.onionshare_filename) + + +class ReceiveModeRequest(Request): + """ + A custom flask Request object that keeps track of how much data has been + uploaded for each file, for receive mode. + """ + def __init__(self, environ, populate_request=True, shallow=False): + super(ReceiveModeRequest, self).__init__(environ, populate_request, shallow) + self.web = environ['web'] + + # Is this a valid upload request? + self.upload_request = False + if self.method == 'POST': + if self.path == '/{}/upload'.format(self.web.slug): + self.upload_request = True + else: + if self.web.common.settings.get('public_mode'): + if self.path == '/upload': + self.upload_request = True + + if self.upload_request: + # A dictionary that maps filenames to the bytes uploaded so far + self.progress = {} + + # Create an upload_id, attach it to the request + self.upload_id = self.web.upload_count + self.web.upload_count += 1 + + # Figure out the content length + try: + self.content_length = int(self.headers['Content-Length']) + except: + self.content_length = 0 + + print("{}: {}".format( + datetime.now().strftime("%b %d, %I:%M%p"), + strings._("receive_mode_upload_starting").format(self.web.common.human_readable_filesize(self.content_length)) + )) + + # Tell the GUI + self.web.add_request(self.web.REQUEST_STARTED, self.path, { + 'id': self.upload_id, + 'content_length': self.content_length + }) + + self.previous_file = None + + def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None): + """ + This gets called for each file that gets uploaded, and returns an file-like + writable stream. + """ + if self.upload_request: + self.progress[filename] = { + 'uploaded_bytes': 0, + 'complete': False + } + + return ReceiveModeTemporaryFile(filename, self.file_write_func, self.file_close_func) + + def close(self): + """ + Closing the request. + """ + super(ReceiveModeRequest, self).close() + if self.upload_request: + # Inform the GUI that the upload has finished + self.web.add_request(self.web.REQUEST_UPLOAD_FINISHED, self.path, { + 'id': self.upload_id + }) + + def file_write_func(self, filename, length): + """ + This function gets called when a specific file is written to. + """ + if self.upload_request: + self.progress[filename]['uploaded_bytes'] += length + + if self.previous_file != filename: + if self.previous_file is not None: + print('') + self.previous_file = filename + + print('\r=> {:15s} {}'.format( + self.web.common.human_readable_filesize(self.progress[filename]['uploaded_bytes']), + filename + ), end='') + + # Update the GUI on the upload progress + self.web.add_request(self.web.REQUEST_PROGRESS, self.path, { + 'id': self.upload_id, + 'progress': self.progress + }) + + def file_close_func(self, filename): + """ + This function gets called when a specific file is closed. + """ + self.progress[filename]['complete'] = True diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py new file mode 100644 index 00000000..f066bde4 --- /dev/null +++ b/onionshare/web/share_mode.py @@ -0,0 +1,60 @@ +import os +import tempfile +import zipfile + + +class ZipWriter(object): + """ + ZipWriter accepts files and directories and compresses them into a zip file + with. If a zip_filename is not passed in, it will use the default onionshare + filename. + """ + def __init__(self, common, zip_filename=None, processed_size_callback=None): + self.common = common + self.cancel_compression = False + + if zip_filename: + self.zip_filename = zip_filename + else: + self.zip_filename = '{0:s}/onionshare_{1:s}.zip'.format(tempfile.mkdtemp(), self.common.random_string(4, 6)) + + self.z = zipfile.ZipFile(self.zip_filename, 'w', allowZip64=True) + self.processed_size_callback = processed_size_callback + if self.processed_size_callback is None: + self.processed_size_callback = lambda _: None + self._size = 0 + self.processed_size_callback(self._size) + + def add_file(self, filename): + """ + Add a file to the zip archive. + """ + self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(filename) + self.processed_size_callback(self._size) + + def add_dir(self, filename): + """ + Add a directory, and all of its children, to the zip archive. + """ + dir_to_strip = os.path.dirname(filename.rstrip('/'))+'/' + for dirpath, dirnames, filenames in os.walk(filename): + for f in filenames: + # Canceling early? + if self.cancel_compression: + return False + + full_filename = os.path.join(dirpath, f) + if not os.path.islink(full_filename): + arc_filename = full_filename[len(dir_to_strip):] + self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(full_filename) + self.processed_size_callback(self._size) + + return True + + def close(self): + """ + Close the zip archive. + """ + self.z.close() diff --git a/onionshare/web/web.py b/onionshare/web/web.py new file mode 100644 index 00000000..ff149f21 --- /dev/null +++ b/onionshare/web/web.py @@ -0,0 +1,647 @@ +import hmac +import logging +import mimetypes +import os +import queue +import socket +import sys +import tempfile +from distutils.version import LooseVersion as Version +from urllib.request import urlopen + +import flask +from flask import ( + Flask, Response, request, render_template, abort, make_response, + flash, redirect, __version__ as flask_version +) +from werkzeug.utils import secure_filename + +from .. import strings +from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable + +from .share_mode import ZipWriter +from .receive_mode import ReceiveModeWSGIMiddleware, ReceiveModeTemporaryFile, ReceiveModeRequest + + +# Stub out flask's show_server_banner function, to avoiding showing warnings that +# are not applicable to OnionShare +def stubbed_show_server_banner(env, debug, app_import_path, eager_loading): + pass + +flask.cli.show_server_banner = stubbed_show_server_banner + + +class Web(object): + """ + The Web object is the OnionShare web server, powered by flask + """ + REQUEST_LOAD = 0 + REQUEST_STARTED = 1 + REQUEST_PROGRESS = 2 + REQUEST_OTHER = 3 + REQUEST_CANCELED = 4 + REQUEST_RATE_LIMIT = 5 + REQUEST_CLOSE_SERVER = 6 + REQUEST_UPLOAD_FILE_RENAMED = 7 + REQUEST_UPLOAD_FINISHED = 8 + REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE = 9 + REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE = 10 + + def __init__(self, common, gui_mode, receive_mode=False): + self.common = common + + # The flask app + self.app = Flask(__name__, + static_folder=self.common.get_resource_path('static'), + template_folder=self.common.get_resource_path('templates')) + self.app.secret_key = self.common.random_string(8) + + # Debug mode? + if self.common.debug: + self.debug_mode() + + # Are we running in GUI mode? + self.gui_mode = gui_mode + + # Are we using receive mode? + self.receive_mode = receive_mode + if self.receive_mode: + # Use custom WSGI middleware, to modify environ + self.app.wsgi_app = ReceiveModeWSGIMiddleware(self.app.wsgi_app, self) + # Use a custom Request class to track upload progess + self.app.request_class = ReceiveModeRequest + + # Starting in Flask 0.11, render_template_string autoescapes template variables + # by default. To prevent content injection through template variables in + # earlier versions of Flask, we force autoescaping in the Jinja2 template + # engine if we detect a Flask version with insecure default behavior. + if Version(flask_version) < Version('0.11'): + # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc + Flask.select_jinja_autoescape = self._safe_select_jinja_autoescape + + # Information about the file + self.file_info = [] + self.is_zipped = False + self.download_filename = None + self.download_filesize = None + self.zip_writer = None + + self.security_headers = [ + ('Content-Security-Policy', 'default-src \'self\'; style-src \'self\'; script-src \'self\'; img-src \'self\' data:;'), + ('X-Frame-Options', 'DENY'), + ('X-Xss-Protection', '1; mode=block'), + ('X-Content-Type-Options', 'nosniff'), + ('Referrer-Policy', 'no-referrer'), + ('Server', 'OnionShare') + ] + + self.q = queue.Queue() + + self.slug = None + + self.download_count = 0 + self.upload_count = 0 + + self.error404_count = 0 + + # If "Stop After First Download" is checked (stay_open == False), only allow + # one download at a time. + self.download_in_progress = False + + self.done = False + + # If the client closes the OnionShare window while a download is in progress, + # it should immediately stop serving the file. The client_cancel global is + # used to tell the download function that the client is canceling the download. + self.client_cancel = False + + # shutting down the server only works within the context of flask, so the easiest way to do it is over http + self.shutdown_slug = self.common.random_string(16) + + # Keep track if the server is running + self.running = False + + # Define the ewb app routes + self.common_routes() + if self.receive_mode: + self.receive_routes() + else: + self.send_routes() + + def send_routes(self): + """ + The web app routes for sharing files + """ + @self.app.route("/") + def index(slug_candidate): + self.check_slug_candidate(slug_candidate) + return index_logic() + + @self.app.route("/") + def index_public(): + if not self.common.settings.get('public_mode'): + return self.error404() + return index_logic() + + def index_logic(slug_candidate=''): + """ + Render the template for the onionshare landing page. + """ + self.add_request(Web.REQUEST_LOAD, request.path) + + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.add_security_headers(r) + + # If download is allowed to continue, serve download page + if self.slug: + r = make_response(render_template( + 'send.html', + slug=self.slug, + file_info=self.file_info, + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) + else: + # If download is allowed to continue, serve download page + r = make_response(render_template( + 'send.html', + file_info=self.file_info, + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) + return self.add_security_headers(r) + + @self.app.route("//download") + def download(slug_candidate): + self.check_slug_candidate(slug_candidate) + return download_logic() + + @self.app.route("/download") + def download_public(): + if not self.common.settings.get('public_mode'): + return self.error404() + return download_logic() + + def download_logic(slug_candidate=''): + """ + Download the zip file. + """ + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.add_security_headers(r) + + # Each download has a unique id + download_id = self.download_count + self.download_count += 1 + + # Prepare some variables to use inside generate() function below + # which is outside of the request context + shutdown_func = request.environ.get('werkzeug.server.shutdown') + path = request.path + + # Tell GUI the download started + self.add_request(Web.REQUEST_STARTED, path, { + 'id': download_id} + ) + + dirname = os.path.dirname(self.download_filename) + basename = os.path.basename(self.download_filename) + + def generate(): + # The user hasn't canceled the download + self.client_cancel = False + + # Starting a new download + if not self.stay_open: + self.download_in_progress = True + + chunk_size = 102400 # 100kb + + fp = open(self.download_filename, 'rb') + self.done = False + canceled = False + while not self.done: + # The user has canceled the download, so stop serving the file + if self.client_cancel: + self.add_request(Web.REQUEST_CANCELED, path, { + 'id': download_id + }) + break + + chunk = fp.read(chunk_size) + if chunk == b'': + self.done = True + else: + try: + yield chunk + + # tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 + + # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) + if not self.gui_mode 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.add_request(Web.REQUEST_PROGRESS, path, { + 'id': download_id, + 'bytes': downloaded_bytes + }) + self.done = False + except: + # looks like the download was canceled + self.done = True + canceled = True + + # tell the GUI the download has canceled + self.add_request(Web.REQUEST_CANCELED, path, { + 'id': download_id + }) + + fp.close() + + if self.common.platform != 'Darwin': + sys.stdout.write("\n") + + # Download is finished + if not self.stay_open: + self.download_in_progress = False + + # Close the server, if necessary + if not self.stay_open and not canceled: + print(strings._("closing_automatically")) + self.running = False + try: + if shutdown_func is None: + raise RuntimeError('Not running with the Werkzeug Server') + shutdown_func() + except: + pass + + r = Response(generate()) + r.headers.set('Content-Length', self.download_filesize) + r.headers.set('Content-Disposition', 'attachment', filename=basename) + r = self.add_security_headers(r) + # guess content type + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set('Content-Type', content_type) + return r + + def receive_routes(self): + """ + The web app routes for receiving files + """ + def index_logic(): + self.add_request(Web.REQUEST_LOAD, request.path) + + if self.common.settings.get('public_mode'): + upload_action = '/upload' + close_action = '/close' + else: + upload_action = '/{}/upload'.format(self.slug) + close_action = '/{}/close'.format(self.slug) + + r = make_response(render_template( + 'receive.html', + upload_action=upload_action, + close_action=close_action, + receive_allow_receiver_shutdown=self.common.settings.get('receive_allow_receiver_shutdown'))) + return self.add_security_headers(r) + + @self.app.route("/") + def index(slug_candidate): + self.check_slug_candidate(slug_candidate) + return index_logic() + + @self.app.route("/") + def index_public(): + if not self.common.settings.get('public_mode'): + return self.error404() + return index_logic() + + + def upload_logic(slug_candidate=''): + """ + Upload files. + """ + # Make sure downloads_dir exists + valid = True + try: + self.common.validate_downloads_dir() + except DownloadsDirErrorCannotCreate: + self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) + print(strings._('error_cannot_create_downloads_dir').format(self.common.settings.get('downloads_dir'))) + valid = False + except DownloadsDirErrorNotWritable: + self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) + print(strings._('error_downloads_dir_not_writable').format(self.common.settings.get('downloads_dir'))) + valid = False + if not valid: + flash('Error uploading, please inform the OnionShare user', 'error') + if self.common.settings.get('public_mode'): + return redirect('/') + else: + return redirect('/{}'.format(slug_candidate)) + + files = request.files.getlist('file[]') + filenames = [] + print('') + for f in files: + if f.filename != '': + # Automatically rename the file, if a file of the same name already exists + filename = secure_filename(f.filename) + filenames.append(filename) + local_path = os.path.join(self.common.settings.get('downloads_dir'), filename) + if os.path.exists(local_path): + if '.' in filename: + # Add "-i", e.g. change "foo.txt" to "foo-2.txt" + parts = filename.split('.') + name = parts[:-1] + ext = parts[-1] + + i = 2 + valid = False + while not valid: + new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + else: + # If no extension, just add "-i", e.g. change "foo" to "foo-2" + i = 2 + valid = False + while not valid: + new_filename = '{}-{}'.format(filename, i) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + + basename = os.path.basename(local_path) + if f.filename != basename: + # Tell the GUI that the file has changed names + self.add_request(Web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { + 'id': request.upload_id, + 'old_filename': f.filename, + 'new_filename': basename + }) + + self.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) + print(strings._('receive_mode_received_file').format(local_path)) + f.save(local_path) + + # Note that flash strings are on English, and not translated, on purpose, + # to avoid leaking the locale of the OnionShare user + if len(filenames) == 0: + flash('No files uploaded', 'info') + else: + for filename in filenames: + flash('Sent {}'.format(filename), 'info') + + if self.common.settings.get('public_mode'): + return redirect('/') + else: + return redirect('/{}'.format(slug_candidate)) + + @self.app.route("//upload", methods=['POST']) + def upload(slug_candidate): + self.check_slug_candidate(slug_candidate) + return upload_logic(slug_candidate) + + @self.app.route("/upload", methods=['POST']) + def upload_public(): + if not self.common.settings.get('public_mode'): + return self.error404() + return upload_logic() + + + def close_logic(slug_candidate=''): + if self.common.settings.get('receive_allow_receiver_shutdown'): + self.force_shutdown() + r = make_response(render_template('closed.html')) + self.add_request(Web.REQUEST_CLOSE_SERVER, request.path) + return self.add_security_headers(r) + else: + return redirect('/{}'.format(slug_candidate)) + + @self.app.route("//close", methods=['POST']) + def close(slug_candidate): + self.check_slug_candidate(slug_candidate) + return close_logic(slug_candidate) + + @self.app.route("/close", methods=['POST']) + def close_public(): + if not self.common.settings.get('public_mode'): + return self.error404() + return close_logic() + + def common_routes(self): + """ + Common web app routes between sending and receiving + """ + @self.app.errorhandler(404) + def page_not_found(e): + """ + 404 error page. + """ + return self.error404() + + @self.app.route("//shutdown") + def shutdown(slug_candidate): + """ + Stop the flask web server, from the context of an http request. + """ + self.check_shutdown_slug_candidate(slug_candidate) + self.force_shutdown() + return "" + + def error404(self): + self.add_request(Web.REQUEST_OTHER, request.path) + if request.path != '/favicon.ico': + self.error404_count += 1 + + # In receive mode, with public mode enabled, skip rate limiting 404s + if not self.common.settings.get('public_mode'): + if self.error404_count == 20: + self.add_request(Web.REQUEST_RATE_LIMIT, request.path) + self.force_shutdown() + print(strings._('error_rate_limit')) + + r = make_response(render_template('404.html'), 404) + return self.add_security_headers(r) + + def add_security_headers(self, r): + """ + Add security headers to a request + """ + for header, value in self.security_headers: + r.headers.set(header, value) + return r + + def set_file_info(self, filenames, processed_size_callback=None): + """ + Using the list of filenames being shared, fill in details that the web + page will need to display. This includes zipping up the file in order to + get the zip file's name and size. + """ + self.common.log("Web", "set_file_info") + self.cancel_compression = False + + # build file info list + self.file_info = {'files': [], 'dirs': []} + for filename in filenames: + info = { + 'filename': filename, + 'basename': os.path.basename(filename.rstrip('/')) + } + if os.path.isfile(filename): + info['size'] = os.path.getsize(filename) + info['size_human'] = self.common.human_readable_filesize(info['size']) + self.file_info['files'].append(info) + if os.path.isdir(filename): + info['size'] = self.common.dir_size(filename) + info['size_human'] = self.common.human_readable_filesize(info['size']) + self.file_info['dirs'].append(info) + self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) + self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) + + # Check if there's only 1 file and no folders + if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: + self.is_zipped = False + self.download_filename = self.file_info['files'][0]['filename'] + self.download_filesize = self.file_info['files'][0]['size'] + else: + # Zip up the files and folders + self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) + self.download_filename = self.zip_writer.zip_filename + for info in self.file_info['files']: + self.zip_writer.add_file(info['filename']) + # Canceling early? + if self.cancel_compression: + self.zip_writer.close() + return False + + for info in self.file_info['dirs']: + if not self.zip_writer.add_dir(info['filename']): + return False + + self.zip_writer.close() + self.download_filesize = os.path.getsize(self.download_filename) + self.is_zipped = True + + return True + + def _safe_select_jinja_autoescape(self, filename): + if filename is None: + return True + return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) + + def add_request(self, request_type, path, data=None): + """ + Add a request to the queue, to communicate with the GUI. + """ + self.q.put({ + 'type': request_type, + 'path': path, + 'data': data + }) + + def generate_slug(self, persistent_slug=None): + self.common.log('Web', 'generate_slug', 'persistent_slug={}'.format(persistent_slug)) + if persistent_slug != None and persistent_slug != '': + self.slug = persistent_slug + self.common.log('Web', 'generate_slug', 'persistent_slug sent, so slug is: "{}"'.format(self.slug)) + else: + self.slug = self.common.build_slug() + self.common.log('Web', 'generate_slug', 'built random slug: "{}"'.format(self.slug)) + + def debug_mode(self): + """ + Turn on debugging mode, which will log flask errors to a debug file. + """ + temp_dir = tempfile.gettempdir() + log_handler = logging.FileHandler( + os.path.join(temp_dir, 'onionshare_server.log')) + log_handler.setLevel(logging.WARNING) + self.app.logger.addHandler(log_handler) + + def check_slug_candidate(self, slug_candidate): + self.common.log('Web', 'check_slug_candidate: slug_candidate={}'.format(slug_candidate)) + if self.common.settings.get('public_mode'): + abort(404) + if not hmac.compare_digest(self.slug, slug_candidate): + abort(404) + + def check_shutdown_slug_candidate(self, slug_candidate): + self.common.log('Web', 'check_shutdown_slug_candidate: slug_candidate={}'.format(slug_candidate)) + if not hmac.compare_digest(self.shutdown_slug, slug_candidate): + abort(404) + + def force_shutdown(self): + """ + Stop the flask web server, from the context of the flask app. + """ + # Shutdown the flask service + try: + func = request.environ.get('werkzeug.server.shutdown') + if func is None: + raise RuntimeError('Not running with the Werkzeug Server') + func() + except: + pass + self.running = False + + def start(self, port, stay_open=False, public_mode=False, persistent_slug=None): + """ + Start the flask web server. + """ + self.common.log('Web', 'start', 'port={}, stay_open={}, public_mode={}, persistent_slug={}'.format(port, stay_open, public_mode, persistent_slug)) + if not public_mode: + self.generate_slug(persistent_slug) + + self.stay_open = stay_open + + # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) + if os.path.exists('/usr/share/anon-ws-base-files/workstation'): + host = '0.0.0.0' + else: + host = '127.0.0.1' + + self.running = True + self.app.run(host=host, port=port, threaded=True) + + def stop(self, port): + """ + Stop the flask web server by loading /shutdown. + """ + + # If the user cancels the download, let the download function know to stop + # serving the file + self.client_cancel = True + + # To stop flask, load http://127.0.0.1://shutdown + if self.running: + try: + s = socket.socket() + s.connect(('127.0.0.1', port)) + s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(self.shutdown_slug)) + except: + try: + urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, self.shutdown_slug)).read() + except: + pass From d699a7f6419674ffba81b161f5ef04d3a0140da0 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 23:45:13 -0700 Subject: [PATCH 06/15] Fix tests to point to new location of ZipWriter class --- test/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 610a43ea..8ac7efb8 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -64,7 +64,7 @@ def temp_file_1024_delete(): # pytest > 2.9 only needs @pytest.fixture @pytest.yield_fixture(scope='session') def custom_zw(): - zw = web.ZipWriter( + zw = web.share_mode.ZipWriter( common.Common(), zip_filename=common.Common.random_string(4, 6), processed_size_callback=lambda _: 'custom_callback' @@ -77,7 +77,7 @@ def custom_zw(): # pytest > 2.9 only needs @pytest.fixture @pytest.yield_fixture(scope='session') def default_zw(): - zw = web.ZipWriter(common.Common()) + zw = web.share_mode.ZipWriter(common.Common()) yield zw zw.close() tmp_dir = os.path.dirname(zw.zip_filename) From 4f27fac8408383b17326c07909b525cd72f0f9ac Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Thu, 20 Sep 2018 23:58:27 -0700 Subject: [PATCH 07/15] Refactor web to push share and receive mode logic into their respective files --- onionshare/web/receive_mode.py | 157 ++++++++++++++- onionshare/web/share_mode.py | 177 +++++++++++++++++ onionshare/web/web.py | 338 +-------------------------------- 3 files changed, 338 insertions(+), 334 deletions(-) diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py index 90accc8c..0ebc9ccd 100644 --- a/onionshare/web/receive_mode.py +++ b/onionshare/web/receive_mode.py @@ -1,10 +1,165 @@ +import os import tempfile from datetime import datetime -from flask import Request +from flask import Request, request, render_template, make_response, flash, redirect +from werkzeug.utils import secure_filename +from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable from .. import strings +def receive_routes(web): + """ + The web app routes for receiving files + """ + def index_logic(): + web.add_request(web.REQUEST_LOAD, request.path) + + if web.common.settings.get('public_mode'): + upload_action = '/upload' + close_action = '/close' + else: + upload_action = '/{}/upload'.format(web.slug) + close_action = '/{}/close'.format(web.slug) + + r = make_response(render_template( + 'receive.html', + upload_action=upload_action, + close_action=close_action, + receive_allow_receiver_shutdown=web.common.settings.get('receive_allow_receiver_shutdown'))) + return web.add_security_headers(r) + + @web.app.route("/") + def index(slug_candidate): + web.check_slug_candidate(slug_candidate) + return index_logic() + + @web.app.route("/") + def index_public(): + if not web.common.settings.get('public_mode'): + return web.error404() + return index_logic() + + + def upload_logic(slug_candidate=''): + """ + Upload files. + """ + # Make sure downloads_dir exists + valid = True + try: + web.common.validate_downloads_dir() + except DownloadsDirErrorCannotCreate: + web.add_request(web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) + print(strings._('error_cannot_create_downloads_dir').format(web.common.settings.get('downloads_dir'))) + valid = False + except DownloadsDirErrorNotWritable: + web.add_request(web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) + print(strings._('error_downloads_dir_not_writable').format(web.common.settings.get('downloads_dir'))) + valid = False + if not valid: + flash('Error uploading, please inform the OnionShare user', 'error') + if web.common.settings.get('public_mode'): + return redirect('/') + else: + return redirect('/{}'.format(slug_candidate)) + + files = request.files.getlist('file[]') + filenames = [] + print('') + for f in files: + if f.filename != '': + # Automatically rename the file, if a file of the same name already exists + filename = secure_filename(f.filename) + filenames.append(filename) + local_path = os.path.join(web.common.settings.get('downloads_dir'), filename) + if os.path.exists(local_path): + if '.' in filename: + # Add "-i", e.g. change "foo.txt" to "foo-2.txt" + parts = filename.split('.') + name = parts[:-1] + ext = parts[-1] + + i = 2 + valid = False + while not valid: + new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) + local_path = os.path.join(web.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + else: + # If no extension, just add "-i", e.g. change "foo" to "foo-2" + i = 2 + valid = False + while not valid: + new_filename = '{}-{}'.format(filename, i) + local_path = os.path.join(web.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + + basename = os.path.basename(local_path) + if f.filename != basename: + # Tell the GUI that the file has changed names + web.add_request(web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { + 'id': request.upload_id, + 'old_filename': f.filename, + 'new_filename': basename + }) + + web.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) + print(strings._('receive_mode_received_file').format(local_path)) + f.save(local_path) + + # Note that flash strings are on English, and not translated, on purpose, + # to avoid leaking the locale of the OnionShare user + if len(filenames) == 0: + flash('No files uploaded', 'info') + else: + for filename in filenames: + flash('Sent {}'.format(filename), 'info') + + if web.common.settings.get('public_mode'): + return redirect('/') + else: + return redirect('/{}'.format(slug_candidate)) + + @web.app.route("//upload", methods=['POST']) + def upload(slug_candidate): + web.check_slug_candidate(slug_candidate) + return upload_logic(slug_candidate) + + @web.app.route("/upload", methods=['POST']) + def upload_public(): + if not web.common.settings.get('public_mode'): + return web.error404() + return upload_logic() + + + def close_logic(slug_candidate=''): + if web.common.settings.get('receive_allow_receiver_shutdown'): + web.force_shutdown() + r = make_response(render_template('closed.html')) + web.add_request(web.REQUEST_CLOSE_SERVER, request.path) + return web.add_security_headers(r) + else: + return redirect('/{}'.format(slug_candidate)) + + @web.app.route("//close", methods=['POST']) + def close(slug_candidate): + web.check_slug_candidate(slug_candidate) + return close_logic(slug_candidate) + + @web.app.route("/close", methods=['POST']) + def close_public(): + if not web.common.settings.get('public_mode'): + return web.error404() + return close_logic() + + class ReceiveModeWSGIMiddleware(object): """ Custom WSGI middleware in order to attach the Web object to environ, so diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index f066bde4..58cc9b99 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -1,6 +1,183 @@ import os +import sys import tempfile import zipfile +import mimetypes +from flask import Response, request, render_template, make_response + +from .. import strings + + +def share_routes(web): + """ + The web app routes for sharing files + """ + @web.app.route("/") + def index(slug_candidate): + web.check_slug_candidate(slug_candidate) + return index_logic() + + @web.app.route("/") + def index_public(): + if not web.common.settings.get('public_mode'): + return web.error404() + return index_logic() + + def index_logic(slug_candidate=''): + """ + Render the template for the onionshare landing page. + """ + web.add_request(web.REQUEST_LOAD, request.path) + + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not web.stay_open and web.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return web.add_security_headers(r) + + # If download is allowed to continue, serve download page + if web.slug: + r = make_response(render_template( + 'send.html', + slug=web.slug, + file_info=web.file_info, + filename=os.path.basename(web.download_filename), + filesize=web.download_filesize, + filesize_human=web.common.human_readable_filesize(web.download_filesize), + is_zipped=web.is_zipped)) + else: + # If download is allowed to continue, serve download page + r = make_response(render_template( + 'send.html', + file_info=web.file_info, + filename=os.path.basename(web.download_filename), + filesize=web.download_filesize, + filesize_human=web.common.human_readable_filesize(web.download_filesize), + is_zipped=web.is_zipped)) + return web.add_security_headers(r) + + @web.app.route("//download") + def download(slug_candidate): + web.check_slug_candidate(slug_candidate) + return download_logic() + + @web.app.route("/download") + def download_public(): + if not web.common.settings.get('public_mode'): + return web.error404() + return download_logic() + + def download_logic(slug_candidate=''): + """ + Download the zip file. + """ + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not web.stay_open and web.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return web.add_security_headers(r) + + # Each download has a unique id + download_id = web.download_count + web.download_count += 1 + + # Prepare some variables to use inside generate() function below + # which is outside of the request context + shutdown_func = request.environ.get('werkzeug.server.shutdown') + path = request.path + + # Tell GUI the download started + web.add_request(web.REQUEST_STARTED, path, { + 'id': download_id} + ) + + dirname = os.path.dirname(web.download_filename) + basename = os.path.basename(web.download_filename) + + def generate(): + # The user hasn't canceled the download + web.client_cancel = False + + # Starting a new download + if not web.stay_open: + web.download_in_progress = True + + chunk_size = 102400 # 100kb + + fp = open(web.download_filename, 'rb') + web.done = False + canceled = False + while not web.done: + # The user has canceled the download, so stop serving the file + if web.client_cancel: + web.add_request(web.REQUEST_CANCELED, path, { + 'id': download_id + }) + break + + chunk = fp.read(chunk_size) + if chunk == b'': + web.done = True + else: + try: + yield chunk + + # tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / web.download_filesize) * 100 + + # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) + if not web.gui_mode or web.common.platform == 'Linux' or web.common.platform == 'BSD': + sys.stdout.write( + "\r{0:s}, {1:.2f}% ".format(web.common.human_readable_filesize(downloaded_bytes), percent)) + sys.stdout.flush() + + web.add_request(web.REQUEST_PROGRESS, path, { + 'id': download_id, + 'bytes': downloaded_bytes + }) + web.done = False + except: + # looks like the download was canceled + web.done = True + canceled = True + + # tell the GUI the download has canceled + web.add_request(web.REQUEST_CANCELED, path, { + 'id': download_id + }) + + fp.close() + + if web.common.platform != 'Darwin': + sys.stdout.write("\n") + + # Download is finished + if not web.stay_open: + web.download_in_progress = False + + # Close the server, if necessary + if not web.stay_open and not canceled: + print(strings._("closing_automatically")) + web.running = False + try: + if shutdown_func is None: + raise RuntimeError('Not running with the Werkzeug Server') + shutdown_func() + except: + pass + + r = Response(generate()) + r.headers.set('Content-Length', web.download_filesize) + r.headers.set('Content-Disposition', 'attachment', filename=basename) + r = web.add_security_headers(r) + # guess content type + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set('Content-Type', content_type) + return r class ZipWriter(object): diff --git a/onionshare/web/web.py b/onionshare/web/web.py index ff149f21..0a6e6964 100644 --- a/onionshare/web/web.py +++ b/onionshare/web/web.py @@ -1,6 +1,5 @@ import hmac import logging -import mimetypes import os import queue import socket @@ -10,17 +9,12 @@ from distutils.version import LooseVersion as Version from urllib.request import urlopen import flask -from flask import ( - Flask, Response, request, render_template, abort, make_response, - flash, redirect, __version__ as flask_version -) -from werkzeug.utils import secure_filename +from flask import Flask, request, render_template, abort, make_response, __version__ as flask_version from .. import strings -from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable -from .share_mode import ZipWriter -from .receive_mode import ReceiveModeWSGIMiddleware, ReceiveModeTemporaryFile, ReceiveModeRequest +from .share_mode import share_routes, ZipWriter +from .receive_mode import receive_routes, ReceiveModeWSGIMiddleware, ReceiveModeTemporaryFile, ReceiveModeRequest # Stub out flask's show_server_banner function, to avoiding showing warnings that @@ -124,331 +118,9 @@ class Web(object): # Define the ewb app routes self.common_routes() if self.receive_mode: - self.receive_routes() + receive_routes(self) else: - self.send_routes() - - def send_routes(self): - """ - The web app routes for sharing files - """ - @self.app.route("/") - def index(slug_candidate): - self.check_slug_candidate(slug_candidate) - return index_logic() - - @self.app.route("/") - def index_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return index_logic() - - def index_logic(slug_candidate=''): - """ - Render the template for the onionshare landing page. - """ - self.add_request(Web.REQUEST_LOAD, request.path) - - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not self.stay_open and self.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return self.add_security_headers(r) - - # If download is allowed to continue, serve download page - if self.slug: - r = make_response(render_template( - 'send.html', - slug=self.slug, - file_info=self.file_info, - filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, - filesize_human=self.common.human_readable_filesize(self.download_filesize), - is_zipped=self.is_zipped)) - else: - # If download is allowed to continue, serve download page - r = make_response(render_template( - 'send.html', - file_info=self.file_info, - filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, - filesize_human=self.common.human_readable_filesize(self.download_filesize), - is_zipped=self.is_zipped)) - return self.add_security_headers(r) - - @self.app.route("//download") - def download(slug_candidate): - self.check_slug_candidate(slug_candidate) - return download_logic() - - @self.app.route("/download") - def download_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return download_logic() - - def download_logic(slug_candidate=''): - """ - Download the zip file. - """ - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not self.stay_open and self.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return self.add_security_headers(r) - - # Each download has a unique id - download_id = self.download_count - self.download_count += 1 - - # Prepare some variables to use inside generate() function below - # which is outside of the request context - shutdown_func = request.environ.get('werkzeug.server.shutdown') - path = request.path - - # Tell GUI the download started - self.add_request(Web.REQUEST_STARTED, path, { - 'id': download_id} - ) - - dirname = os.path.dirname(self.download_filename) - basename = os.path.basename(self.download_filename) - - def generate(): - # The user hasn't canceled the download - self.client_cancel = False - - # Starting a new download - if not self.stay_open: - self.download_in_progress = True - - chunk_size = 102400 # 100kb - - fp = open(self.download_filename, 'rb') - self.done = False - canceled = False - while not self.done: - # The user has canceled the download, so stop serving the file - if self.client_cancel: - self.add_request(Web.REQUEST_CANCELED, path, { - 'id': download_id - }) - break - - chunk = fp.read(chunk_size) - if chunk == b'': - self.done = True - else: - try: - yield chunk - - # tell GUI the progress - downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 - - # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - if not self.gui_mode 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.add_request(Web.REQUEST_PROGRESS, path, { - 'id': download_id, - 'bytes': downloaded_bytes - }) - self.done = False - except: - # looks like the download was canceled - self.done = True - canceled = True - - # tell the GUI the download has canceled - self.add_request(Web.REQUEST_CANCELED, path, { - 'id': download_id - }) - - fp.close() - - if self.common.platform != 'Darwin': - sys.stdout.write("\n") - - # Download is finished - if not self.stay_open: - self.download_in_progress = False - - # Close the server, if necessary - if not self.stay_open and not canceled: - print(strings._("closing_automatically")) - self.running = False - try: - if shutdown_func is None: - raise RuntimeError('Not running with the Werkzeug Server') - shutdown_func() - except: - pass - - r = Response(generate()) - r.headers.set('Content-Length', self.download_filesize) - r.headers.set('Content-Disposition', 'attachment', filename=basename) - r = self.add_security_headers(r) - # guess content type - (content_type, _) = mimetypes.guess_type(basename, strict=False) - if content_type is not None: - r.headers.set('Content-Type', content_type) - return r - - def receive_routes(self): - """ - The web app routes for receiving files - """ - def index_logic(): - self.add_request(Web.REQUEST_LOAD, request.path) - - if self.common.settings.get('public_mode'): - upload_action = '/upload' - close_action = '/close' - else: - upload_action = '/{}/upload'.format(self.slug) - close_action = '/{}/close'.format(self.slug) - - r = make_response(render_template( - 'receive.html', - upload_action=upload_action, - close_action=close_action, - receive_allow_receiver_shutdown=self.common.settings.get('receive_allow_receiver_shutdown'))) - return self.add_security_headers(r) - - @self.app.route("/") - def index(slug_candidate): - self.check_slug_candidate(slug_candidate) - return index_logic() - - @self.app.route("/") - def index_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return index_logic() - - - def upload_logic(slug_candidate=''): - """ - Upload files. - """ - # Make sure downloads_dir exists - valid = True - try: - self.common.validate_downloads_dir() - except DownloadsDirErrorCannotCreate: - self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) - print(strings._('error_cannot_create_downloads_dir').format(self.common.settings.get('downloads_dir'))) - valid = False - except DownloadsDirErrorNotWritable: - self.add_request(Web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) - print(strings._('error_downloads_dir_not_writable').format(self.common.settings.get('downloads_dir'))) - valid = False - if not valid: - flash('Error uploading, please inform the OnionShare user', 'error') - if self.common.settings.get('public_mode'): - return redirect('/') - else: - return redirect('/{}'.format(slug_candidate)) - - files = request.files.getlist('file[]') - filenames = [] - print('') - for f in files: - if f.filename != '': - # Automatically rename the file, if a file of the same name already exists - filename = secure_filename(f.filename) - filenames.append(filename) - local_path = os.path.join(self.common.settings.get('downloads_dir'), filename) - if os.path.exists(local_path): - if '.' in filename: - # Add "-i", e.g. change "foo.txt" to "foo-2.txt" - parts = filename.split('.') - name = parts[:-1] - ext = parts[-1] - - i = 2 - valid = False - while not valid: - new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) - local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - else: - # If no extension, just add "-i", e.g. change "foo" to "foo-2" - i = 2 - valid = False - while not valid: - new_filename = '{}-{}'.format(filename, i) - local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - - basename = os.path.basename(local_path) - if f.filename != basename: - # Tell the GUI that the file has changed names - self.add_request(Web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { - 'id': request.upload_id, - 'old_filename': f.filename, - 'new_filename': basename - }) - - self.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) - print(strings._('receive_mode_received_file').format(local_path)) - f.save(local_path) - - # Note that flash strings are on English, and not translated, on purpose, - # to avoid leaking the locale of the OnionShare user - if len(filenames) == 0: - flash('No files uploaded', 'info') - else: - for filename in filenames: - flash('Sent {}'.format(filename), 'info') - - if self.common.settings.get('public_mode'): - return redirect('/') - else: - return redirect('/{}'.format(slug_candidate)) - - @self.app.route("//upload", methods=['POST']) - def upload(slug_candidate): - self.check_slug_candidate(slug_candidate) - return upload_logic(slug_candidate) - - @self.app.route("/upload", methods=['POST']) - def upload_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return upload_logic() - - - def close_logic(slug_candidate=''): - if self.common.settings.get('receive_allow_receiver_shutdown'): - self.force_shutdown() - r = make_response(render_template('closed.html')) - self.add_request(Web.REQUEST_CLOSE_SERVER, request.path) - return self.add_security_headers(r) - else: - return redirect('/{}'.format(slug_candidate)) - - @self.app.route("//close", methods=['POST']) - def close(slug_candidate): - self.check_slug_candidate(slug_candidate) - return close_logic(slug_candidate) - - @self.app.route("/close", methods=['POST']) - def close_public(): - if not self.common.settings.get('public_mode'): - return self.error404() - return close_logic() + share_routes(self) def common_routes(self): """ From 916c5ed1971c7f514a37bbd20c4a45e8d0a7c208 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 11:14:32 -0700 Subject: [PATCH 08/15] Refactor web even more to all of the share and receive web logic into ShareModeWeb and ReceiveModeWeb classes --- onionshare/__init__.py | 2 +- onionshare/web/receive_mode.py | 280 ++++++++++--------- onionshare/web/share_mode.py | 349 ++++++++++++++---------- onionshare/web/web.py | 84 ++---- onionshare_gui/receive_mode/__init__.py | 2 +- onionshare_gui/share_mode/__init__.py | 2 +- onionshare_gui/share_mode/threads.py | 2 +- test/test_onionshare_web.py | 32 +-- 8 files changed, 387 insertions(+), 366 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 2f57ccf2..9c390fa8 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -119,7 +119,7 @@ def main(cwd=None): # Prepare files to share print(strings._("preparing_files")) try: - web.set_file_info(filenames) + web.share_mode.set_file_info(filenames) if web.is_zipped: app.cleanup_filenames.append(web.download_filename) except OSError as e: diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py index 0ebc9ccd..ab5f5f13 100644 --- a/onionshare/web/receive_mode.py +++ b/onionshare/web/receive_mode.py @@ -8,156 +8,164 @@ from ..common import DownloadsDirErrorCannotCreate, DownloadsDirErrorNotWritable from .. import strings -def receive_routes(web): +class ReceiveModeWeb(object): """ - The web app routes for receiving files + All of the web logic for receive mode """ - def index_logic(): - web.add_request(web.REQUEST_LOAD, request.path) + def __init__(self, web): + self.web = web + self.define_routes() - if web.common.settings.get('public_mode'): - upload_action = '/upload' - close_action = '/close' - else: - upload_action = '/{}/upload'.format(web.slug) - close_action = '/{}/close'.format(web.slug) - - r = make_response(render_template( - 'receive.html', - upload_action=upload_action, - close_action=close_action, - receive_allow_receiver_shutdown=web.common.settings.get('receive_allow_receiver_shutdown'))) - return web.add_security_headers(r) - - @web.app.route("/") - def index(slug_candidate): - web.check_slug_candidate(slug_candidate) - return index_logic() - - @web.app.route("/") - def index_public(): - if not web.common.settings.get('public_mode'): - return web.error404() - return index_logic() - - - def upload_logic(slug_candidate=''): + def define_routes(self): """ - Upload files. + The web app routes for receiving files """ - # Make sure downloads_dir exists - valid = True - try: - web.common.validate_downloads_dir() - except DownloadsDirErrorCannotCreate: - web.add_request(web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) - print(strings._('error_cannot_create_downloads_dir').format(web.common.settings.get('downloads_dir'))) - valid = False - except DownloadsDirErrorNotWritable: - web.add_request(web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) - print(strings._('error_downloads_dir_not_writable').format(web.common.settings.get('downloads_dir'))) - valid = False - if not valid: - flash('Error uploading, please inform the OnionShare user', 'error') - if web.common.settings.get('public_mode'): + def index_logic(): + self.web.add_request(self.web.REQUEST_LOAD, request.path) + + if self.web.common.settings.get('public_mode'): + upload_action = '/upload' + close_action = '/close' + else: + upload_action = '/{}/upload'.format(self.web.slug) + close_action = '/{}/close'.format(self.web.slug) + + r = make_response(render_template( + 'receive.html', + upload_action=upload_action, + close_action=close_action, + receive_allow_receiver_shutdown=self.web.common.settings.get('receive_allow_receiver_shutdown'))) + return self.web.add_security_headers(r) + + @self.web.app.route("/") + def index(slug_candidate): + self.web.check_slug_candidate(slug_candidate) + return index_logic() + + @self.web.app.route("/") + def index_public(): + if not self.web.common.settings.get('public_mode'): + return self.web.error404() + return index_logic() + + + def upload_logic(slug_candidate=''): + """ + Upload files. + """ + # Make sure downloads_dir exists + valid = True + try: + self.web.common.validate_downloads_dir() + except DownloadsDirErrorCannotCreate: + self.web.add_request(self.web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) + print(strings._('error_cannot_create_downloads_dir').format(self.web.common.settings.get('downloads_dir'))) + valid = False + except DownloadsDirErrorNotWritable: + self.web.add_request(self.web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) + print(strings._('error_downloads_dir_not_writable').format(self.web.common.settings.get('downloads_dir'))) + valid = False + if not valid: + flash('Error uploading, please inform the OnionShare user', 'error') + if self.web.common.settings.get('public_mode'): + return redirect('/') + else: + return redirect('/{}'.format(slug_candidate)) + + files = request.files.getlist('file[]') + filenames = [] + print('') + for f in files: + if f.filename != '': + # Automatically rename the file, if a file of the same name already exists + filename = secure_filename(f.filename) + filenames.append(filename) + local_path = os.path.join(self.web.common.settings.get('downloads_dir'), filename) + if os.path.exists(local_path): + if '.' in filename: + # Add "-i", e.g. change "foo.txt" to "foo-2.txt" + parts = filename.split('.') + name = parts[:-1] + ext = parts[-1] + + i = 2 + valid = False + while not valid: + new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) + local_path = os.path.join(self.web.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + else: + # If no extension, just add "-i", e.g. change "foo" to "foo-2" + i = 2 + valid = False + while not valid: + new_filename = '{}-{}'.format(filename, i) + local_path = os.path.join(self.web.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + + basename = os.path.basename(local_path) + if f.filename != basename: + # Tell the GUI that the file has changed names + self.web.add_request(self.web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { + 'id': request.upload_id, + 'old_filename': f.filename, + 'new_filename': basename + }) + + self.web.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) + print(strings._('receive_mode_received_file').format(local_path)) + f.save(local_path) + + # Note that flash strings are on English, and not translated, on purpose, + # to avoid leaking the locale of the OnionShare user + if len(filenames) == 0: + flash('No files uploaded', 'info') + else: + for filename in filenames: + flash('Sent {}'.format(filename), 'info') + + if self.web.common.settings.get('public_mode'): return redirect('/') else: return redirect('/{}'.format(slug_candidate)) - files = request.files.getlist('file[]') - filenames = [] - print('') - for f in files: - if f.filename != '': - # Automatically rename the file, if a file of the same name already exists - filename = secure_filename(f.filename) - filenames.append(filename) - local_path = os.path.join(web.common.settings.get('downloads_dir'), filename) - if os.path.exists(local_path): - if '.' in filename: - # Add "-i", e.g. change "foo.txt" to "foo-2.txt" - parts = filename.split('.') - name = parts[:-1] - ext = parts[-1] + @self.web.app.route("//upload", methods=['POST']) + def upload(slug_candidate): + self.web.check_slug_candidate(slug_candidate) + return upload_logic(slug_candidate) - i = 2 - valid = False - while not valid: - new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) - local_path = os.path.join(web.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - else: - # If no extension, just add "-i", e.g. change "foo" to "foo-2" - i = 2 - valid = False - while not valid: - new_filename = '{}-{}'.format(filename, i) - local_path = os.path.join(web.common.settings.get('downloads_dir'), new_filename) - if os.path.exists(local_path): - i += 1 - else: - valid = True - - basename = os.path.basename(local_path) - if f.filename != basename: - # Tell the GUI that the file has changed names - web.add_request(web.REQUEST_UPLOAD_FILE_RENAMED, request.path, { - 'id': request.upload_id, - 'old_filename': f.filename, - 'new_filename': basename - }) - - web.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) - print(strings._('receive_mode_received_file').format(local_path)) - f.save(local_path) - - # Note that flash strings are on English, and not translated, on purpose, - # to avoid leaking the locale of the OnionShare user - if len(filenames) == 0: - flash('No files uploaded', 'info') - else: - for filename in filenames: - flash('Sent {}'.format(filename), 'info') - - if web.common.settings.get('public_mode'): - return redirect('/') - else: - return redirect('/{}'.format(slug_candidate)) - - @web.app.route("//upload", methods=['POST']) - def upload(slug_candidate): - web.check_slug_candidate(slug_candidate) - return upload_logic(slug_candidate) - - @web.app.route("/upload", methods=['POST']) - def upload_public(): - if not web.common.settings.get('public_mode'): - return web.error404() - return upload_logic() + @self.web.app.route("/upload", methods=['POST']) + def upload_public(): + if not self.web.common.settings.get('public_mode'): + return self.web.error404() + return upload_logic() - def close_logic(slug_candidate=''): - if web.common.settings.get('receive_allow_receiver_shutdown'): - web.force_shutdown() - r = make_response(render_template('closed.html')) - web.add_request(web.REQUEST_CLOSE_SERVER, request.path) - return web.add_security_headers(r) - else: - return redirect('/{}'.format(slug_candidate)) + def close_logic(slug_candidate=''): + if self.web.common.settings.get('receive_allow_receiver_shutdown'): + self.web.force_shutdown() + r = make_response(render_template('closed.html')) + self.web.add_request(self.web.REQUEST_CLOSE_SERVER, request.path) + return self.web.add_security_headers(r) + else: + return redirect('/{}'.format(slug_candidate)) - @web.app.route("//close", methods=['POST']) - def close(slug_candidate): - web.check_slug_candidate(slug_candidate) - return close_logic(slug_candidate) + @self.web.app.route("//close", methods=['POST']) + def close(slug_candidate): + self.web.check_slug_candidate(slug_candidate) + return close_logic(slug_candidate) - @web.app.route("/close", methods=['POST']) - def close_public(): - if not web.common.settings.get('public_mode'): - return web.error404() - return close_logic() + @self.web.app.route("/close", methods=['POST']) + def close_public(): + if not self.web.common.settings.get('public_mode'): + return self.web.error404() + return close_logic() class ReceiveModeWSGIMiddleware(object): diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index 58cc9b99..c8a411bb 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -8,176 +8,237 @@ from flask import Response, request, render_template, make_response from .. import strings -def share_routes(web): +class ShareModeWeb(object): """ - The web app routes for sharing files + All of the web logic for share mode """ - @web.app.route("/") - def index(slug_candidate): - web.check_slug_candidate(slug_candidate) - return index_logic() + def __init__(self, web): + self.web = web + self.define_routes() - @web.app.route("/") - def index_public(): - if not web.common.settings.get('public_mode'): - return web.error404() - return index_logic() - - def index_logic(slug_candidate=''): + def define_routes(self): """ - Render the template for the onionshare landing page. + The web app routes for sharing files """ - web.add_request(web.REQUEST_LOAD, request.path) + @self.web.app.route("/") + def index(slug_candidate): + self.web.check_slug_candidate(slug_candidate) + return index_logic() - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not web.stay_open and web.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return web.add_security_headers(r) + @self.web.app.route("/") + def index_public(): + if not self.web.common.settings.get('public_mode'): + return self.web.error404() + return index_logic() + + def index_logic(slug_candidate=''): + """ + Render the template for the onionshare landing page. + """ + self.web.add_request(self.web.REQUEST_LOAD, request.path) + + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.web.stay_open and self.web.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.web.add_security_headers(r) - # If download is allowed to continue, serve download page - if web.slug: - r = make_response(render_template( - 'send.html', - slug=web.slug, - file_info=web.file_info, - filename=os.path.basename(web.download_filename), - filesize=web.download_filesize, - filesize_human=web.common.human_readable_filesize(web.download_filesize), - is_zipped=web.is_zipped)) - else: # If download is allowed to continue, serve download page - r = make_response(render_template( - 'send.html', - file_info=web.file_info, - filename=os.path.basename(web.download_filename), - filesize=web.download_filesize, - filesize_human=web.common.human_readable_filesize(web.download_filesize), - is_zipped=web.is_zipped)) - return web.add_security_headers(r) + if self.web.slug: + r = make_response(render_template( + 'send.html', + slug=self.web.slug, + file_info=self.web.file_info, + filename=os.path.basename(self.web.download_filename), + filesize=self.web.download_filesize, + filesize_human=self.web.common.human_readable_filesize(self.web.download_filesize), + is_zipped=self.web.is_zipped)) + else: + # If download is allowed to continue, serve download page + r = make_response(render_template( + 'send.html', + file_info=self.web.file_info, + filename=os.path.basename(self.web.download_filename), + filesize=self.web.download_filesize, + filesize_human=self.web.common.human_readable_filesize(self.web.download_filesize), + is_zipped=self.web.is_zipped)) + return self.web.add_security_headers(r) - @web.app.route("//download") - def download(slug_candidate): - web.check_slug_candidate(slug_candidate) - return download_logic() + @self.web.app.route("//download") + def download(slug_candidate): + self.web.check_slug_candidate(slug_candidate) + return download_logic() - @web.app.route("/download") - def download_public(): - if not web.common.settings.get('public_mode'): - return web.error404() - return download_logic() + @self.web.app.route("/download") + def download_public(): + if not self.web.common.settings.get('public_mode'): + return self.web.error404() + return download_logic() - def download_logic(slug_candidate=''): - """ - Download the zip file. - """ - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - deny_download = not web.stay_open and web.download_in_progress - if deny_download: - r = make_response(render_template('denied.html')) - return web.add_security_headers(r) + def download_logic(slug_candidate=''): + """ + Download the zip file. + """ + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.web.stay_open and self.web.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.web.add_security_headers(r) - # Each download has a unique id - download_id = web.download_count - web.download_count += 1 + # Each download has a unique id + download_id = self.web.download_count + self.web.download_count += 1 - # Prepare some variables to use inside generate() function below - # which is outside of the request context - shutdown_func = request.environ.get('werkzeug.server.shutdown') - path = request.path + # Prepare some variables to use inside generate() function below + # which is outside of the request context + shutdown_func = request.environ.get('werkzeug.server.shutdown') + path = request.path - # Tell GUI the download started - web.add_request(web.REQUEST_STARTED, path, { - 'id': download_id} - ) + # Tell GUI the download started + self.web.add_request(self.web.REQUEST_STARTED, path, { + 'id': download_id} + ) - dirname = os.path.dirname(web.download_filename) - basename = os.path.basename(web.download_filename) + dirname = os.path.dirname(self.web.download_filename) + basename = os.path.basename(self.web.download_filename) - def generate(): - # The user hasn't canceled the download - web.client_cancel = False + def generate(): + # The user hasn't canceled the download + self.web.client_cancel = False - # Starting a new download - if not web.stay_open: - web.download_in_progress = True + # Starting a new download + if not self.web.stay_open: + self.web.download_in_progress = True - chunk_size = 102400 # 100kb + chunk_size = 102400 # 100kb - fp = open(web.download_filename, 'rb') - web.done = False - canceled = False - while not web.done: - # The user has canceled the download, so stop serving the file - if web.client_cancel: - web.add_request(web.REQUEST_CANCELED, path, { - 'id': download_id - }) - break - - chunk = fp.read(chunk_size) - if chunk == b'': - web.done = True - else: - try: - yield chunk - - # tell GUI the progress - downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / web.download_filesize) * 100 - - # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - if not web.gui_mode or web.common.platform == 'Linux' or web.common.platform == 'BSD': - sys.stdout.write( - "\r{0:s}, {1:.2f}% ".format(web.common.human_readable_filesize(downloaded_bytes), percent)) - sys.stdout.flush() - - web.add_request(web.REQUEST_PROGRESS, path, { - 'id': download_id, - 'bytes': downloaded_bytes - }) - web.done = False - except: - # looks like the download was canceled - web.done = True - canceled = True - - # tell the GUI the download has canceled - web.add_request(web.REQUEST_CANCELED, path, { + fp = open(self.web.download_filename, 'rb') + self.web.done = False + canceled = False + while not self.web.done: + # The user has canceled the download, so stop serving the file + if self.web.client_cancel: + self.web.add_request(self.web.REQUEST_CANCELED, path, { 'id': download_id }) + break - fp.close() + chunk = fp.read(chunk_size) + if chunk == b'': + self.web.done = True + else: + try: + yield chunk - if web.common.platform != 'Darwin': - sys.stdout.write("\n") + # tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / self.web.download_filesize) * 100 - # Download is finished - if not web.stay_open: - web.download_in_progress = False + # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) + if not self.web.is_gui or self.web.common.platform == 'Linux' or self.web.common.platform == 'BSD': + sys.stdout.write( + "\r{0:s}, {1:.2f}% ".format(self.web.common.human_readable_filesize(downloaded_bytes), percent)) + sys.stdout.flush() - # Close the server, if necessary - if not web.stay_open and not canceled: - print(strings._("closing_automatically")) - web.running = False - try: - if shutdown_func is None: - raise RuntimeError('Not running with the Werkzeug Server') - shutdown_func() - except: - pass + self.web.add_request(self.web.REQUEST_PROGRESS, path, { + 'id': download_id, + 'bytes': downloaded_bytes + }) + self.web.done = False + except: + # looks like the download was canceled + self.web.done = True + canceled = True - r = Response(generate()) - r.headers.set('Content-Length', web.download_filesize) - r.headers.set('Content-Disposition', 'attachment', filename=basename) - r = web.add_security_headers(r) - # guess content type - (content_type, _) = mimetypes.guess_type(basename, strict=False) - if content_type is not None: - r.headers.set('Content-Type', content_type) - return r + # tell the GUI the download has canceled + self.web.add_request(self.web.REQUEST_CANCELED, path, { + 'id': download_id + }) + + fp.close() + + if self.web.common.platform != 'Darwin': + sys.stdout.write("\n") + + # Download is finished + if not self.web.stay_open: + self.web.download_in_progress = False + + # Close the server, if necessary + if not self.web.stay_open and not canceled: + print(strings._("closing_automatically")) + self.web.running = False + try: + if shutdown_func is None: + raise RuntimeError('Not running with the Werkzeug Server') + shutdown_func() + except: + pass + + r = Response(generate()) + r.headers.set('Content-Length', self.web.download_filesize) + r.headers.set('Content-Disposition', 'attachment', filename=basename) + r = self.web.add_security_headers(r) + # guess content type + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set('Content-Type', content_type) + return r + + def set_file_info(self, filenames, processed_size_callback=None): + """ + Using the list of filenames being shared, fill in details that the web + page will need to display. This includes zipping up the file in order to + get the zip file's name and size. + """ + self.web.common.log("Web", "set_file_info") + self.web.cancel_compression = False + + # build file info list + self.web.file_info = {'files': [], 'dirs': []} + for filename in filenames: + info = { + 'filename': filename, + 'basename': os.path.basename(filename.rstrip('/')) + } + if os.path.isfile(filename): + info['size'] = os.path.getsize(filename) + info['size_human'] = self.web.common.human_readable_filesize(info['size']) + self.web.file_info['files'].append(info) + if os.path.isdir(filename): + info['size'] = self.web.common.dir_size(filename) + info['size_human'] = self.web.common.human_readable_filesize(info['size']) + self.web.file_info['dirs'].append(info) + self.web.file_info['files'] = sorted(self.web.file_info['files'], key=lambda k: k['basename']) + self.web.file_info['dirs'] = sorted(self.web.file_info['dirs'], key=lambda k: k['basename']) + + # Check if there's only 1 file and no folders + if len(self.web.file_info['files']) == 1 and len(self.web.file_info['dirs']) == 0: + self.web.is_zipped = False + self.web.download_filename = self.web.file_info['files'][0]['filename'] + self.web.download_filesize = self.web.file_info['files'][0]['size'] + else: + # Zip up the files and folders + self.web.zip_writer = ZipWriter(self.web.common, processed_size_callback=processed_size_callback) + self.web.download_filename = self.web.zip_writer.zip_filename + for info in self.web.file_info['files']: + self.web.zip_writer.add_file(info['filename']) + # Canceling early? + if self.web.cancel_compression: + self.web.zip_writer.close() + return False + + for info in self.web.file_info['dirs']: + if not self.web.zip_writer.add_dir(info['filename']): + return False + + self.web.zip_writer.close() + self.web.download_filesize = os.path.getsize(self.web.download_filename) + self.web.is_zipped = True + + return True class ZipWriter(object): diff --git a/onionshare/web/web.py b/onionshare/web/web.py index 0a6e6964..7959ae0f 100644 --- a/onionshare/web/web.py +++ b/onionshare/web/web.py @@ -13,8 +13,8 @@ from flask import Flask, request, render_template, abort, make_response, __versi from .. import strings -from .share_mode import share_routes, ZipWriter -from .receive_mode import receive_routes, ReceiveModeWSGIMiddleware, ReceiveModeTemporaryFile, ReceiveModeRequest +from .share_mode import ShareModeWeb +from .receive_mode import ReceiveModeWeb, ReceiveModeWSGIMiddleware, ReceiveModeTemporaryFile, ReceiveModeRequest # Stub out flask's show_server_banner function, to avoiding showing warnings that @@ -41,7 +41,7 @@ class Web(object): REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE = 9 REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE = 10 - def __init__(self, common, gui_mode, receive_mode=False): + def __init__(self, common, is_gui, mode='share'): self.common = common # The flask app @@ -55,11 +55,11 @@ class Web(object): self.debug_mode() # Are we running in GUI mode? - self.gui_mode = gui_mode + self.is_gui = is_gui # Are we using receive mode? - self.receive_mode = receive_mode - if self.receive_mode: + self.mode = mode + if self.mode == 'receive': # Use custom WSGI middleware, to modify environ self.app.wsgi_app = ReceiveModeWSGIMiddleware(self.app.wsgi_app, self) # Use a custom Request class to track upload progess @@ -115,14 +115,19 @@ class Web(object): # Keep track if the server is running self.running = False - # Define the ewb app routes - self.common_routes() - if self.receive_mode: - receive_routes(self) - else: - share_routes(self) + # Define the web app routes + self.define_common_routes() - def common_routes(self): + # Create the mode web object, which defines its own routes + self.share_mode = None + self.receive_mode = None + if self.mode == 'receive': + self.receive_mode = ReceiveModeWeb(self) + elif self.mode == 'share': + self.share_mode = ShareModeWeb(self) + + + def define_common_routes(self): """ Common web app routes between sending and receiving """ @@ -165,59 +170,6 @@ class Web(object): r.headers.set(header, value) return r - def set_file_info(self, filenames, processed_size_callback=None): - """ - Using the list of filenames being shared, fill in details that the web - page will need to display. This includes zipping up the file in order to - get the zip file's name and size. - """ - self.common.log("Web", "set_file_info") - self.cancel_compression = False - - # build file info list - self.file_info = {'files': [], 'dirs': []} - for filename in filenames: - info = { - 'filename': filename, - 'basename': os.path.basename(filename.rstrip('/')) - } - if os.path.isfile(filename): - info['size'] = os.path.getsize(filename) - info['size_human'] = self.common.human_readable_filesize(info['size']) - self.file_info['files'].append(info) - if os.path.isdir(filename): - info['size'] = self.common.dir_size(filename) - info['size_human'] = self.common.human_readable_filesize(info['size']) - self.file_info['dirs'].append(info) - self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) - self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) - - # Check if there's only 1 file and no folders - if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: - self.is_zipped = False - self.download_filename = self.file_info['files'][0]['filename'] - self.download_filesize = self.file_info['files'][0]['size'] - else: - # Zip up the files and folders - self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) - self.download_filename = self.zip_writer.zip_filename - for info in self.file_info['files']: - self.zip_writer.add_file(info['filename']) - # Canceling early? - if self.cancel_compression: - self.zip_writer.close() - return False - - for info in self.file_info['dirs']: - if not self.zip_writer.add_dir(info['filename']): - return False - - self.zip_writer.close() - self.download_filesize = os.path.getsize(self.download_filename) - self.is_zipped = True - - return True - def _safe_select_jinja_autoescape(self, filename): if filename is None: return True diff --git a/onionshare_gui/receive_mode/__init__.py b/onionshare_gui/receive_mode/__init__.py index 8712653b..5845b30a 100644 --- a/onionshare_gui/receive_mode/__init__.py +++ b/onionshare_gui/receive_mode/__init__.py @@ -34,7 +34,7 @@ class ReceiveMode(Mode): Custom initialization for ReceiveMode. """ # Create the Web object - self.web = Web(self.common, True, True) + self.web = Web(self.common, True, 'receive') # Server status self.server_status.set_mode('receive') diff --git a/onionshare_gui/share_mode/__init__.py b/onionshare_gui/share_mode/__init__.py index aec32305..d7ed74ed 100644 --- a/onionshare_gui/share_mode/__init__.py +++ b/onionshare_gui/share_mode/__init__.py @@ -43,7 +43,7 @@ class ShareMode(Mode): self.compress_thread = None # Create the Web object - self.web = Web(self.common, True, False) + self.web = Web(self.common, True, 'share') # File selection self.file_selection = FileSelection(self.common) diff --git a/onionshare_gui/share_mode/threads.py b/onionshare_gui/share_mode/threads.py index dc43bf0a..4fb40bd0 100644 --- a/onionshare_gui/share_mode/threads.py +++ b/onionshare_gui/share_mode/threads.py @@ -41,7 +41,7 @@ class CompressThread(QtCore.QThread): self.mode.common.log('CompressThread', 'run') try: - if self.mode.web.set_file_info(self.mode.filenames, processed_size_callback=self.set_processed_size): + if self.mode.web.share_mode.set_file_info(self.mode.filenames, processed_size_callback=self.set_processed_size): self.success.emit() else: # Cancelled diff --git a/test/test_onionshare_web.py b/test/test_onionshare_web.py index 2209a0fd..24a0e163 100644 --- a/test/test_onionshare_web.py +++ b/test/test_onionshare_web.py @@ -38,11 +38,11 @@ DEFAULT_ZW_FILENAME_REGEX = re.compile(r'^onionshare_[a-z2-7]{6}.zip$') RANDOM_STR_REGEX = re.compile(r'^[a-z2-7]+$') -def web_obj(common_obj, receive_mode, num_files=0): +def web_obj(common_obj, mode, num_files=0): """ Creates a Web object, in either share mode or receive mode, ready for testing """ common_obj.load_settings() - web = Web(common_obj, False, receive_mode) + web = Web(common_obj, False, mode) web.generate_slug() web.stay_open = True web.running = True @@ -50,14 +50,14 @@ def web_obj(common_obj, receive_mode, num_files=0): web.app.testing = True # Share mode - if not receive_mode: + if mode == 'share': # Add files files = [] for i in range(num_files): with tempfile.NamedTemporaryFile(delete=False) as tmp_file: tmp_file.write(b'*' * 1024) files.append(tmp_file.name) - web.set_file_info(files) + web.share_mode.set_file_info(files) # Receive mode else: pass @@ -67,8 +67,8 @@ def web_obj(common_obj, receive_mode, num_files=0): class TestWeb: def test_share_mode(self, common_obj): - web = web_obj(common_obj, False, 3) - assert web.receive_mode is False + web = web_obj(common_obj, 'share', 3) + assert web.mode is 'share' with web.app.test_client() as c: # Load 404 pages res = c.get('/') @@ -91,7 +91,7 @@ class TestWeb: assert res.mimetype == 'application/zip' def test_share_mode_close_after_first_download_on(self, common_obj, temp_file_1024): - web = web_obj(common_obj, False, 3) + web = web_obj(common_obj, 'share', 3) web.stay_open = False assert web.running == True @@ -106,7 +106,7 @@ class TestWeb: assert web.running == False def test_share_mode_close_after_first_download_off(self, common_obj, temp_file_1024): - web = web_obj(common_obj, False, 3) + web = web_obj(common_obj, 'share', 3) web.stay_open = True assert web.running == True @@ -120,8 +120,8 @@ class TestWeb: assert web.running == True def test_receive_mode(self, common_obj): - web = web_obj(common_obj, True) - assert web.receive_mode is True + web = web_obj(common_obj, 'receive') + assert web.mode is 'receive' with web.app.test_client() as c: # Load 404 pages @@ -139,7 +139,7 @@ class TestWeb: assert res.status_code == 200 def test_receive_mode_allow_receiver_shutdown_on(self, common_obj): - web = web_obj(common_obj, True) + web = web_obj(common_obj, 'receive') common_obj.settings.set('receive_allow_receiver_shutdown', True) @@ -154,7 +154,7 @@ class TestWeb: assert web.running == False def test_receive_mode_allow_receiver_shutdown_off(self, common_obj): - web = web_obj(common_obj, True) + web = web_obj(common_obj, 'receive') common_obj.settings.set('receive_allow_receiver_shutdown', False) @@ -167,9 +167,9 @@ class TestWeb: # Should redirect to index, and server should still be running assert res.status_code == 302 assert web.running == True - + def test_public_mode_on(self, common_obj): - web = web_obj(common_obj, True) + web = web_obj(common_obj, 'receive') common_obj.settings.set('public_mode', True) with web.app.test_client() as c: @@ -182,9 +182,9 @@ class TestWeb: res = c.get('/{}'.format(web.slug)) data2 = res.get_data() assert res.status_code == 404 - + def test_public_mode_off(self, common_obj): - web = web_obj(common_obj, True) + web = web_obj(common_obj, 'receive') common_obj.settings.set('public_mode', False) with web.app.test_client() as c: From 05a2ee0559ff968cfd87c125295c01c8b52ae6ca Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 11:19:36 -0700 Subject: [PATCH 09/15] Refactor the CLI main function to explicitly use 'share' or 'receive' mode --- onionshare/__init__.py | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 9c390fa8..64655f4a 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -65,13 +65,18 @@ def main(cwd=None): receive = bool(args.receive) config = args.config + if receive: + mode = 'receive' + else: + mode = 'share' + # Make sure filenames given if not using receiver mode - if not receive and len(filenames) == 0: + if mode == 'share' and len(filenames) == 0: print(strings._('no_filenames')) sys.exit() # Validate filenames - if not receive: + if mode == 'share': valid = True for filename in filenames: if not os.path.isfile(filename) and not os.path.isdir(filename): @@ -90,7 +95,7 @@ def main(cwd=None): common.debug = debug # Create the Web object - web = Web(common, False, receive) + web = Web(common, False, mode) # Start the Onion object onion = Onion(common) @@ -116,21 +121,22 @@ def main(cwd=None): print(e.args[0]) sys.exit() - # Prepare files to share - print(strings._("preparing_files")) - try: - web.share_mode.set_file_info(filenames) - if web.is_zipped: - app.cleanup_filenames.append(web.download_filename) - except OSError as e: - print(e.strerror) - sys.exit(1) + if mode == 'share': + # Prepare files to share + print(strings._("preparing_files")) + try: + web.share_mode.set_file_info(filenames) + if web.is_zipped: + app.cleanup_filenames.append(web.download_filename) + except OSError as e: + print(e.strerror) + sys.exit(1) - # Warn about sending large files over Tor - if web.download_filesize >= 157286400: # 150mb - print('') - print(strings._("large_filesize")) - print('') + # Warn about sending large files over Tor + if web.download_filesize >= 157286400: # 150mb + print('') + print(strings._("large_filesize")) + print('') # Start OnionShare http service in new thread t = threading.Thread(target=web.start, args=(app.port, stay_open, common.settings.get('public_mode'), common.settings.get('slug'))) @@ -158,7 +164,7 @@ def main(cwd=None): url = 'http://{0:s}/{1:s}'.format(app.onion_host, web.slug) print('') - if receive: + if mode == 'receive': print(strings._('receive_mode_downloads_dir').format(common.settings.get('downloads_dir'))) print('') print(strings._('receive_mode_warning')) From 4127aa4d7130c70bd82f7433d15d3d96aadb16ae Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 11:36:19 -0700 Subject: [PATCH 10/15] Move more mode-specific logic out of the Web class and into the approprate mode web classes --- onionshare/__init__.py | 17 ++-- onionshare/web/receive_mode.py | 7 +- onionshare/web/share_mode.py | 103 ++++++++++++++---------- onionshare/web/web.py | 28 +------ onionshare_gui/receive_mode/__init__.py | 2 +- onionshare_gui/share_mode/__init__.py | 10 +-- onionshare_gui/share_mode/threads.py | 4 +- 7 files changed, 87 insertions(+), 84 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 64655f4a..9e3fefdd 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -126,14 +126,14 @@ def main(cwd=None): print(strings._("preparing_files")) try: web.share_mode.set_file_info(filenames) - if web.is_zipped: - app.cleanup_filenames.append(web.download_filename) + if web.share_mode.is_zipped: + app.cleanup_filenames.append(web.share_mode.download_filename) except OSError as e: print(e.strerror) sys.exit(1) # Warn about sending large files over Tor - if web.download_filesize >= 157286400: # 150mb + if web.share_mode.download_filesize >= 157286400: # 150mb print('') print(strings._("large_filesize")) print('') @@ -193,11 +193,12 @@ def main(cwd=None): if app.shutdown_timeout > 0: # if the shutdown timer was set and has run out, stop the server if not app.shutdown_timer.is_alive(): - # If there were no attempts to download the share, or all downloads are done, we can stop - if web.download_count == 0 or web.done: - print(strings._("close_on_timeout")) - web.stop(app.port) - break + if mode == 'share': + # If there were no attempts to download the share, or all downloads are done, we can stop + if web.share_mode.download_count == 0 or web.done: + print(strings._("close_on_timeout")) + web.stop(app.port) + break # Allow KeyboardInterrupt exception to be handled with threads # https://stackoverflow.com/questions/3788208/python-threading-ignores-keyboardinterrupt-exception time.sleep(0.2) diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py index ab5f5f13..3784ebf8 100644 --- a/onionshare/web/receive_mode.py +++ b/onionshare/web/receive_mode.py @@ -14,6 +14,9 @@ class ReceiveModeWeb(object): """ def __init__(self, web): self.web = web + + self.upload_count = 0 + self.define_routes() def define_routes(self): @@ -243,8 +246,8 @@ class ReceiveModeRequest(Request): self.progress = {} # Create an upload_id, attach it to the request - self.upload_id = self.web.upload_count - self.web.upload_count += 1 + self.upload_id = self.upload_count + self.upload_count += 1 # Figure out the content length try: diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index c8a411bb..21f0d1e5 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -14,6 +14,25 @@ class ShareModeWeb(object): """ def __init__(self, web): self.web = web + + # Information about the file to be shared + self.file_info = [] + self.is_zipped = False + self.download_filename = None + self.download_filesize = None + self.zip_writer = None + + self.download_count = 0 + + # If "Stop After First Download" is checked (stay_open == False), only allow + # one download at a time. + self.download_in_progress = False + + # If the client closes the OnionShare window while a download is in progress, + # it should immediately stop serving the file. The client_cancel global is + # used to tell the download function that the client is canceling the download. + self.client_cancel = False + self.define_routes() def define_routes(self): @@ -39,7 +58,7 @@ class ShareModeWeb(object): # Deny new downloads if "Stop After First Download" is checked and there is # currently a download - deny_download = not self.web.stay_open and self.web.download_in_progress + deny_download = not self.web.stay_open and self.download_in_progress if deny_download: r = make_response(render_template('denied.html')) return self.web.add_security_headers(r) @@ -49,20 +68,20 @@ class ShareModeWeb(object): r = make_response(render_template( 'send.html', slug=self.web.slug, - file_info=self.web.file_info, - filename=os.path.basename(self.web.download_filename), - filesize=self.web.download_filesize, - filesize_human=self.web.common.human_readable_filesize(self.web.download_filesize), - is_zipped=self.web.is_zipped)) + file_info=self.file_info, + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.web.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) else: # If download is allowed to continue, serve download page r = make_response(render_template( 'send.html', - file_info=self.web.file_info, - filename=os.path.basename(self.web.download_filename), - filesize=self.web.download_filesize, - filesize_human=self.web.common.human_readable_filesize(self.web.download_filesize), - is_zipped=self.web.is_zipped)) + file_info=self.file_info, + filename=os.path.basename(self.download_filename), + filesize=self.download_filesize, + filesize_human=self.web.common.human_readable_filesize(self.download_filesize), + is_zipped=self.is_zipped)) return self.web.add_security_headers(r) @self.web.app.route("//download") @@ -82,14 +101,14 @@ class ShareModeWeb(object): """ # Deny new downloads if "Stop After First Download" is checked and there is # currently a download - deny_download = not self.web.stay_open and self.web.download_in_progress + deny_download = not self.web.stay_open and self.download_in_progress if deny_download: r = make_response(render_template('denied.html')) return self.web.add_security_headers(r) # Each download has a unique id - download_id = self.web.download_count - self.web.download_count += 1 + download_id = self.download_count + self.download_count += 1 # Prepare some variables to use inside generate() function below # which is outside of the request context @@ -101,25 +120,25 @@ class ShareModeWeb(object): 'id': download_id} ) - dirname = os.path.dirname(self.web.download_filename) - basename = os.path.basename(self.web.download_filename) + dirname = os.path.dirname(self.download_filename) + basename = os.path.basename(self.download_filename) def generate(): # The user hasn't canceled the download - self.web.client_cancel = False + self.client_cancel = False # Starting a new download if not self.web.stay_open: - self.web.download_in_progress = True + self.download_in_progress = True chunk_size = 102400 # 100kb - fp = open(self.web.download_filename, 'rb') + fp = open(self.download_filename, 'rb') self.web.done = False canceled = False while not self.web.done: # The user has canceled the download, so stop serving the file - if self.web.client_cancel: + if self.client_cancel: self.web.add_request(self.web.REQUEST_CANCELED, path, { 'id': download_id }) @@ -134,7 +153,7 @@ class ShareModeWeb(object): # tell GUI the progress downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / self.web.download_filesize) * 100 + percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) if not self.web.is_gui or self.web.common.platform == 'Linux' or self.web.common.platform == 'BSD': @@ -164,7 +183,7 @@ class ShareModeWeb(object): # Download is finished if not self.web.stay_open: - self.web.download_in_progress = False + self.download_in_progress = False # Close the server, if necessary if not self.web.stay_open and not canceled: @@ -178,7 +197,7 @@ class ShareModeWeb(object): pass r = Response(generate()) - r.headers.set('Content-Length', self.web.download_filesize) + r.headers.set('Content-Length', self.download_filesize) r.headers.set('Content-Disposition', 'attachment', filename=basename) r = self.web.add_security_headers(r) # guess content type @@ -197,7 +216,7 @@ class ShareModeWeb(object): self.web.cancel_compression = False # build file info list - self.web.file_info = {'files': [], 'dirs': []} + self.file_info = {'files': [], 'dirs': []} for filename in filenames: info = { 'filename': filename, @@ -206,37 +225,37 @@ class ShareModeWeb(object): if os.path.isfile(filename): info['size'] = os.path.getsize(filename) info['size_human'] = self.web.common.human_readable_filesize(info['size']) - self.web.file_info['files'].append(info) + self.file_info['files'].append(info) if os.path.isdir(filename): info['size'] = self.web.common.dir_size(filename) info['size_human'] = self.web.common.human_readable_filesize(info['size']) - self.web.file_info['dirs'].append(info) - self.web.file_info['files'] = sorted(self.web.file_info['files'], key=lambda k: k['basename']) - self.web.file_info['dirs'] = sorted(self.web.file_info['dirs'], key=lambda k: k['basename']) + self.file_info['dirs'].append(info) + self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) + self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) # Check if there's only 1 file and no folders - if len(self.web.file_info['files']) == 1 and len(self.web.file_info['dirs']) == 0: - self.web.is_zipped = False - self.web.download_filename = self.web.file_info['files'][0]['filename'] - self.web.download_filesize = self.web.file_info['files'][0]['size'] + if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: + self.is_zipped = False + self.download_filename = self.file_info['files'][0]['filename'] + self.download_filesize = self.file_info['files'][0]['size'] else: # Zip up the files and folders - self.web.zip_writer = ZipWriter(self.web.common, processed_size_callback=processed_size_callback) - self.web.download_filename = self.web.zip_writer.zip_filename - for info in self.web.file_info['files']: - self.web.zip_writer.add_file(info['filename']) + self.zip_writer = ZipWriter(self.web.common, processed_size_callback=processed_size_callback) + self.download_filename = self.zip_writer.zip_filename + for info in self.file_info['files']: + self.zip_writer.add_file(info['filename']) # Canceling early? if self.web.cancel_compression: - self.web.zip_writer.close() + self.zip_writer.close() return False - for info in self.web.file_info['dirs']: - if not self.web.zip_writer.add_dir(info['filename']): + for info in self.file_info['dirs']: + if not self.zip_writer.add_dir(info['filename']): return False - self.web.zip_writer.close() - self.web.download_filesize = os.path.getsize(self.web.download_filename) - self.web.is_zipped = True + self.zip_writer.close() + self.download_filesize = os.path.getsize(self.download_filename) + self.is_zipped = True return True diff --git a/onionshare/web/web.py b/onionshare/web/web.py index 7959ae0f..9046154a 100644 --- a/onionshare/web/web.py +++ b/onionshare/web/web.py @@ -73,13 +73,6 @@ class Web(object): # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc Flask.select_jinja_autoescape = self._safe_select_jinja_autoescape - # Information about the file - self.file_info = [] - self.is_zipped = False - self.download_filename = None - self.download_filesize = None - self.zip_writer = None - self.security_headers = [ ('Content-Security-Policy', 'default-src \'self\'; style-src \'self\'; script-src \'self\'; img-src \'self\' data:;'), ('X-Frame-Options', 'DENY'), @@ -90,25 +83,11 @@ class Web(object): ] self.q = queue.Queue() - self.slug = None - - self.download_count = 0 - self.upload_count = 0 - self.error404_count = 0 - # If "Stop After First Download" is checked (stay_open == False), only allow - # one download at a time. - self.download_in_progress = False - self.done = False - # If the client closes the OnionShare window while a download is in progress, - # it should immediately stop serving the file. The client_cancel global is - # used to tell the download function that the client is canceling the download. - self.client_cancel = False - # shutting down the server only works within the context of flask, so the easiest way to do it is over http self.shutdown_slug = self.common.random_string(16) @@ -254,9 +233,10 @@ class Web(object): Stop the flask web server by loading /shutdown. """ - # If the user cancels the download, let the download function know to stop - # serving the file - self.client_cancel = True + if self.mode == 'share': + # If the user cancels the download, let the download function know to stop + # serving the file + self.share_mode.client_cancel = True # To stop flask, load http://127.0.0.1://shutdown if self.running: diff --git a/onionshare_gui/receive_mode/__init__.py b/onionshare_gui/receive_mode/__init__.py index 5845b30a..590dec65 100644 --- a/onionshare_gui/receive_mode/__init__.py +++ b/onionshare_gui/receive_mode/__init__.py @@ -100,7 +100,7 @@ class ReceiveMode(Mode): Starting the server. """ # Reset web counters - self.web.upload_count = 0 + self.web.receive_mode.upload_count = 0 self.web.error404_count = 0 # Hide and reset the uploads if we have previously shared diff --git a/onionshare_gui/share_mode/__init__.py b/onionshare_gui/share_mode/__init__.py index d7ed74ed..52ec672e 100644 --- a/onionshare_gui/share_mode/__init__.py +++ b/onionshare_gui/share_mode/__init__.py @@ -125,7 +125,7 @@ class ShareMode(Mode): The shutdown timer expired, should we stop the server? Returns a bool """ # If there were no attempts to download the share, or all downloads are done, we can stop - if self.web.download_count == 0 or self.web.done: + if self.web.share_mode.download_count == 0 or self.web.done: self.server_status.stop_server() self.server_status_label.setText(strings._('close_on_timeout', True)) return True @@ -139,7 +139,7 @@ class ShareMode(Mode): Starting the server. """ # Reset web counters - self.web.download_count = 0 + self.web.share_mode.download_count = 0 self.web.error404_count = 0 # Hide and reset the downloads if we have previously shared @@ -177,7 +177,7 @@ class ShareMode(Mode): self._zip_progress_bar = None # Warn about sending large files over Tor - if self.web.download_filesize >= 157286400: # 150mb + if self.web.share_mode.download_filesize >= 157286400: # 150mb self.filesize_warning.setText(strings._("large_filesize", True)) self.filesize_warning.show() @@ -229,7 +229,7 @@ class ShareMode(Mode): """ Handle REQUEST_STARTED event. """ - self.downloads.add(event["data"]["id"], self.web.download_filesize) + self.downloads.add(event["data"]["id"], self.web.share_mode.download_filesize) self.downloads_in_progress += 1 self.update_downloads_in_progress() @@ -242,7 +242,7 @@ class ShareMode(Mode): self.downloads.update(event["data"]["id"], event["data"]["bytes"]) # Is the download complete? - if event["data"]["bytes"] == self.web.download_filesize: + if event["data"]["bytes"] == self.web.share_mode.download_filesize: self.system_tray.showMessage(strings._('systray_download_completed_title', True), strings._('systray_download_completed_message', True)) # Update the total 'completed downloads' info diff --git a/onionshare_gui/share_mode/threads.py b/onionshare_gui/share_mode/threads.py index 4fb40bd0..6e114d62 100644 --- a/onionshare_gui/share_mode/threads.py +++ b/onionshare_gui/share_mode/threads.py @@ -47,8 +47,8 @@ class CompressThread(QtCore.QThread): # Cancelled pass - if self.mode.web.is_zipped: - self.mode.app.cleanup_filenames.append(self.mode.web.download_filename) + if self.mode.web.share_mode.is_zipped: + self.mode.app.cleanup_filenames.append(self.mode.web.share_mode.download_filename) except OSError as e: self.error.emit(e.strerror) From 5003d44cfb32ef78cceb1e6cdd8a316b830c2c16 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 11:41:49 -0700 Subject: [PATCH 11/15] Pass common into ShareModeWeb and ReceiveModeWeb --- onionshare/web/receive_mode.py | 35 ++++++++++++++++++---------------- onionshare/web/share_mode.py | 29 +++++++++++++++------------- onionshare/web/web.py | 5 +++-- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py index 3784ebf8..c422d74e 100644 --- a/onionshare/web/receive_mode.py +++ b/onionshare/web/receive_mode.py @@ -12,7 +12,10 @@ class ReceiveModeWeb(object): """ All of the web logic for receive mode """ - def __init__(self, web): + def __init__(self, common, web): + self.common = common + self.common.log('ReceiveModeWeb', '__init__') + self.web = web self.upload_count = 0 @@ -26,7 +29,7 @@ class ReceiveModeWeb(object): def index_logic(): self.web.add_request(self.web.REQUEST_LOAD, request.path) - if self.web.common.settings.get('public_mode'): + if self.common.settings.get('public_mode'): upload_action = '/upload' close_action = '/close' else: @@ -37,7 +40,7 @@ class ReceiveModeWeb(object): 'receive.html', upload_action=upload_action, close_action=close_action, - receive_allow_receiver_shutdown=self.web.common.settings.get('receive_allow_receiver_shutdown'))) + receive_allow_receiver_shutdown=self.common.settings.get('receive_allow_receiver_shutdown'))) return self.web.add_security_headers(r) @self.web.app.route("/") @@ -47,7 +50,7 @@ class ReceiveModeWeb(object): @self.web.app.route("/") def index_public(): - if not self.web.common.settings.get('public_mode'): + if not self.common.settings.get('public_mode'): return self.web.error404() return index_logic() @@ -59,18 +62,18 @@ class ReceiveModeWeb(object): # Make sure downloads_dir exists valid = True try: - self.web.common.validate_downloads_dir() + self.common.validate_downloads_dir() except DownloadsDirErrorCannotCreate: self.web.add_request(self.web.REQUEST_ERROR_DOWNLOADS_DIR_CANNOT_CREATE, request.path) - print(strings._('error_cannot_create_downloads_dir').format(self.web.common.settings.get('downloads_dir'))) + print(strings._('error_cannot_create_downloads_dir').format(self.common.settings.get('downloads_dir'))) valid = False except DownloadsDirErrorNotWritable: self.web.add_request(self.web.REQUEST_ERROR_DOWNLOADS_DIR_NOT_WRITABLE, request.path) - print(strings._('error_downloads_dir_not_writable').format(self.web.common.settings.get('downloads_dir'))) + print(strings._('error_downloads_dir_not_writable').format(self.common.settings.get('downloads_dir'))) valid = False if not valid: flash('Error uploading, please inform the OnionShare user', 'error') - if self.web.common.settings.get('public_mode'): + if self.common.settings.get('public_mode'): return redirect('/') else: return redirect('/{}'.format(slug_candidate)) @@ -83,7 +86,7 @@ class ReceiveModeWeb(object): # Automatically rename the file, if a file of the same name already exists filename = secure_filename(f.filename) filenames.append(filename) - local_path = os.path.join(self.web.common.settings.get('downloads_dir'), filename) + local_path = os.path.join(self.common.settings.get('downloads_dir'), filename) if os.path.exists(local_path): if '.' in filename: # Add "-i", e.g. change "foo.txt" to "foo-2.txt" @@ -95,7 +98,7 @@ class ReceiveModeWeb(object): valid = False while not valid: new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) - local_path = os.path.join(self.web.common.settings.get('downloads_dir'), new_filename) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) if os.path.exists(local_path): i += 1 else: @@ -106,7 +109,7 @@ class ReceiveModeWeb(object): valid = False while not valid: new_filename = '{}-{}'.format(filename, i) - local_path = os.path.join(self.web.common.settings.get('downloads_dir'), new_filename) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) if os.path.exists(local_path): i += 1 else: @@ -121,7 +124,7 @@ class ReceiveModeWeb(object): 'new_filename': basename }) - self.web.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) + self.common.log('ReceiveModeWeb', 'define_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) print(strings._('receive_mode_received_file').format(local_path)) f.save(local_path) @@ -133,7 +136,7 @@ class ReceiveModeWeb(object): for filename in filenames: flash('Sent {}'.format(filename), 'info') - if self.web.common.settings.get('public_mode'): + if self.common.settings.get('public_mode'): return redirect('/') else: return redirect('/{}'.format(slug_candidate)) @@ -145,13 +148,13 @@ class ReceiveModeWeb(object): @self.web.app.route("/upload", methods=['POST']) def upload_public(): - if not self.web.common.settings.get('public_mode'): + if not self.common.settings.get('public_mode'): return self.web.error404() return upload_logic() def close_logic(slug_candidate=''): - if self.web.common.settings.get('receive_allow_receiver_shutdown'): + if self.common.settings.get('receive_allow_receiver_shutdown'): self.web.force_shutdown() r = make_response(render_template('closed.html')) self.web.add_request(self.web.REQUEST_CLOSE_SERVER, request.path) @@ -166,7 +169,7 @@ class ReceiveModeWeb(object): @self.web.app.route("/close", methods=['POST']) def close_public(): - if not self.web.common.settings.get('public_mode'): + if not self.common.settings.get('public_mode'): return self.web.error404() return close_logic() diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index 21f0d1e5..81e5a5b9 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -12,7 +12,10 @@ class ShareModeWeb(object): """ All of the web logic for share mode """ - def __init__(self, web): + def __init__(self, common, web): + self.common = common + self.common.log('ShareModeWeb', '__init__') + self.web = web # Information about the file to be shared @@ -46,7 +49,7 @@ class ShareModeWeb(object): @self.web.app.route("/") def index_public(): - if not self.web.common.settings.get('public_mode'): + if not self.common.settings.get('public_mode'): return self.web.error404() return index_logic() @@ -71,7 +74,7 @@ class ShareModeWeb(object): file_info=self.file_info, filename=os.path.basename(self.download_filename), filesize=self.download_filesize, - filesize_human=self.web.common.human_readable_filesize(self.download_filesize), + filesize_human=self.common.human_readable_filesize(self.download_filesize), is_zipped=self.is_zipped)) else: # If download is allowed to continue, serve download page @@ -80,7 +83,7 @@ class ShareModeWeb(object): file_info=self.file_info, filename=os.path.basename(self.download_filename), filesize=self.download_filesize, - filesize_human=self.web.common.human_readable_filesize(self.download_filesize), + filesize_human=self.common.human_readable_filesize(self.download_filesize), is_zipped=self.is_zipped)) return self.web.add_security_headers(r) @@ -91,7 +94,7 @@ class ShareModeWeb(object): @self.web.app.route("/download") def download_public(): - if not self.web.common.settings.get('public_mode'): + if not self.common.settings.get('public_mode'): return self.web.error404() return download_logic() @@ -156,9 +159,9 @@ class ShareModeWeb(object): percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - if not self.web.is_gui or self.web.common.platform == 'Linux' or self.web.common.platform == 'BSD': + 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.web.common.human_readable_filesize(downloaded_bytes), percent)) + "\r{0:s}, {1:.2f}% ".format(self.common.human_readable_filesize(downloaded_bytes), percent)) sys.stdout.flush() self.web.add_request(self.web.REQUEST_PROGRESS, path, { @@ -178,7 +181,7 @@ class ShareModeWeb(object): fp.close() - if self.web.common.platform != 'Darwin': + if self.common.platform != 'Darwin': sys.stdout.write("\n") # Download is finished @@ -212,7 +215,7 @@ class ShareModeWeb(object): page will need to display. This includes zipping up the file in order to get the zip file's name and size. """ - self.web.common.log("Web", "set_file_info") + self.common.log("ShareModeWeb", "set_file_info") self.web.cancel_compression = False # build file info list @@ -224,11 +227,11 @@ class ShareModeWeb(object): } if os.path.isfile(filename): info['size'] = os.path.getsize(filename) - info['size_human'] = self.web.common.human_readable_filesize(info['size']) + info['size_human'] = self.common.human_readable_filesize(info['size']) self.file_info['files'].append(info) if os.path.isdir(filename): - info['size'] = self.web.common.dir_size(filename) - info['size_human'] = self.web.common.human_readable_filesize(info['size']) + info['size'] = self.common.dir_size(filename) + info['size_human'] = self.common.human_readable_filesize(info['size']) self.file_info['dirs'].append(info) self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) @@ -240,7 +243,7 @@ class ShareModeWeb(object): self.download_filesize = self.file_info['files'][0]['size'] else: # Zip up the files and folders - self.zip_writer = ZipWriter(self.web.common, processed_size_callback=processed_size_callback) + self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) self.download_filename = self.zip_writer.zip_filename for info in self.file_info['files']: self.zip_writer.add_file(info['filename']) diff --git a/onionshare/web/web.py b/onionshare/web/web.py index 9046154a..52c4da16 100644 --- a/onionshare/web/web.py +++ b/onionshare/web/web.py @@ -43,6 +43,7 @@ class Web(object): def __init__(self, common, is_gui, mode='share'): self.common = common + self.common.log('Web', '__init__', 'is_gui={}, mode={}'.format(is_gui, mode)) # The flask app self.app = Flask(__name__, @@ -101,9 +102,9 @@ class Web(object): self.share_mode = None self.receive_mode = None if self.mode == 'receive': - self.receive_mode = ReceiveModeWeb(self) + self.receive_mode = ReceiveModeWeb(self.common, self) elif self.mode == 'share': - self.share_mode = ShareModeWeb(self) + self.share_mode = ShareModeWeb(self.common, self) def define_common_routes(self): From e941ce0fd7eeacd0bc3ef26c00f863d4c306abca Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 12:29:23 -0700 Subject: [PATCH 12/15] If only sharing one file, compress it with gzip, and serve it with gzip compression if the browser supports it --- onionshare/__init__.py | 3 +- onionshare/web/share_mode.py | 62 +++++++++++++++++++++++++--- onionshare_gui/share_mode/threads.py | 3 +- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 9e3fefdd..4d6d77d0 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -126,8 +126,7 @@ def main(cwd=None): print(strings._("preparing_files")) try: web.share_mode.set_file_info(filenames) - if web.share_mode.is_zipped: - app.cleanup_filenames.append(web.share_mode.download_filename) + app.cleanup_filenames += web.share_mode.cleanup_filenames except OSError as e: print(e.strerror) sys.exit(1) diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index 81e5a5b9..95bc8443 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -3,6 +3,7 @@ import sys import tempfile import zipfile import mimetypes +import gzip from flask import Response, request, render_template, make_response from .. import strings @@ -23,6 +24,7 @@ class ShareModeWeb(object): self.is_zipped = False self.download_filename = None self.download_filesize = None + self.gzip_filename = None self.zip_writer = None self.download_count = 0 @@ -118,12 +120,20 @@ class ShareModeWeb(object): shutdown_func = request.environ.get('werkzeug.server.shutdown') path = request.path + # If this is a zipped file, then serve as-is. If it's not zipped, then, + # if the http client supports gzip compression, gzip the file first + # and serve that + use_gzip = (not self.is_zipped) and ('gzip' in request.headers.get('Accept-Encoding', '').lower()) + if use_gzip: + file_to_download = self.gzip_filename + else: + file_to_download = self.download_filename + # Tell GUI the download started self.web.add_request(self.web.REQUEST_STARTED, path, { - 'id': download_id} - ) + 'id': download_id + }) - dirname = os.path.dirname(self.download_filename) basename = os.path.basename(self.download_filename) def generate(): @@ -136,7 +146,7 @@ class ShareModeWeb(object): chunk_size = 102400 # 100kb - fp = open(self.download_filename, 'rb') + fp = open(file_to_download, 'rb') self.web.done = False canceled = False while not self.web.done: @@ -200,7 +210,11 @@ class ShareModeWeb(object): pass r = Response(generate()) - r.headers.set('Content-Length', self.download_filesize) + if use_gzip: + r.headers.set('Content-Encoding', 'gzip') + r.headers.set('Content-Length', os.path.getsize(self.gzip_filename)) + else: + r.headers.set('Content-Length', self.download_filesize) r.headers.set('Content-Disposition', 'attachment', filename=basename) r = self.web.add_security_headers(r) # guess content type @@ -218,6 +232,8 @@ class ShareModeWeb(object): self.common.log("ShareModeWeb", "set_file_info") self.web.cancel_compression = False + self.cleanup_filenames = [] + # build file info list self.file_info = {'files': [], 'dirs': []} for filename in filenames: @@ -238,9 +254,18 @@ class ShareModeWeb(object): # Check if there's only 1 file and no folders if len(self.file_info['files']) == 1 and len(self.file_info['dirs']) == 0: - self.is_zipped = False self.download_filename = self.file_info['files'][0]['filename'] self.download_filesize = self.file_info['files'][0]['size'] + + # Compress the file with gzip now, so we don't have to do it on each request + self.gzip_filename = tempfile.mkstemp('wb+')[1] + self._gzip_compress(self.download_filename, self.gzip_filename, 6, processed_size_callback) + + # Make sure the gzip file gets cleaned up when onionshare stops + self.cleanup_filenames.append(self.gzip_filename) + + self.is_zipped = False + else: # Zip up the files and folders self.zip_writer = ZipWriter(self.common, processed_size_callback=processed_size_callback) @@ -258,10 +283,35 @@ class ShareModeWeb(object): self.zip_writer.close() self.download_filesize = os.path.getsize(self.download_filename) + + # Make sure the zip file gets cleaned up when onionshare stops + self.cleanup_filenames.append(self.zip_writer.zip_filename) + self.is_zipped = True return True + 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() + class ZipWriter(object): """ diff --git a/onionshare_gui/share_mode/threads.py b/onionshare_gui/share_mode/threads.py index 6e114d62..d6022746 100644 --- a/onionshare_gui/share_mode/threads.py +++ b/onionshare_gui/share_mode/threads.py @@ -47,8 +47,7 @@ class CompressThread(QtCore.QThread): # Cancelled pass - if self.mode.web.share_mode.is_zipped: - self.mode.app.cleanup_filenames.append(self.mode.web.share_mode.download_filename) + self.mode.app.cleanup_filenames += self.mode.web.share_mode.cleanup_filenames except OSError as e: self.error.emit(e.strerror) From fa20d7685b1c58242dba3fea897953fc4ad64946 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 16:22:37 -0700 Subject: [PATCH 13/15] Dynamically figure out the total size of the download based on the whether or not the client making the http request accepts gzip --- onionshare/web/share_mode.py | 24 +++++++++++++++++++----- onionshare_gui/share_mode/__init__.py | 7 ++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index 95bc8443..2024e732 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -25,6 +25,7 @@ class ShareModeWeb(object): self.download_filename = None self.download_filesize = None self.gzip_filename = None + self.gzip_filesize = None self.zip_writer = None self.download_count = 0 @@ -69,13 +70,18 @@ class ShareModeWeb(object): return self.web.add_security_headers(r) # If download is allowed to continue, serve download page + if self.should_use_gzip(): + filesize = self.gzip_filesize + else: + filesize = self.download_filesize + if self.web.slug: r = make_response(render_template( 'send.html', slug=self.web.slug, file_info=self.file_info, filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, + filesize=filesize, filesize_human=self.common.human_readable_filesize(self.download_filesize), is_zipped=self.is_zipped)) else: @@ -84,7 +90,7 @@ class ShareModeWeb(object): 'send.html', file_info=self.file_info, filename=os.path.basename(self.download_filename), - filesize=self.download_filesize, + filesize=filesize, filesize_human=self.common.human_readable_filesize(self.download_filesize), is_zipped=self.is_zipped)) return self.web.add_security_headers(r) @@ -123,7 +129,7 @@ class ShareModeWeb(object): # If this is a zipped file, then serve as-is. If it's not zipped, then, # if the http client supports gzip compression, gzip the file first # and serve that - use_gzip = (not self.is_zipped) and ('gzip' in request.headers.get('Accept-Encoding', '').lower()) + use_gzip = self.should_use_gzip() if use_gzip: file_to_download = self.gzip_filename else: @@ -131,7 +137,8 @@ class ShareModeWeb(object): # Tell GUI the download started self.web.add_request(self.web.REQUEST_STARTED, path, { - 'id': download_id + 'id': download_id, + 'use_gzip': use_gzip }) basename = os.path.basename(self.download_filename) @@ -212,7 +219,7 @@ class ShareModeWeb(object): r = Response(generate()) if use_gzip: r.headers.set('Content-Encoding', 'gzip') - r.headers.set('Content-Length', os.path.getsize(self.gzip_filename)) + r.headers.set('Content-Length', self.gzip_filesize) else: r.headers.set('Content-Length', self.download_filesize) r.headers.set('Content-Disposition', 'attachment', filename=basename) @@ -260,6 +267,7 @@ class ShareModeWeb(object): # Compress the file with gzip now, so we don't have to do it on each request self.gzip_filename = tempfile.mkstemp('wb+')[1] self._gzip_compress(self.download_filename, self.gzip_filename, 6, processed_size_callback) + self.gzip_filesize = os.path.getsize(self.gzip_filename) # Make sure the gzip file gets cleaned up when onionshare stops self.cleanup_filenames.append(self.gzip_filename) @@ -291,6 +299,12 @@ class ShareModeWeb(object): return True + 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 diff --git a/onionshare_gui/share_mode/__init__.py b/onionshare_gui/share_mode/__init__.py index 52ec672e..ac6a1373 100644 --- a/onionshare_gui/share_mode/__init__.py +++ b/onionshare_gui/share_mode/__init__.py @@ -229,7 +229,11 @@ class ShareMode(Mode): """ Handle REQUEST_STARTED event. """ - self.downloads.add(event["data"]["id"], self.web.share_mode.download_filesize) + if event["data"]["use_gzip"]: + filesize = self.web.share_mode.gzip_filesize + else: + filesize = self.web.share_mode.download_filesize + self.downloads.add(event["data"]["id"], filesize) self.downloads_in_progress += 1 self.update_downloads_in_progress() @@ -388,6 +392,7 @@ class ZipProgressBar(QtWidgets.QProgressBar): def update_processed_size(self, val): self._processed_size = val + if self.processed_size < self.total_files_size: self.setValue(int((self.processed_size * 100) / self.total_files_size)) elif self.total_files_size != 0: From 711473f47eefc93ae771dfbe644e2cbda51712e5 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 16:24:22 -0700 Subject: [PATCH 14/15] Include onionshare.web module in setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index a36fecab..94213f7c 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,7 @@ setup( url=url, license=license, keywords=keywords, packages=[ 'onionshare', + 'onionshare.web', 'onionshare_gui', 'onionshare_gui.share_mode', 'onionshare_gui.receive_mode' From b96b36122dd8d97141fd20417ec60668bb149586 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Fri, 21 Sep 2018 17:08:11 -0700 Subject: [PATCH 15/15] Actually tell the GUI the progess --- onionshare/web/share_mode.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py index 2024e732..d4d6aed7 100644 --- a/onionshare/web/share_mode.py +++ b/onionshare/web/share_mode.py @@ -132,8 +132,10 @@ class ShareModeWeb(object): use_gzip = self.should_use_gzip() if use_gzip: file_to_download = self.gzip_filename + filesize = self.gzip_filesize else: file_to_download = self.download_filename + filesize = self.download_filesize # Tell GUI the download started self.web.add_request(self.web.REQUEST_STARTED, path, { @@ -173,7 +175,7 @@ class ShareModeWeb(object): # tell GUI the progress downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / self.download_filesize) * 100 + percent = (1.0 * downloaded_bytes / filesize) * 100 # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) if not self.web.is_gui or self.common.platform == 'Linux' or self.common.platform == 'BSD': @@ -219,9 +221,7 @@ class ShareModeWeb(object): r = Response(generate()) if use_gzip: r.headers.set('Content-Encoding', 'gzip') - r.headers.set('Content-Length', self.gzip_filesize) - else: - r.headers.set('Content-Length', self.download_filesize) + r.headers.set('Content-Length', filesize) r.headers.set('Content-Disposition', 'attachment', filename=basename) r = self.web.add_security_headers(r) # guess content type