summaryrefslogtreecommitdiff
path: root/onionshare/web
diff options
context:
space:
mode:
Diffstat (limited to 'onionshare/web')
-rw-r--r--onionshare/web/receive_mode.py365
-rw-r--r--onionshare/web/send_base_mode.py305
-rw-r--r--onionshare/web/share_mode.py324
-rw-r--r--onionshare/web/web.py343
-rw-r--r--onionshare/web/website_mode.py104
5 files changed, 1011 insertions, 430 deletions
diff --git a/onionshare/web/receive_mode.py b/onionshare/web/receive_mode.py
index bc805445..90f000b9 100644
--- a/onionshare/web/receive_mode.py
+++ b/onionshare/web/receive_mode.py
@@ -8,97 +8,107 @@ from werkzeug.utils import secure_filename
from .. import strings
-class ReceiveModeWeb(object):
+class ReceiveModeWeb:
"""
All of the web logic for receive mode
"""
+
def __init__(self, common, web):
self.common = common
- self.common.log('ReceiveModeWeb', '__init__')
+ self.common.log("ReceiveModeWeb", "__init__")
self.web = web
self.can_upload = True
- self.upload_count = 0
self.uploads_in_progress = []
+ # This tracks the history id
+ self.cur_history_id = 0
+
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 index():
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ self.web.add_request(
+ self.web.REQUEST_INDIVIDUAL_FILE_STARTED,
+ "{}".format(request.path),
+ {"id": history_id, "status_code": 200},
+ )
+ self.web.add_request(self.web.REQUEST_LOAD, request.path)
+ r = make_response(
+ render_template(
+ "receive.html", static_url_path=self.web.static_url_path
+ )
+ )
+ return self.web.add_security_headers(r)
- def upload_logic(slug_candidate='', ajax=False):
+ @self.web.app.route("/upload", methods=["POST"])
+ def upload(ajax=False):
"""
Handle the upload files POST request, though at this point, the files have
already been uploaded and saved to their correct locations.
"""
- files = request.files.getlist('file[]')
+ files = request.files.getlist("file[]")
filenames = []
for f in files:
- if f.filename != '':
+ if f.filename != "":
filename = secure_filename(f.filename)
filenames.append(filename)
local_path = os.path.join(request.receive_mode_dir, filename)
basename = os.path.basename(local_path)
# Tell the GUI the receive mode directory for this file
- self.web.add_request(self.web.REQUEST_UPLOAD_SET_DIR, request.path, {
- 'id': request.upload_id,
- 'filename': basename,
- 'dir': request.receive_mode_dir
- })
-
- self.common.log('ReceiveModeWeb', 'define_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path))
- print('\n' + "Received: {}".format(local_path))
+ self.web.add_request(
+ self.web.REQUEST_UPLOAD_SET_DIR,
+ request.path,
+ {
+ "id": request.history_id,
+ "filename": basename,
+ "dir": request.receive_mode_dir,
+ },
+ )
+
+ self.common.log(
+ "ReceiveModeWeb",
+ "define_routes",
+ "/upload, uploaded {}, saving to {}".format(
+ f.filename, local_path
+ ),
+ )
+ print("\n" + "Received: {}".format(local_path))
if request.upload_error:
- self.common.log('ReceiveModeWeb', 'define_routes', '/upload, there was an upload error')
-
- self.web.add_request(self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE, request.path, {
- "receive_mode_dir": request.receive_mode_dir
- })
- print("Could not create OnionShare data folder: {}".format(request.receive_mode_dir))
-
- msg = 'Error uploading, please inform the OnionShare user'
+ self.common.log(
+ "ReceiveModeWeb",
+ "define_routes",
+ "/upload, there was an upload error",
+ )
+
+ self.web.add_request(
+ self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE,
+ request.path,
+ {"receive_mode_dir": request.receive_mode_dir},
+ )
+ print(
+ "Could not create OnionShare data folder: {}".format(
+ request.receive_mode_dir
+ )
+ )
+
+ msg = "Error uploading, please inform the OnionShare user"
if ajax:
return json.dumps({"error_flashes": [msg]})
else:
- flash(msg, 'error')
-
- if self.common.settings.get('public_mode'):
- return redirect('/')
- else:
- return redirect('/{}'.format(slug_candidate))
+ flash(msg, "error")
+ return redirect("/")
# Note that flash strings are in English, and not translated, on purpose,
# to avoid leaking the locale of the OnionShare user
@@ -106,67 +116,49 @@ class ReceiveModeWeb(object):
info_flashes = []
if len(filenames) == 0:
- msg = 'No files uploaded'
+ msg = "No files uploaded"
if ajax:
info_flashes.append(msg)
else:
- flash(msg, 'info')
+ flash(msg, "info")
else:
- msg = 'Sent '
+ msg = "Sent "
for filename in filenames:
- msg += '{}, '.format(filename)
- msg = msg.rstrip(', ')
+ msg += "{}, ".format(filename)
+ msg = msg.rstrip(", ")
if ajax:
info_flashes.append(msg)
else:
- flash(msg, 'info')
+ flash(msg, "info")
if self.can_upload:
if ajax:
return json.dumps({"info_flashes": info_flashes})
else:
- if self.common.settings.get('public_mode'):
- path = '/'
- else:
- path = '/{}'.format(slug_candidate)
- return redirect('{}'.format(path))
+ return redirect("/")
else:
if ajax:
- return json.dumps({"new_body": render_template('thankyou.html')})
+ return json.dumps(
+ {
+ "new_body": render_template(
+ "thankyou.html",
+ static_url_path=self.web.static_url_path,
+ )
+ }
+ )
else:
# It was the last upload and the timer ran out
- r = make_response(render_template('thankyou.html'))
+ r = make_response(
+ render_template("thankyou.html"),
+ static_url_path=self.web.static_url_path,
+ )
return self.web.add_security_headers(r)
- @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()
-
- @self.web.app.route("/<slug_candidate>/upload-ajax", methods=['POST'])
- def upload_ajax(slug_candidate):
- if not self.can_upload:
- return self.web.error403()
- self.web.check_slug_candidate(slug_candidate)
- return upload_logic(slug_candidate, ajax=True)
-
- @self.web.app.route("/upload-ajax", methods=['POST'])
+ @self.web.app.route("/upload-ajax", methods=["POST"])
def upload_ajax_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(ajax=True)
+ return upload(ajax=True)
class ReceiveModeWSGIMiddleware(object):
@@ -174,13 +166,14 @@ 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
- environ['stop_q'] = self.web.stop_q
+ environ["web"] = self.web
+ environ["stop_q"] = self.web.stop_q
return self.app(environ, start_response)
@@ -190,6 +183,7 @@ class ReceiveModeFile(object):
written to it, in order to track the progress of uploads. It starts out with
a .part file extension, and when it's complete it removes that extension.
"""
+
def __init__(self, request, filename, write_func, close_func):
self.onionshare_request = request
self.onionshare_filename = filename
@@ -197,24 +191,44 @@ class ReceiveModeFile(object):
self.onionshare_close_func = close_func
self.filename = os.path.join(self.onionshare_request.receive_mode_dir, filename)
- self.filename_in_progress = '{}.part'.format(self.filename)
+ self.filename_in_progress = "{}.part".format(self.filename)
# Open the file
self.upload_error = False
try:
- self.f = open(self.filename_in_progress, 'wb+')
+ self.f = open(self.filename_in_progress, "wb+")
except:
# This will only happen if someone is messing with the data dir while
# OnionShare is running, but if it does make sure to throw an error
self.upload_error = True
- self.f = tempfile.TemporaryFile('wb+')
+ 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']
+ 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))
@@ -256,25 +270,22 @@ 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']
- self.stop_q = environ['stop_q']
+ self.web = environ["web"]
+ self.stop_q = environ["stop_q"]
- self.web.common.log('ReceiveModeRequest', '__init__')
+ self.web.common.log("ReceiveModeRequest", "__init__")
# Prevent running the close() method more than once
self.closed = False
# Is this a valid upload request?
self.upload_request = False
- if self.method == 'POST':
- if self.web.common.settings.get('public_mode'):
- if self.path == '/upload' or self.path == '/upload-ajax':
- self.upload_request = True
- else:
- if self.path == '/{}/upload'.format(self.web.slug) or self.path == '/{}/upload-ajax'.format(self.web.slug):
- self.upload_request = True
+ if self.method == "POST":
+ if self.path == "/upload" or self.path == "/upload-ajax":
+ self.upload_request = True
if self.upload_request:
# No errors yet
@@ -284,7 +295,9 @@ class ReceiveModeRequest(Request):
now = datetime.now()
date_dir = now.strftime("%Y-%m-%d")
time_dir = now.strftime("%H.%M.%S")
- self.receive_mode_dir = os.path.join(self.web.common.settings.get('data_dir'), date_dir, time_dir)
+ self.receive_mode_dir = os.path.join(
+ self.web.common.settings.get("data_dir"), date_dir, time_dir
+ )
# Create that directory, which shouldn't exist yet
try:
@@ -296,7 +309,7 @@ class ReceiveModeRequest(Request):
# Keep going until we find a directory name that's available
i = 1
while True:
- new_receive_mode_dir = '{}-{}'.format(self.receive_mode_dir, i)
+ new_receive_mode_dir = "{}-{}".format(self.receive_mode_dir, i)
try:
os.makedirs(new_receive_mode_dir, 0o700, exist_ok=False)
self.receive_mode_dir = new_receive_mode_dir
@@ -306,15 +319,29 @@ class ReceiveModeRequest(Request):
i += 1
# Failsafe
if i == 100:
- self.web.common.log('ReceiveModeRequest', '__init__', 'Error finding available receive mode directory')
+ self.web.common.log(
+ "ReceiveModeRequest",
+ "__init__",
+ "Error finding available receive mode directory",
+ )
self.upload_error = True
break
except PermissionError:
- self.web.add_request(self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE, request.path, {
- "receive_mode_dir": self.receive_mode_dir
- })
- print("Could not create OnionShare data folder: {}".format(self.receive_mode_dir))
- self.web.common.log('ReceiveModeRequest', '__init__', 'Permission denied creating receive mode directory')
+ self.web.add_request(
+ self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE,
+ request.path,
+ {"receive_mode_dir": self.receive_mode_dir},
+ )
+ print(
+ "Could not create OnionShare data folder: {}".format(
+ self.receive_mode_dir
+ )
+ )
+ self.web.common.log(
+ "ReceiveModeRequest",
+ "__init__",
+ "Permission denied creating receive mode directory",
+ )
self.upload_error = True
# If there's an error so far, finish early
@@ -327,28 +354,33 @@ class ReceiveModeRequest(Request):
# Prevent new uploads if we've said so (timer expired)
if self.web.receive_mode.can_upload:
- # Create an upload_id, attach it to the request
- self.upload_id = self.web.receive_mode.upload_count
-
- self.web.receive_mode.upload_count += 1
+ # Create an history_id, attach it to the request
+ self.history_id = self.web.receive_mode.cur_history_id
+ self.web.receive_mode.cur_history_id += 1
- # Figure out the content length
+ # Figure out the content length
try:
- self.content_length = int(self.headers['Content-Length'])
+ 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))
- ))
+ 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)
+ ),
+ )
+ )
# Don't tell the GUI that a request has started until we start receiving files
self.told_gui_about_request = False
self.previous_file = None
- def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=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.
@@ -356,24 +388,26 @@ class ReceiveModeRequest(Request):
if self.upload_request:
if not self.told_gui_about_request:
# Tell the GUI about the request
- self.web.add_request(self.web.REQUEST_STARTED, self.path, {
- 'id': self.upload_id,
- 'content_length': self.content_length
- })
- self.web.receive_mode.uploads_in_progress.append(self.upload_id)
+ self.web.add_request(
+ self.web.REQUEST_STARTED,
+ self.path,
+ {"id": self.history_id, "content_length": self.content_length},
+ )
+ self.web.receive_mode.uploads_in_progress.append(self.history_id)
self.told_gui_about_request = True
self.filename = secure_filename(filename)
- self.progress[self.filename] = {
- 'uploaded_bytes': 0,
- 'complete': False
- }
+ self.progress[self.filename] = {"uploaded_bytes": 0, "complete": False}
- f = ReceiveModeFile(self, self.filename, self.file_write_func, self.file_close_func)
+ f = ReceiveModeFile(
+ self, self.filename, self.file_write_func, self.file_close_func
+ )
if f.upload_error:
- self.web.common.log('ReceiveModeRequest', '_get_file_stream', 'Error creating file')
+ self.web.common.log(
+ "ReceiveModeRequest", "_get_file_stream", "Error creating file"
+ )
self.upload_error = True
return f
@@ -388,23 +422,26 @@ class ReceiveModeRequest(Request):
return
self.closed = True
- self.web.common.log('ReceiveModeRequest', 'close')
+ self.web.common.log("ReceiveModeRequest", "close")
try:
if self.told_gui_about_request:
- upload_id = self.upload_id
+ history_id = self.history_id
- if not self.web.stop_q.empty() or not self.progress[self.filename]['complete']:
+ if (
+ not self.web.stop_q.empty()
+ or not self.progress[self.filename]["complete"]
+ ):
# Inform the GUI that the upload has canceled
- self.web.add_request(self.web.REQUEST_UPLOAD_CANCELED, self.path, {
- 'id': upload_id
- })
+ self.web.add_request(
+ self.web.REQUEST_UPLOAD_CANCELED, self.path, {"id": history_id}
+ )
else:
# Inform the GUI that the upload has finished
- self.web.add_request(self.web.REQUEST_UPLOAD_FINISHED, self.path, {
- 'id': upload_id
- })
- self.web.receive_mode.uploads_in_progress.remove(upload_id)
+ self.web.add_request(
+ self.web.REQUEST_UPLOAD_FINISHED, self.path, {"id": history_id}
+ )
+ self.web.receive_mode.uploads_in_progress.remove(history_id)
except AttributeError:
pass
@@ -417,28 +454,34 @@ class ReceiveModeRequest(Request):
return
if self.upload_request:
- self.progress[filename]['uploaded_bytes'] += length
+ self.progress[filename]["uploaded_bytes"] += length
if self.previous_file != filename:
self.previous_file = filename
- print('\r=> {:15s} {}'.format(
- self.web.common.human_readable_filesize(self.progress[filename]['uploaded_bytes']),
- filename
- ), end='')
+ print(
+ "\r=> {:15s} {}".format(
+ self.web.common.human_readable_filesize(
+ self.progress[filename]["uploaded_bytes"]
+ ),
+ filename,
+ ),
+ end="",
+ )
# Update the GUI on the upload progress
if self.told_gui_about_request:
- self.web.add_request(self.web.REQUEST_PROGRESS, self.path, {
- 'id': self.upload_id,
- 'progress': self.progress
- })
+ self.web.add_request(
+ self.web.REQUEST_PROGRESS,
+ self.path,
+ {"id": self.history_id, "progress": self.progress},
+ )
def file_close_func(self, filename, upload_error=False):
"""
This function gets called when a specific file is closed.
"""
- self.progress[filename]['complete'] = True
+ self.progress[filename]["complete"] = True
# If the file tells us there was an upload error, let the request know as well
if upload_error:
diff --git a/onionshare/web/send_base_mode.py b/onionshare/web/send_base_mode.py
new file mode 100644
index 00000000..86d34016
--- /dev/null
+++ b/onionshare/web/send_base_mode.py
@@ -0,0 +1,305 @@
+import os
+import sys
+import tempfile
+import mimetypes
+import gzip
+from flask import Response, request, render_template, make_response
+
+from .. import strings
+
+
+class SendBaseModeWeb:
+ """
+ All of the web logic shared between share and website mode (modes where the user sends files)
+ """
+
+ def __init__(self, common, web):
+ super(SendBaseModeWeb, self).__init__()
+ self.common = common
+ self.web = web
+
+ # Information about the file to be shared
+ self.is_zipped = False
+ self.download_filename = None
+ self.download_filesize = None
+ self.gzip_filename = None
+ self.gzip_filesize = None
+ self.zip_writer = None
+
+ # If "Stop After First Download" is checked (stay_open == False), only allow
+ # one download at a time.
+ self.download_in_progress = False
+
+ # This tracks the history id
+ self.cur_history_id = 0
+
+ self.define_routes()
+ self.init()
+
+ def set_file_info(self, filenames, processed_size_callback=None):
+ """
+ Build a data structure that describes the list of files
+ """
+ # If there's just one folder, replace filenames with a list of files inside that folder
+ if len(filenames) == 1 and os.path.isdir(filenames[0]):
+ filenames = [
+ os.path.join(filenames[0], x) for x in os.listdir(filenames[0])
+ ]
+
+ # Re-initialize
+ self.files = {} # Dictionary mapping file paths to filenames on disk
+ self.root_files = (
+ {}
+ ) # This is only the root files and dirs, as opposed to all of them
+ self.cleanup_filenames = []
+ self.cur_history_id = 0
+ self.file_info = {"files": [], "dirs": []}
+ self.gzip_individual_files = {}
+ self.init()
+
+ # Build the file list
+ for filename in filenames:
+ basename = os.path.basename(filename.rstrip("/"))
+
+ # If it's a filename, add it
+ if os.path.isfile(filename):
+ self.files[basename] = filename
+ self.root_files[basename] = filename
+
+ # If it's a directory, add it recursively
+ elif os.path.isdir(filename):
+ self.root_files[basename + "/"] = filename
+
+ for root, _, nested_filenames in os.walk(filename):
+ # Normalize the root path. So if the directory name is "/home/user/Documents/some_folder",
+ # and it has a nested folder foobar, the root is "/home/user/Documents/some_folder/foobar".
+ # The normalized_root should be "some_folder/foobar"
+ normalized_root = os.path.join(
+ basename, root[len(filename) :].lstrip("/")
+ ).rstrip("/")
+
+ # Add the dir itself
+ self.files[normalized_root + "/"] = root
+
+ # Add the files in this dir
+ for nested_filename in nested_filenames:
+ self.files[
+ os.path.join(normalized_root, nested_filename)
+ ] = os.path.join(root, nested_filename)
+
+ self.set_file_info_custom(filenames, processed_size_callback)
+
+ def directory_listing(self, filenames, path="", filesystem_path=None):
+ # Tell the GUI about the directory listing
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ self.web.add_request(
+ self.web.REQUEST_INDIVIDUAL_FILE_STARTED,
+ "/{}".format(path),
+ {"id": history_id, "method": request.method, "status_code": 200},
+ )
+
+ breadcrumbs = [("☗", "/")]
+ parts = path.split("/")[:-1]
+ for i in range(len(parts)):
+ breadcrumbs.append(
+ ("{}".format(parts[i]), "/{}/".format("/".join(parts[0 : i + 1])))
+ )
+ breadcrumbs_leaf = breadcrumbs.pop()[0]
+
+ # If filesystem_path is None, this is the root directory listing
+ files, dirs = self.build_directory_listing(filenames, filesystem_path)
+ r = self.directory_listing_template(
+ path, files, dirs, breadcrumbs, breadcrumbs_leaf
+ )
+ return self.web.add_security_headers(r)
+
+ def build_directory_listing(self, filenames, filesystem_path):
+ files = []
+ dirs = []
+
+ for filename in filenames:
+ if filesystem_path:
+ this_filesystem_path = os.path.join(filesystem_path, filename)
+ else:
+ this_filesystem_path = self.files[filename]
+
+ is_dir = os.path.isdir(this_filesystem_path)
+
+ if is_dir:
+ dirs.append({"basename": filename})
+ else:
+ size = os.path.getsize(this_filesystem_path)
+ size_human = self.common.human_readable_filesize(size)
+ files.append({"basename": filename, "size_human": size_human})
+ return files, dirs
+
+ def stream_individual_file(self, filesystem_path):
+ """
+ Return a flask response that's streaming the download of an individual file, and gzip
+ compressing it if the browser supports it.
+ """
+ use_gzip = self.should_use_gzip()
+
+ # gzip compress the individual file, if it hasn't already been compressed
+ if use_gzip:
+ if filesystem_path not in self.gzip_individual_files:
+ gzip_filename = tempfile.mkstemp("wb+")[1]
+ self._gzip_compress(filesystem_path, gzip_filename, 6, None)
+ self.gzip_individual_files[filesystem_path] = gzip_filename
+
+ # Make sure the gzip file gets cleaned up when onionshare stops
+ self.cleanup_filenames.append(gzip_filename)
+
+ file_to_download = self.gzip_individual_files[filesystem_path]
+ filesize = os.path.getsize(self.gzip_individual_files[filesystem_path])
+ else:
+ file_to_download = filesystem_path
+ filesize = os.path.getsize(filesystem_path)
+
+ path = request.path
+
+ # Tell GUI the individual file started
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ self.web.add_request(
+ self.web.REQUEST_INDIVIDUAL_FILE_STARTED,
+ path,
+ {"id": history_id, "filesize": filesize},
+ )
+
+ # Only GET requests are allowed, any other method should fail
+ if request.method != "GET":
+ return self.web.error405()
+
+ def generate():
+ chunk_size = 102400 # 100kb
+
+ fp = open(file_to_download, "rb")
+ done = False
+ while not done:
+ chunk = fp.read(chunk_size)
+ if chunk == b"":
+ done = True
+ else:
+ try:
+ yield chunk
+
+ # Tell GUI the progress
+ downloaded_bytes = fp.tell()
+ percent = (1.0 * downloaded_bytes / filesize) * 100
+ if (
+ not self.web.is_gui
+ or self.common.platform == "Linux"
+ or self.common.platform == "BSD"
+ ):
+ sys.stdout.write(
+ "\r{0:s}, {1:.2f}% ".format(
+ self.common.human_readable_filesize(
+ downloaded_bytes
+ ),
+ percent,
+ )
+ )
+ sys.stdout.flush()
+
+ self.web.add_request(
+ self.web.REQUEST_INDIVIDUAL_FILE_PROGRESS,
+ path,
+ {
+ "id": history_id,
+ "bytes": downloaded_bytes,
+ "filesize": filesize,
+ },
+ )
+ done = False
+ except:
+ # Looks like the download was canceled
+ done = True
+
+ # Tell the GUI the individual file was canceled
+ self.web.add_request(
+ self.web.REQUEST_INDIVIDUAL_FILE_CANCELED,
+ path,
+ {"id": history_id},
+ )
+
+ fp.close()
+
+ if self.common.platform != "Darwin":
+ sys.stdout.write("\n")
+
+ basename = os.path.basename(filesystem_path)
+
+ r = Response(generate())
+ if use_gzip:
+ r.headers.set("Content-Encoding", "gzip")
+ r.headers.set("Content-Length", filesize)
+ r.headers.set("Content-Disposition", "inline", filename=basename)
+ r = self.web.add_security_headers(r)
+ (content_type, _) = mimetypes.guess_type(basename, strict=False)
+ if content_type is not None:
+ r.headers.set("Content-Type", content_type)
+ return r
+
+ def should_use_gzip(self):
+ """
+ Should we use gzip for this browser?
+ """
+ return (not self.is_zipped) and (
+ "gzip" in request.headers.get("Accept-Encoding", "").lower()
+ )
+
+ def _gzip_compress(
+ self, input_filename, output_filename, level, processed_size_callback=None
+ ):
+ """
+ Compress a file with gzip, without loading the whole thing into memory
+ Thanks: https://stackoverflow.com/questions/27035296/python-how-to-gzip-a-large-text-file-without-memoryerror
+ """
+ bytes_processed = 0
+ blocksize = 1 << 16 # 64kB
+ with open(input_filename, "rb") as input_file:
+ output_file = gzip.open(output_filename, "wb", level)
+ while True:
+ if processed_size_callback is not None:
+ processed_size_callback(bytes_processed)
+
+ block = input_file.read(blocksize)
+ if len(block) == 0:
+ break
+ output_file.write(block)
+ bytes_processed += blocksize
+
+ output_file.close()
+
+ def init(self):
+ """
+ Inherited class will implement this
+ """
+ pass
+
+ def define_routes(self):
+ """
+ Inherited class will implement this
+ """
+ pass
+
+ def directory_listing_template(self):
+ """
+ Inherited class will implement this. It should call render_template and return
+ the response.
+ """
+ pass
+
+ def set_file_info_custom(self, filenames, processed_size_callback):
+ """
+ Inherited class will implement this.
+ """
+ pass
+
+ def render_logic(self, path=""):
+ """
+ Inherited class will implement this.
+ """
+ pass
diff --git a/onionshare/web/share_mode.py b/onionshare/web/share_mode.py
index 560a8ba4..21dea639 100644
--- a/onionshare/web/share_mode.py
+++ b/onionshare/web/share_mode.py
@@ -3,65 +3,46 @@ import sys
import tempfile
import zipfile
import mimetypes
-import gzip
from flask import Response, request, render_template, make_response
+from .send_base_mode import SendBaseModeWeb
from .. import strings
-class ShareModeWeb(object):
+class ShareModeWeb(SendBaseModeWeb):
"""
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
+ def init(self):
+ self.common.log("ShareModeWeb", "init")
- self.define_routes()
+ # Allow downloading individual files if "Stop sharing after files have been sent" is unchecked
+ self.download_individual_files = not self.common.settings.get(
+ "close_after_first_download"
+ )
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=''):
+
+ @self.web.app.route("/", defaults={"path": ""})
+ @self.web.app.route("/<path:path>")
+ def index(path):
"""
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
+ # Deny new downloads if "Stop sharing after files have been sent" 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'))
+ r = make_response(
+ render_template("denied.html"),
+ static_url_path=self.web.static_url_path,
+ )
return self.web.add_security_headers(r)
# If download is allowed to continue, serve download page
@@ -70,38 +51,10 @@ class ShareModeWeb(object):
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()
+ return self.render_logic(path)
@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=''):
+ def download():
"""
Download the zip file.
"""
@@ -109,16 +62,16 @@ class ShareModeWeb(object):
# 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'))
+ r = make_response(
+ render_template(
+ "denied.html", static_url_path=self.web.static_url_path
+ )
+ )
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')
+ 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,
@@ -133,10 +86,11 @@ class ShareModeWeb(object):
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
- })
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ self.web.add_request(
+ self.web.REQUEST_STARTED, path, {"id": history_id, "use_gzip": use_gzip}
+ )
basename = os.path.basename(self.download_filename)
@@ -147,19 +101,19 @@ class ShareModeWeb(object):
chunk_size = 102400 # 100kb
- fp = open(file_to_download, 'rb')
+ 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 not self.web.stop_q.empty():
- self.web.add_request(self.web.REQUEST_CANCELED, path, {
- 'id': download_id
- })
+ self.web.add_request(
+ self.web.REQUEST_CANCELED, path, {"id": history_id}
+ )
break
chunk = fp.read(chunk_size)
- if chunk == b'':
+ if chunk == b"":
self.web.done = True
else:
try:
@@ -170,15 +124,26 @@ class ShareModeWeb(object):
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':
+ 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))
+ "\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.add_request(
+ self.web.REQUEST_PROGRESS,
+ path,
+ {"id": history_id, "bytes": downloaded_bytes},
+ )
self.web.done = False
except:
# looks like the download was canceled
@@ -186,13 +151,13 @@ class ShareModeWeb(object):
canceled = True
# tell the GUI the download has canceled
- self.web.add_request(self.web.REQUEST_CANCELED, path, {
- 'id': download_id
- })
+ self.web.add_request(
+ self.web.REQUEST_CANCELED, path, {"id": history_id}
+ )
fp.close()
- if self.common.platform != 'Darwin':
+ if self.common.platform != "Darwin":
sys.stdout.write("\n")
# Download is finished
@@ -205,60 +170,127 @@ class ShareModeWeb(object):
self.web.running = False
try:
if shutdown_func is None:
- raise RuntimeError('Not running with the Werkzeug Server')
+ 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.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)
+ 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")
+ def directory_listing_template(
+ self, path, files, dirs, breadcrumbs, breadcrumbs_leaf
+ ):
+ return make_response(
+ render_template(
+ "send.html",
+ file_info=self.file_info,
+ files=files,
+ dirs=dirs,
+ breadcrumbs=breadcrumbs,
+ breadcrumbs_leaf=breadcrumbs_leaf,
+ 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,
+ static_url_path=self.web.static_url_path,
+ download_individual_files=self.download_individual_files,
+ )
+ )
+
+ def set_file_info_custom(self, filenames, processed_size_callback):
+ self.common.log("ShareModeWeb", "set_file_info_custom")
self.web.cancel_compression = False
+ self.build_zipfile_list(filenames, processed_size_callback)
+
+ def render_logic(self, path=""):
+ if path in self.files:
+ filesystem_path = self.files[path]
+
+ # If it's a directory
+ if os.path.isdir(filesystem_path):
+ # Render directory listing
+ filenames = []
+ for filename in os.listdir(filesystem_path):
+ if os.path.isdir(os.path.join(filesystem_path, filename)):
+ filenames.append(filename + "/")
+ else:
+ filenames.append(filename)
+ filenames.sort()
+ return self.directory_listing(filenames, path, filesystem_path)
+
+ # If it's a file
+ elif os.path.isfile(filesystem_path):
+ if self.download_individual_files:
+ return self.stream_individual_file(filesystem_path)
+ else:
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ return self.web.error404(history_id)
+
+ # If it's not a directory or file, throw a 404
+ else:
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ return self.web.error404(history_id)
+ else:
+ # Special case loading /
+
+ if path == "":
+ # Root directory listing
+ filenames = list(self.root_files)
+ filenames.sort()
+ return self.directory_listing(filenames, path)
- self.cleanup_filenames = []
+ else:
+ # If the path isn't found, throw a 404
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ return self.web.error404(history_id)
- # build file info list
- self.file_info = {'files': [], 'dirs': []}
+ def build_zipfile_list(self, filenames, processed_size_callback=None):
+ self.common.log("ShareModeWeb", "build_zipfile_list")
for filename in filenames:
info = {
- 'filename': filename,
- 'basename': os.path.basename(filename.rstrip('/'))
+ "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)
+ 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'])
+ 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']
+ 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_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
@@ -268,17 +300,19 @@ class ShareModeWeb(object):
else:
# Zip up the files and folders
- self.zip_writer = ZipWriter(self.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'])
+ 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']):
+ for info in self.file_info["dirs"]:
+ if not self.zip_writer.add_dir(info["filename"]):
return False
self.zip_writer.close()
@@ -291,33 +325,6 @@ 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
- 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):
"""
@@ -325,6 +332,7 @@ class ZipWriter(object):
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
@@ -332,9 +340,11 @@ class ZipWriter(object):
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.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.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
@@ -353,7 +363,7 @@ class ZipWriter(object):
"""
Add a directory, and all of its children, to the zip archive.
"""
- dir_to_strip = os.path.dirname(filename.rstrip('/'))+'/'
+ dir_to_strip = os.path.dirname(filename.rstrip("/")) + "/"
for dirpath, dirnames, filenames in os.walk(filename):
for f in filenames:
# Canceling early?
@@ -362,7 +372,7 @@ class ZipWriter(object):
full_filename = os.path.join(dirpath, f)
if not os.path.islink(full_filename):
- arc_filename = full_filename[len(dir_to_strip):]
+ 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)
diff --git a/onionshare/web/web.py b/onionshare/web/web.py
index edaf75f1..b5b805ec 100644
--- a/onionshare/web/web.py
+++ b/onionshare/web/web.py
@@ -5,54 +5,78 @@ import queue
import socket
import sys
import tempfile
+import requests
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 flask import (
+ Flask,
+ request,
+ render_template,
+ abort,
+ make_response,
+ send_file,
+ __version__ as flask_version,
+)
+from flask_httpauth import HTTPBasicAuth
from .. import strings
from .share_mode import ShareModeWeb
from .receive_mode import ReceiveModeWeb, ReceiveModeWSGIMiddleware, ReceiveModeRequest
-
+from .website_mode import WebsiteModeWeb
# 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
+
try:
flask.cli.show_server_banner = stubbed_show_server_banner
except:
pass
-class Web(object):
+class Web:
"""
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_UPLOAD_FILE_RENAMED = 6
- REQUEST_UPLOAD_SET_DIR = 7
- REQUEST_UPLOAD_FINISHED = 8
- REQUEST_UPLOAD_CANCELED = 9
- REQUEST_ERROR_DATA_DIR_CANNOT_CREATE = 10
-
- def __init__(self, common, is_gui, mode='share'):
+ REQUEST_CANCELED = 3
+ REQUEST_RATE_LIMIT = 4
+ REQUEST_UPLOAD_FILE_RENAMED = 5
+ REQUEST_UPLOAD_SET_DIR = 6
+ REQUEST_UPLOAD_FINISHED = 7
+ REQUEST_UPLOAD_CANCELED = 8
+ REQUEST_INDIVIDUAL_FILE_STARTED = 9
+ REQUEST_INDIVIDUAL_FILE_PROGRESS = 10
+ REQUEST_INDIVIDUAL_FILE_CANCELED = 11
+ REQUEST_ERROR_DATA_DIR_CANNOT_CREATE = 12
+ REQUEST_OTHER = 13
+ REQUEST_INVALID_PASSWORD = 14
+
+ def __init__(self, common, is_gui, mode="share"):
self.common = common
- self.common.log('Web', '__init__', 'is_gui={}, mode={}'.format(is_gui, mode))
+ 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 = Flask(
+ __name__,
+ static_folder=self.common.get_resource_path("static"),
+ static_url_path="/static_".format(
+ self.common.random_string(16)
+ ), # randomize static_url_path to avoid making /static unusable
+ template_folder=self.common.get_resource_path("templates"),
+ )
self.app.secret_key = self.common.random_string(8)
+ self.generate_static_url_path()
+ self.auth = HTTPBasicAuth()
+ self.auth.error_handler(self.error401)
# Verbose mode?
if self.common.verbose:
@@ -68,7 +92,7 @@ class Web(object):
# Are we using receive mode?
self.mode = mode
- if self.mode == 'receive':
+ 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
@@ -78,27 +102,27 @@ class Web(object):
# 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'):
+ 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')
+ ("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.password = None
+
+ self.reset_invalid_passwords()
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)
+ self.shutdown_password = self.common.random_string(16)
# Keep track if the server is running
self.running = False
@@ -109,59 +133,143 @@ class Web(object):
# 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.website_mode = None
+ if self.mode == "share":
self.share_mode = ShareModeWeb(self.common, self)
-
+ elif self.mode == "receive":
+ self.receive_mode = ReceiveModeWeb(self.common, self)
+ elif self.mode == "website":
+ self.website_mode = WebsiteModeWeb(self.common, self)
+
+ def get_mode(self):
+ if self.mode == "share":
+ return self.share_mode
+ elif self.mode == "receive":
+ return self.receive_mode
+ elif self.mode == "website":
+ return self.website_mode
+ else:
+ return None
+
+ def generate_static_url_path(self):
+ # The static URL path has a 128-bit random number in it to avoid having name
+ # collisions with files that might be getting shared
+ self.static_url_path = "/static_{}".format(self.common.random_string(16))
+ self.common.log(
+ "Web",
+ "generate_static_url_path",
+ "new static_url_path is {}".format(self.static_url_path),
+ )
+
+ # Update the flask route to handle the new static URL path
+ self.app.static_url_path = self.static_url_path
+ self.app.add_url_rule(
+ self.static_url_path + "/<path:filename>",
+ endpoint="static",
+ view_func=self.app.send_static_file,
+ )
def define_common_routes(self):
"""
- Common web app routes between sending and receiving
+ Common web app routes between all modes.
"""
- @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 ""
+ @self.auth.get_password
+ def get_pw(username):
+ if username == "onionshare":
+ return self.password
+ else:
+ return None
+
+ @self.app.before_request
+ def conditional_auth_check():
+ # Allow static files without basic authentication
+ if request.path.startswith(self.static_url_path + "/"):
+ return None
+
+ # If public mode is disabled, require authentication
+ if not self.common.settings.get("public_mode"):
+
+ @self.auth.login_required
+ def _check_login():
+ return None
+
+ return _check_login()
- @self.app.route("/noscript-xss-instructions")
- def noscript_xss_instructions():
+ @self.app.errorhandler(404)
+ def not_found(e):
+ mode = self.get_mode()
+ history_id = mode.cur_history_id
+ mode.cur_history_id += 1
+ return self.error404(history_id)
+
+ @self.app.route("/<password_candidate>/shutdown")
+ def shutdown(password_candidate):
"""
- Display instructions for disabling Tor Browser's NoScript XSS setting
+ Stop the flask web server, from the context of an http request.
"""
- r = make_response(render_template('receive_noscript_xss.html'))
- return self.add_security_headers(r)
-
- def error404(self):
- self.add_request(Web.REQUEST_OTHER, request.path)
- if request.path != '/favicon.ico':
- self.error404_count += 1
+ if password_candidate == self.shutdown_password:
+ self.force_shutdown()
+ return ""
+ abort(404)
- # 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)
+ if self.mode != "website":
+
+ @self.app.route("/favicon.ico")
+ def favicon():
+ return send_file(
+ "{}/img/favicon.ico".format(self.common.get_resource_path("static"))
+ )
+
+ def error401(self):
+ auth = request.authorization
+ if auth:
+ if (
+ auth["username"] == "onionshare"
+ and auth["password"] not in self.invalid_passwords
+ ):
+ print("Invalid password guess: {}".format(auth["password"]))
+ self.add_request(Web.REQUEST_INVALID_PASSWORD, data=auth["password"])
+
+ self.invalid_passwords.append(auth["password"])
+ self.invalid_passwords_count += 1
+
+ if self.invalid_passwords_count == 20:
+ self.add_request(Web.REQUEST_RATE_LIMIT)
self.force_shutdown()
- print("Someone has made too many wrong attempts on your address, which means they could be trying to guess it, so OnionShare has stopped the server. Start sharing again and send the recipient a new address to share.")
+ print(
+ "Someone has made too many wrong attempts to guess your password, so OnionShare has stopped the server. Start sharing again and send the recipient a new address to share."
+ )
- r = make_response(render_template('404.html'), 404)
+ r = make_response(
+ render_template("401.html", static_url_path=self.static_url_path), 401
+ )
return self.add_security_headers(r)
def error403(self):
self.add_request(Web.REQUEST_OTHER, request.path)
+ r = make_response(
+ render_template("403.html", static_url_path=self.static_url_path), 403
+ )
+ return self.add_security_headers(r)
+
+ def error404(self, history_id):
+ self.add_request(
+ self.REQUEST_INDIVIDUAL_FILE_STARTED,
+ "{}".format(request.path),
+ {"id": history_id, "status_code": 404},
+ )
- r = make_response(render_template('403.html'), 403)
+ self.add_request(Web.REQUEST_OTHER, request.path)
+ r = make_response(
+ render_template("404.html", static_url_path=self.static_url_path), 404
+ )
+ return self.add_security_headers(r)
+
+ def error405(self):
+ r = make_response(
+ render_template("405.html", static_url_path=self.static_url_path), 405
+ )
return self.add_security_headers(r)
def add_security_headers(self, r):
@@ -170,52 +278,61 @@ class Web(object):
"""
for header, value in self.security_headers:
r.headers.set(header, value)
+ # Set a CSP header unless in website mode and the user has disabled it
+ if (
+ not self.common.settings.get("csp_header_disabled")
+ or self.mode != "website"
+ ):
+ r.headers.set(
+ "Content-Security-Policy",
+ "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;",
+ )
return r
def _safe_select_jinja_autoescape(self, filename):
if filename is None:
return True
- return filename.endswith(('.html', '.htm', '.xml', '.xhtml'))
+ return filename.endswith((".html", ".htm", ".xml", ".xhtml"))
- def add_request(self, request_type, path, data=None):
+ def add_request(self, request_type, path=None, 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))
+ self.q.put({"type": request_type, "path": path, "data": data})
+
+ def generate_password(self, persistent_password=None):
+ self.common.log(
+ "Web",
+ "generate_password",
+ "persistent_password={}".format(persistent_password),
+ )
+ if persistent_password != None and persistent_password != "":
+ self.password = persistent_password
+ self.common.log(
+ "Web",
+ "generate_password",
+ 'persistent_password sent, so password is: "{}"'.format(self.password),
+ )
else:
- self.slug = self.common.build_slug()
- self.common.log('Web', 'generate_slug', 'built random slug: "{}"'.format(self.slug))
+ self.password = self.common.build_password()
+ self.common.log(
+ "Web",
+ "generate_password",
+ 'built random password: "{}"'.format(self.password),
+ )
def verbose_mode(self):
"""
Turn on verbose mode, which will log flask errors to a file.
"""
- flask_log_filename = os.path.join(self.common.build_data_dir(), 'flask.log')
+ flask_log_filename = os.path.join(self.common.build_data_dir(), "flask.log")
log_handler = logging.FileHandler(flask_log_filename)
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 reset_invalid_passwords(self):
+ self.invalid_passwords_count = 0
+ self.invalid_passwords = []
def force_shutdown(self):
"""
@@ -223,19 +340,25 @@ class Web(object):
"""
# Shutdown the flask service
try:
- func = request.environ.get('werkzeug.server.shutdown')
+ func = request.environ.get("werkzeug.server.shutdown")
if func is None:
- raise RuntimeError('Not running with the Werkzeug Server')
+ raise RuntimeError("Not running with the Werkzeug Server")
func()
except:
pass
self.running = False
- def start(self, port, stay_open=False, public_mode=False, slug=None):
+ def start(self, port, stay_open=False, public_mode=False, password=None):
"""
Start the flask web server.
"""
- self.common.log('Web', 'start', 'port={}, stay_open={}, public_mode={}, slug={}'.format(port, stay_open, public_mode, slug))
+ self.common.log(
+ "Web",
+ "start",
+ "port={}, stay_open={}, public_mode={}, password={}".format(
+ port, stay_open, public_mode, password
+ ),
+ )
self.stay_open = stay_open
@@ -247,10 +370,10 @@ class Web(object):
pass
# 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'
+ if os.path.exists("/usr/share/anon-ws-base-files/workstation"):
+ host = "0.0.0.0"
else:
- host = '127.0.0.1'
+ host = "127.0.0.1"
self.running = True
self.app.run(host=host, port=port, threaded=True)
@@ -259,22 +382,18 @@ class Web(object):
"""
Stop the flask web server by loading /shutdown.
"""
- self.common.log('Web', 'stop', 'stopping server')
+ self.common.log("Web", "stop", "stopping server")
# Let the mode know that the user stopped the server
self.stop_q.put(True)
- # Reset any slug that was in use
- self.slug = None
-
- # To stop flask, load http://127.0.0.1:<port>/<shutdown_slug>/shutdown
+ # To stop flask, load http://shutdown:[shutdown_password]@127.0.0.1/[shutdown_password]/shutdown
+ # (We're putting the shutdown_password in the path as well to make routing simpler)
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
+ requests.get(
+ "http://127.0.0.1:{}/{}/shutdown".format(port, self.shutdown_password),
+ auth=requests.auth.HTTPBasicAuth("onionshare", self.password),
+ )
+
+ # Reset any password that was in use
+ self.password = None
diff --git a/onionshare/web/website_mode.py b/onionshare/web/website_mode.py
new file mode 100644
index 00000000..61b6d2c6
--- /dev/null
+++ b/onionshare/web/website_mode.py
@@ -0,0 +1,104 @@
+import os
+import sys
+import tempfile
+import mimetypes
+from flask import Response, request, render_template, make_response
+
+from .send_base_mode import SendBaseModeWeb
+from .. import strings
+
+
+class WebsiteModeWeb(SendBaseModeWeb):
+ """
+ All of the web logic for website mode
+ """
+
+ def init(self):
+ pass
+
+ def define_routes(self):
+ """
+ The web app routes for sharing a website
+ """
+
+ @self.web.app.route("/", defaults={"path": ""})
+ @self.web.app.route("/<path:path>")
+ def path_public(path):
+ return path_logic(path)
+
+ def path_logic(path=""):
+ """
+ Render the onionshare website.
+ """
+ return self.render_logic(path)
+
+ def directory_listing_template(
+ self, path, files, dirs, breadcrumbs, breadcrumbs_leaf
+ ):
+ return make_response(
+ render_template(
+ "listing.html",
+ path=path,
+ files=files,
+ dirs=dirs,
+ breadcrumbs=breadcrumbs,
+ breadcrumbs_leaf=breadcrumbs_leaf,
+ static_url_path=self.web.static_url_path,
+ )
+ )
+
+ def set_file_info_custom(self, filenames, processed_size_callback):
+ self.common.log("WebsiteModeWeb", "set_file_info_custom")
+ self.web.cancel_compression = True
+
+ def render_logic(self, path=""):
+ if path in self.files:
+ filesystem_path = self.files[path]
+
+ # If it's a directory
+ if os.path.isdir(filesystem_path):
+ # Is there an index.html?
+ index_path = os.path.join(path, "index.html")
+ if index_path in self.files:
+ # Render it
+ return self.stream_individual_file(self.files[index_path])
+
+ else:
+ # Otherwise, render directory listing
+ filenames = []
+ for filename in os.listdir(filesystem_path):
+ if os.path.isdir(os.path.join(filesystem_path, filename)):
+ filenames.append(filename + "/")
+ else:
+ filenames.append(filename)
+ filenames.sort()
+ return self.directory_listing(filenames, path, filesystem_path)
+
+ # If it's a file
+ elif os.path.isfile(filesystem_path):
+ return self.stream_individual_file(filesystem_path)
+
+ # If it's not a directory or file, throw a 404
+ else:
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ return self.web.error404(history_id)
+ else:
+ # Special case loading /
+
+ if path == "":
+ index_path = "index.html"
+ if index_path in self.files:
+ # Render it
+ return self.stream_individual_file(self.files[index_path])
+ else:
+ # Root directory listing
+ filenames = list(self.root_files)
+ filenames.sort()
+ return self.directory_listing(filenames, path)
+
+ else:
+ # If the path isn't found, throw a 404
+ history_id = self.cur_history_id
+ self.cur_history_id += 1
+ return self.web.error404(history_id)