summaryrefslogtreecommitdiff
path: root/onionshare
diff options
context:
space:
mode:
authorMiguel Jacq <mig@mig5.net>2018-10-01 16:42:54 +1000
committerMiguel Jacq <mig@mig5.net>2018-10-01 16:42:54 +1000
commitf653e8cc04f09427afb219a82dbf7cf33090d45a (patch)
tree62c5f1c0c88b0083e6c8d3bed3e29e026c660c19 /onionshare
parent997e2f87ee003616388b96e65471500490a773ba (diff)
parent2ffcdbb1083dece7664792f7bef9dbf2245e549e (diff)
downloadonionshare-f653e8cc04f09427afb219a82dbf7cf33090d45a.tar.gz
onionshare-f653e8cc04f09427afb219a82dbf7cf33090d45a.zip
Merge develop in and fix upload/timer functionality so that it works as described. Still needs fixing to not throw a connection error to the lucky last uploader after their upload completes and server stops due to expiry having passed
Diffstat (limited to 'onionshare')
-rw-r--r--onionshare/__init__.py59
-rw-r--r--onionshare/common.py2
-rw-r--r--onionshare/onion.py38
-rw-r--r--onionshare/onionshare.py1
-rw-r--r--onionshare/web.py842
-rw-r--r--onionshare/web/__init__.py21
-rw-r--r--onionshare/web/receive_mode.py323
-rw-r--r--onionshare/web/share_mode.py384
-rw-r--r--onionshare/web/web.py260
9 files changed, 1044 insertions, 886 deletions
diff --git a/onionshare/__init__.py b/onionshare/__init__.py
index becca93f..715c5571 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:
- print(strings._('no_filenames'))
+ if mode == 'share' and len(filenames) == 0:
+ parser.print_help()
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)
@@ -111,21 +116,26 @@ def main(cwd=None):
except KeyboardInterrupt:
print("")
sys.exit()
+ except (TorTooOld, TorErrorProtocolError) as e:
+ print("")
+ print(e.args[0])
+ sys.exit()
- # Prepare files to share
- print(strings._("preparing_files"))
- try:
- web.set_file_info(filenames)
- app.cleanup_filenames.append(web.zip_filename)
- except OSError as e:
- print(e.strerror)
- sys.exit(1)
-
- # Warn about sending large files over Tor
- if web.zip_filesize >= 157286400: # 150mb
- print('')
- print(strings._("large_filesize"))
- print('')
+ if mode == 'share':
+ # Prepare files to share
+ print(strings._("preparing_files"))
+ try:
+ web.share_mode.set_file_info(filenames)
+ app.cleanup_filenames += web.share_mode.cleanup_filenames
+ except OSError as e:
+ print(e.strerror)
+ sys.exit(1)
+
+ # Warn about sending large files over Tor
+ if web.share_mode.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')))
@@ -153,7 +163,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'))
@@ -182,11 +192,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/common.py b/onionshare/common.py
index 0ce411e8..28b282c2 100644
--- a/onionshare/common.py
+++ b/onionshare/common.py
@@ -433,7 +433,7 @@ class Common(object):
tmpsock.bind(("127.0.0.1", random.randint(min_port, max_port)))
break
except OSError as e:
- raise OSError(e)
+ pass
_, port = tmpsock.getsockname()
return port
diff --git a/onionshare/onion.py b/onionshare/onion.py
index a10dc53c..c45ae72e 100644
--- a/onionshare/onion.py
+++ b/onionshare/onion.py
@@ -402,6 +402,8 @@ class Onion(object):
# ephemeral stealth onion services are not supported
self.supports_stealth = False
+ # Does this version of Tor support next-gen ('v3') onions?
+ self.supports_next_gen_onions = self.tor_version > Version('0.3.3.1')
def is_authenticated(self):
"""
@@ -427,7 +429,6 @@ class Onion(object):
raise TorTooOld(strings._('error_stealth_not_supported'))
print(strings._("config_onion_service").format(int(port)))
- print(strings._('using_ephemeral'))
if self.stealth:
if self.settings.get('hidservauth_string'):
@@ -443,31 +444,29 @@ class Onion(object):
# is the key a v2 key?
if onionkey.is_v2_key(key_content):
key_type = "RSA1024"
- # The below section is commented out because re-publishing
- # a pre-prepared v3 private key is currently unstable in Tor.
- # This is fixed upstream but won't reach stable until 0.3.5
- # (expected in December 2018)
- # See https://trac.torproject.org/projects/tor/ticket/25552
- # Until then, we will deliberately not work with 'persistent'
- # v3 onions, which should not be possible via the GUI settings
- # anyway.
- # Our ticket: https://github.com/micahflee/onionshare/issues/677
- #
- # Assume it was a v3 key
- # key_type = "ED25519-V3"
+ # The below section is commented out because re-publishing
+ # a pre-prepared v3 private key is currently unstable in Tor.
+ # This is fixed upstream but won't reach stable until 0.3.5
+ # (expected in December 2018)
+ # See https://trac.torproject.org/projects/tor/ticket/25552
+ # Until then, we will deliberately not work with 'persistent'
+ # v3 onions, which should not be possible via the GUI settings
+ # anyway.
+ # Our ticket: https://github.com/micahflee/onionshare/issues/677
+ #
+ # Assume it was a v3 key
+ # key_type = "ED25519-V3"
else:
raise TorErrorProtocolError(strings._('error_invalid_private_key'))
- self.common.log('Onion', 'Starting a hidden service with a saved private key')
else:
# Work out if we can support v3 onion services, which are preferred
- if Version(self.tor_version) >= Version('0.3.2.9') and not self.settings.get('use_legacy_v2_onions'):
+ if Version(self.tor_version) >= Version('0.3.3.1') and not self.settings.get('use_legacy_v2_onions'):
key_type = "ED25519-V3"
key_content = onionkey.generate_v3_private_key()[0]
else:
# fall back to v2 onion services
key_type = "RSA1024"
key_content = onionkey.generate_v2_private_key()[0]
- self.common.log('Onion', 'Starting a hidden service with a new private key')
# v3 onions don't yet support basic auth. Our ticket:
# https://github.com/micahflee/onionshare/issues/697
@@ -475,6 +474,7 @@ class Onion(object):
basic_auth = None
self.stealth = False
+ self.common.log('Onion', 'start_onion_service', 'key_type={}'.format(key_type))
try:
if basic_auth != None:
res = self.c.create_ephemeral_hidden_service({ 80: port }, await_publication=True, basic_auth=basic_auth, key_type=key_type, key_content=key_content)
@@ -482,8 +482,8 @@ class Onion(object):
# if the stem interface is older than 1.5.0, basic_auth isn't a valid keyword arg
res = self.c.create_ephemeral_hidden_service({ 80: port }, await_publication=True, key_type=key_type, key_content=key_content)
- except ProtocolError:
- raise TorErrorProtocolError(strings._('error_tor_protocol_error'))
+ except ProtocolError as e:
+ raise TorErrorProtocolError(strings._('error_tor_protocol_error').format(e.args[0]))
self.service_id = res.service_id
onion_host = self.service_id + '.onion'
@@ -514,7 +514,7 @@ class Onion(object):
self.settings.save()
return onion_host
else:
- raise TorErrorProtocolError(strings._('error_tor_protocol_error'))
+ raise TorErrorProtocolError(strings._('error_tor_protocol_error_unknown'))
def cleanup(self, stop_tor=True):
"""
diff --git a/onionshare/onionshare.py b/onionshare/onionshare.py
index b710fa3c..32e56ba0 100644
--- a/onionshare/onionshare.py
+++ b/onionshare/onionshare.py
@@ -21,6 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import os, shutil
from . import common, strings
+from .onion import TorTooOld, TorErrorProtocolError
from .common import ShutdownTimer
class OnionShare(object):
diff --git a/onionshare/web.py b/onionshare/web.py
deleted file mode 100644
index f52d9dd6..00000000
--- a/onionshare/web.py
+++ /dev/null
@@ -1,842 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-OnionShare | https://onionshare.org/
-
-Copyright (C) 2014-2018 Micah Lee <micah@micahflee.com>
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program. If not, see <http://www.gnu.org/licenses/>.
-"""
-
-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.zip_filename = None
- self.zip_filesize = 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
- self.can_upload = True
- self.uploads_in_progress = []
-
- # 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("/<slug_candidate>")
- 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.zip_filename),
- filesize=self.zip_filesize,
- filesize_human=self.common.human_readable_filesize(self.zip_filesize)))
- 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)))
- return self.add_security_headers(r)
-
- @self.app.route("/<slug_candidate>/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.zip_filename)
- basename = os.path.basename(self.zip_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.zip_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.zip_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.zip_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))
- return self.add_security_headers(r)
-
- @self.app.route("/<slug_candidate>")
- def index(slug_candidate):
- self.check_slug_candidate(slug_candidate)
- if not self.can_upload:
- return self.error403()
- return index_logic()
-
- @self.app.route("/")
- def index_public():
- if not self.can_upload:
- return self.error403()
- 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("/<slug_candidate>/upload", methods=['POST'])
- def upload(slug_candidate):
- if not self.can_upload:
- return self.error403()
- 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()
- if not self.can_upload:
- return self.error403()
- return upload_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("/<slug_candidate>/shutdown")
- def shutdown(slug_candidate):
- """
- Stop the flask web server, from the context of an http request.
- """
- self.check_slug_candidate(slug_candidate, self.shutdown_slug)
- 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 error403(self):
- self.add_request(Web.REQUEST_OTHER, request.path)
-
- r = make_response(render_template('403.html'), 403)
- 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.
- """
- # 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'])
-
- # zip up the files and folders
- z = ZipWriter(self.common, processed_size_callback=processed_size_callback)
- for info in self.file_info['files']:
- z.add_file(info['filename'])
- for info in self.file_info['dirs']:
- z.add_dir(info['filename'])
- z.close()
- self.zip_filename = z.zip_filename
- self.zip_filesize = os.path.getsize(self.zip_filename)
-
- 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, slug_compare=None):
- self.common.log('Web', 'check_slug_candidate: slug_candidate={}, slug_compare={}'.format(slug_candidate, slug_compare))
- if self.common.settings.get('public_mode'):
- abort(404)
- else:
- if not slug_compare:
- slug_compare = self.slug
- if not hmac.compare_digest(slug_compare, 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={}, persistent_slug={}'.format(port, stay_open, 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:<port>/<shutdown_slug>/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
-
- 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:
- 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)
-
- 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' and self.web.can_upload:
- 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))
- ))
- # append to self.uploads_in_progress
- self.web.uploads_in_progress.append(self.upload_id)
-
- # 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
- })
-
- # remove from self.uploads_in_progress
- self.web.uploads_in_progress.remove(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
diff --git a/onionshare/web/__init__.py b/onionshare/web/__init__.py
new file mode 100644
index 00000000..d45b4983
--- /dev/null
+++ b/onionshare/web/__init__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+"""
+OnionShare | https://onionshare.org/
+
+Copyright (C) 2014-2018 Micah Lee <micah@micahflee.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+
+from .web import Web
diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py
new file mode 100644
index 00000000..73762573
--- /dev/null
+++ b/onionshare/web/receive_mode.py
@@ -0,0 +1,323 @@
+import os
+import tempfile
+from datetime import datetime
+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
+
+
+class ReceiveModeWeb(object):
+ """
+ All of the web logic for receive mode
+ """
+ def __init__(self, common, web):
+ self.common = common
+ self.common.log('ReceiveModeWeb', '__init__')
+
+ self.web = web
+
+ self.can_upload = True
+ self.upload_count = 0
+ self.uploads_in_progress = []
+
+ self.define_routes()
+
+ def define_routes(self):
+ """
+ The web app routes for receiving files
+ """
+ def index_logic():
+ self.web.add_request(self.web.REQUEST_LOAD, request.path)
+
+ if self.common.settings.get('public_mode'):
+ upload_action = '/upload'
+ else:
+ upload_action = '/{}/upload'.format(self.web.slug)
+
+ r = make_response(render_template(
+ 'receive.html',
+ upload_action=upload_action))
+ return self.web.add_security_headers(r)
+
+ @self.web.app.route("/<slug_candidate>")
+ def index(slug_candidate):
+ if not self.can_upload:
+ return self.web.error403()
+ self.web.check_slug_candidate(slug_candidate)
+ return index_logic()
+
+ @self.web.app.route("/")
+ def index_public():
+ if not self.can_upload:
+ return self.web.error403()
+ if not self.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.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.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.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.web.add_request(self.web.REQUEST_UPLOAD_FILE_RENAMED, request.path, {
+ 'id': request.upload_id,
+ 'old_filename': f.filename,
+ 'new_filename': basename
+ })
+
+ 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)
+
+ # 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.web.app.route("/<slug_candidate>/upload", methods=['POST'])
+ def upload(slug_candidate):
+ if not self.can_upload:
+ return self.web.error403()
+ self.web.check_slug_candidate(slug_candidate)
+ return upload_logic(slug_candidate)
+
+ @self.web.app.route("/upload", methods=['POST'])
+ def upload_public():
+ if not self.can_upload:
+ return self.web.error403()
+ if not self.common.settings.get('public_mode'):
+ return self.web.error404()
+ return upload_logic()
+
+
+
+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
+ self.upload_rejected = 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.receive_mode.upload_count
+
+ if self.web.receive_mode.can_upload:
+ self.web.receive_mode.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))
+ ))
+
+ # append to self.uploads_in_progress
+ self.web.receive_mode.uploads_in_progress.append(self.upload_id)
+
+ # 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
+ else:
+ self.upload_rejected = True
+
+ 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:
+ if not self.upload_rejected:
+ # Inform the GUI that the upload has finished
+ self.web.add_request(self.web.REQUEST_UPLOAD_FINISHED, self.path, {
+ 'id': self.upload_id
+ })
+
+ # remove from self.uploads_in_progress
+ self.web.receive_mode.uploads_in_progress.remove(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..a57d0a39
--- /dev/null
+++ b/onionshare/web/share_mode.py
@@ -0,0 +1,384 @@
+import os
+import sys
+import tempfile
+import zipfile
+import mimetypes
+import gzip
+from flask import Response, request, render_template, make_response
+
+from .. import strings
+
+
+class ShareModeWeb(object):
+ """
+ All of the web logic for share mode
+ """
+ def __init__(self, common, web):
+ self.common = common
+ self.common.log('ShareModeWeb', '__init__')
+
+ 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.gzip_filename = None
+ self.gzip_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):
+ """
+ The web app routes for sharing files
+ """
+ @self.web.app.route("/<slug_candidate>")
+ def index(slug_candidate):
+ self.web.check_slug_candidate(slug_candidate)
+ return index_logic()
+
+ @self.web.app.route("/")
+ def index_public():
+ if not self.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.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 self.should_use_gzip():
+ self.filesize = self.gzip_filesize
+ else:
+ self.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.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.filesize,
+ filesize_human=self.common.human_readable_filesize(self.download_filesize),
+ is_zipped=self.is_zipped))
+ return self.web.add_security_headers(r)
+
+ @self.web.app.route("/<slug_candidate>/download")
+ def download(slug_candidate):
+ self.web.check_slug_candidate(slug_candidate)
+ return download_logic()
+
+ @self.web.app.route("/download")
+ def download_public():
+ if not self.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 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.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
+
+ # 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 = self.should_use_gzip()
+ if use_gzip:
+ file_to_download = self.gzip_filename
+ self.filesize = self.gzip_filesize
+ else:
+ file_to_download = self.download_filename
+ self.filesize = self.download_filesize
+
+ # Tell GUI the download started
+ self.web.add_request(self.web.REQUEST_STARTED, path, {
+ 'id': download_id,
+ 'use_gzip': use_gzip
+ })
+
+ 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.web.stay_open:
+ self.download_in_progress = True
+
+ chunk_size = 102400 # 100kb
+
+ fp = open(file_to_download, '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.client_cancel:
+ self.web.add_request(self.web.REQUEST_CANCELED, path, {
+ 'id': download_id
+ })
+ break
+
+ chunk = fp.read(chunk_size)
+ if chunk == b'':
+ self.web.done = True
+ else:
+ try:
+ yield chunk
+
+ # tell GUI the progress
+ downloaded_bytes = fp.tell()
+ percent = (1.0 * downloaded_bytes / self.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':
+ sys.stdout.write(
+ "\r{0:s}, {1:.2f}% ".format(self.common.human_readable_filesize(downloaded_bytes), percent))
+ sys.stdout.flush()
+
+ self.web.add_request(self.web.REQUEST_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
+
+ # tell the GUI the download has canceled
+ self.web.add_request(self.web.REQUEST_CANCELED, path, {
+ 'id': download_id
+ })
+
+ fp.close()
+
+ if self.common.platform != 'Darwin':
+ sys.stdout.write("\n")
+
+ # Download is finished
+ if not self.web.stay_open:
+ self.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())
+ if use_gzip:
+ r.headers.set('Content-Encoding', 'gzip')
+ r.headers.set('Content-Length', self.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.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:
+ 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.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)
+ 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)
+
+ self.is_zipped = False
+
+ 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.web.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)
+
+ # 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 should_use_gzip(self):
+ """
+ Should we use gzip for this browser?
+ """
+ return (not self.is_zipped) and ('gzip' in request.headers.get('Accept-Encoding', '').lower())
+
+ def _gzip_compress(self, input_filename, output_filename, level, processed_size_callback=None):
+ """
+ Compress a file with gzip, without loading the whole thing into memory
+ Thanks: https://stackoverflow.com/questions/27035296/python-how-to-gzip-a-large-text-file-without-memoryerror
+ """
+ bytes_processed = 0
+ blocksize = 1 << 16 # 64kB
+ with open(input_filename, 'rb') as input_file:
+ output_file = gzip.open(output_filename, 'wb', level)
+ while True:
+ if processed_size_callback is not None:
+ processed_size_callback(bytes_processed)
+
+ block = input_file.read(blocksize)
+ if len(block) == 0:
+ break
+ output_file.write(block)
+ bytes_processed += blocksize
+
+ output_file.close()
+
+
+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..2885e87f
--- /dev/null
+++ b/onionshare/web/web.py
@@ -0,0 +1,260 @@
+import hmac
+import logging
+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, request, render_template, abort, make_response, __version__ as flask_version
+
+from .. import strings
+
+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
+# 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, 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__,
+ 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.is_gui = is_gui
+
+ # Are we using 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
+ self.app.request_class = ReceiveModeRequest
+
+ self.can_upload = True
+
+ # 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
+
+ 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.error404_count = 0
+
+ self.done = 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 web app routes
+ self.define_common_routes()
+
+ # 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.common, self)
+ elif self.mode == 'share':
+ self.share_mode = ShareModeWeb(self.common, self)
+
+
+ def define_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("/<slug_candidate>/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 error403(self):
+ self.add_request(Web.REQUEST_OTHER, request.path)
+
+ r = make_response(render_template('403.html'), 403)
+ 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 _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 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:<port>/<shutdown_slug>/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