Split the increasingly-sprawly onionshare module into different modules:

onionshare: the main business logic
helpers: helper function used in multiple modules
strings: handles all localized strings
web: the flask web server
This commit is contained in:
Micah Lee 2014-08-26 18:22:59 -07:00
parent 0a6d01f11e
commit 54a37ee28e
4 changed files with 319 additions and 314 deletions

66
onionshare/helpers.py Normal file
View File

@ -0,0 +1,66 @@
import os, inspect, hashlib, base64, hmac, platform
from itertools import izip
def get_platform():
p = platform.system()
if p == 'Linux' and platform.uname()[0:2] == ('Linux', 'amnesia'):
p = 'Tails'
return p
def get_onionshare_dir():
if get_platform() == 'Darwin':
onionshare_dir = os.path.dirname(__file__)
else:
onionshare_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
return onionshare_dir
def constant_time_compare(val1, val2):
_builtin_constant_time_compare = getattr(hmac, 'compare_digest', None)
if _builtin_constant_time_compare is not None:
return _builtin_constant_time_compare(val1, val2)
len_eq = len(val1) == len(val2)
if len_eq:
result = 0
left = val1
else:
result = 1
left = val2
for x, y in izip(bytearray(left), bytearray(val2)):
result |= x ^ y
return result == 0
def random_string(num_bytes):
b = os.urandom(num_bytes)
h = hashlib.sha256(b).digest()[:16]
return base64.b32encode(h).lower().replace('=','')
def human_readable_filesize(b):
thresh = 1024.0
if b < thresh:
return '{0} B'.format(b)
units = ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB']
u = 0
b /= thresh
while b >= thresh:
b /= thresh
u += 1
return '{0} {1}'.format(round(b, 1), units[u])
def is_root():
return os.geteuid() == 0
def file_crunching(filename):
# calculate filehash, file size
BLOCKSIZE = 65536
hasher = hashlib.sha1()
with open(filename, 'rb') as f:
buf = f.read(BLOCKSIZE)
while len(buf) > 0:
hasher.update(buf)
buf = f.read(BLOCKSIZE)
filehash = hasher.hexdigest()
filesize = os.path.getsize(filename)
return filehash, filesize

View File

@ -1,304 +1,111 @@
# -*- coding: utf-8 -*-
import os, sys, subprocess, time, hashlib, platform, json, locale, socket, argparse, Queue, inspect, base64, mimetypes, hmac, shutil
from itertools import izip
import os, sys, subprocess, time, argparse, inspect, shutil, socket
from stem.control import Controller
from stem import SocketError
from flask import Flask, Response, request, render_template_string, abort
import strings, helpers, web
class NoTor(Exception): pass
class TailsError(Exception): pass
def constant_time_compare(val1, val2):
_builtin_constant_time_compare = getattr(hmac, 'compare_digest', None)
if _builtin_constant_time_compare is not None:
return _builtin_constant_time_compare(val1, val2)
class OnionShare(object):
def __init__(self, debug=False, local_only=False, stay_open=False):
# debug mode
if debug:
web.debug_mode()
len_eq = len(val1) == len(val2)
if len_eq:
result = 0
left = val1
else:
result = 1
left = val2
for x, y in izip(bytearray(left), bytearray(val2)):
result |= x ^ y
return result == 0
# do not use tor -- for development
self.local_only = local_only
def random_string(num_bytes):
b = os.urandom(num_bytes)
h = hashlib.sha256(b).digest()[:16]
return base64.b32encode(h).lower().replace('=','')
# automatically close when download is finished
self.stay_open = stay_open
def get_platform():
p = platform.system()
if p == 'Linux' and platform.uname()[0:2] == ('Linux', 'amnesia'):
p = 'Tails'
return p
# list of hidden service dirs to cleanup
self.hidserv_dirs = []
# information about the file
filename = filesize = filehash = None
def set_file_info(new_filename, new_filehash, new_filesize):
global filename, filehash, filesize
filename = new_filename
filehash = new_filehash
filesize = new_filesize
# choose a random port
self.choose_port()
self.local_host = "127.0.0.1:{0}".format(self.port)
# automatically close
stay_open = False
def set_stay_open(new_stay_open):
global stay_open
stay_open = new_stay_open
def cleanup(self):
for d in self.hidserv_dirs:
shutil.rmtree(d)
def get_stay_open():
return stay_open
def choose_port(self):
# let the OS choose a port
tmpsock = socket.socket()
tmpsock.bind(("127.0.0.1", 0))
self.port = tmpsock.getsockname()[1]
tmpsock.close()
app = Flask(__name__)
def start_hidden_service(self):
if helpers.get_platform() == 'Tails':
# in Tails, start the hidden service in a root process
p = subprocess.Popen(['/usr/bin/sudo', '--', '/usr/bin/onionshare', str(app.port)], stderr=subprocess.PIPE, stdout=subprocess.PIPE)
stdout = p.stdout.read(22) # .onion URLs are 22 chars long
def debug_mode():
import logging
global app
if platform.system() == 'Windows':
temp_dir = os.environ['Temp'].replace('\\', '/')
else:
temp_dir = '/tmp/'
log_handler = logging.FileHandler('{0}/onionshare_server.log'.format(temp_dir))
log_handler.setLevel(logging.WARNING)
app.logger.addHandler(log_handler)
# get path of onioshare directory
if get_platform() == 'Darwin':
onionshare_dir = os.path.dirname(__file__)
else:
onionshare_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
strings = {}
slug = random_string(16)
download_count = 0
REQUEST_LOAD = 0
REQUEST_DOWNLOAD = 1
REQUEST_PROGRESS = 2
REQUEST_OTHER = 3
q = Queue.Queue()
def add_request(type, path, data=None):
global q
q.put({
'type': type,
'path': path,
'data': data
})
cleanup_q = Queue.Queue()
def register_cleanup_handler(directory):
global cleanup_q
def handler(signum = None, frame = None):
shutil.rmtree(directory)
cleanup_q.put(handler)
def execute_cleanup_handlers():
global cleanup_q
try:
while True:
handler = cleanup_q.get(False)
handler()
except Queue.Empty:
pass
def human_readable_filesize(b):
thresh = 1024.0
if b < thresh:
return '{0} B'.format(b)
units = ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB']
u = 0
b /= thresh
while b >= thresh:
b /= thresh
u += 1
return '{0} {1}'.format(round(b, 1), units[u])
@app.route("/<slug_candidate>")
def index(slug_candidate):
global filename, filesize, filehash, slug, strings, REQUEST_LOAD, onionshare_dir
if not constant_time_compare(slug.encode('ascii'), slug_candidate.encode('ascii')):
abort(404)
add_request(REQUEST_LOAD, request.path)
return render_template_string(
open('{0}/index.html'.format(onionshare_dir)).read(),
slug=slug,
filename=os.path.basename(filename).decode("utf-8"),
filehash=filehash,
filesize=filesize,
filesize_human=human_readable_filesize(filesize),
strings=strings
)
@app.route("/<slug_candidate>/download")
def download(slug_candidate):
global filename, filesize, q, download_count
global REQUEST_DOWNLOAD, REQUEST_PROGRESS
if not constant_time_compare(slug.encode('ascii'), slug_candidate.encode('ascii')):
abort(404)
# each download has a unique id
download_id = download_count
download_count += 1
# prepare some variables to use inside generate() function below
# which is outsie of the request context
shutdown_func = request.environ.get('werkzeug.server.shutdown')
path = request.path
# tell GUI the download started
add_request(REQUEST_DOWNLOAD, path, { 'id':download_id })
dirname = os.path.dirname(filename)
basename = os.path.basename(filename)
def generate():
chunk_size = 102400 # 100kb
fp = open(filename, 'rb')
done = False
while not done:
chunk = fp.read(102400)
if chunk == '':
done = True
if stdout:
self.onion_host = stdout
else:
yield chunk
if root_p.poll() == -1:
raise TailsError(o.stderr.read())
else:
raise TailsError(strings._("error_tails_unknown_root"))
# tell GUI the progress
downloaded_bytes = fp.tell()
percent = round((1.0 * downloaded_bytes / filesize) * 100, 2);
sys.stdout.write("\r{0}, {1}% ".format(human_readable_filesize(downloaded_bytes), percent))
sys.stdout.flush()
add_request(REQUEST_PROGRESS, path, { 'id':download_id, 'bytes':downloaded_bytes })
fp.close()
sys.stdout.write("\n")
# download is finished, close the server
global stay_open
if not stay_open:
print translated("closing_automatically")
if shutdown_func is None:
raise RuntimeError('Not running with the Werkzeug Server')
shutdown_func()
r = Response(generate())
r.headers.add('Content-Length', filesize)
r.headers.add('Content-Disposition', 'attachment', filename=basename)
# guess content type
(content_type, _) = mimetypes.guess_type(basename, strict=False)
if content_type is not None:
r.headers.add('Content-Type', content_type)
return r
@app.errorhandler(404)
def page_not_found(e):
global REQUEST_OTHER, onionshare_dir
add_request(REQUEST_OTHER, request.path)
return render_template_string(open('{0}/404.html'.format(onionshare_dir)).read())
def is_root():
return os.geteuid() == 0
def load_strings(default="en"):
global strings
try:
translated = json.loads(open('{0}/strings.json'.format(os.getcwd())).read())
except IOError:
translated = json.loads(open('{0}/strings.json'.format(onionshare_dir)).read())
strings = translated[default]
lc, enc = locale.getdefaultlocale()
if lc:
lang = lc[:2]
if lang in translated:
# if a string doesn't exist, fallback to English
for key in translated[default]:
if key in translated[lang]:
strings[key] = translated[lang][key]
return strings
def translated(k):
return strings[k].encode("utf-8")
def file_crunching(filename):
# calculate filehash, file size
BLOCKSIZE = 65536
hasher = hashlib.sha1()
with open(filename, 'rb') as f:
buf = f.read(BLOCKSIZE)
while len(buf) > 0:
hasher.update(buf)
buf = f.read(BLOCKSIZE)
filehash = hasher.hexdigest()
filesize = os.path.getsize(filename)
return filehash, filesize
def choose_port():
# let the OS choose a port
tmpsock = socket.socket()
tmpsock.bind(("127.0.0.1", 0))
port = tmpsock.getsockname()[1]
tmpsock.close()
return port
def start_hidden_service(port):
# come up with a hidden service directory name
hidserv_dir_rand = random_string(8)
if get_platform() == "Windows":
if 'Temp' in os.environ:
temp = os.environ['Temp'].replace('\\', '/')
else:
temp = 'C:/tmp'
hidserv_dir = "{0}/onionshare_{1}".format(temp, hidserv_dir_rand)
else:
hidserv_dir = "/tmp/onionshare_{0}".format(hidserv_dir_rand)
if self.local_only:
self.onion_host = '127.0.0.1:{0}'.format(self.port)
register_cleanup_handler(hidserv_dir)
else:
print strings._("connecting_ctrlport").format(self.port)
# connect to the tor controlport
controlports = [9051, 9151]
controller = False
for controlport in controlports:
try:
controller = Controller.from_port(port=controlport)
except SocketError:
pass
if not controller:
raise NoTor(translated("cant_connect_ctrlport").format(controlports))
controller.authenticate()
# come up with a hidden service directory name
hidserv_dir_rand = helpers.random_string(8)
if helpers.get_platform() == "Windows":
if 'Temp' in os.environ:
temp = os.environ['Temp'].replace('\\', '/')
else:
temp = 'C:/tmp'
hidserv_dir = "{0}/onionshare_{1}".format(temp, hidserv_dir_rand)
else:
hidserv_dir = "/tmp/onionshare_{0}".format(hidserv_dir_rand)
# set up hidden service
controller.set_options([
('HiddenServiceDir', hidserv_dir),
('HiddenServicePort', '80 127.0.0.1:{0}'.format(port))
])
self.hidserv_dirs.append(hidserv_dir)
# figure out the .onion hostname
hostname_file = '{0}/hostname'.format(hidserv_dir)
onion_host = open(hostname_file, 'r').read().strip()
# connect to the tor controlport
controlports = [9051, 9151]
controller = False
for controlport in controlports:
try:
controller = Controller.from_port(port=controlport)
except SocketError:
pass
if not controller:
raise NoTor(strings._("cant_connect_ctrlport").format(controlports))
controller.authenticate()
return onion_host
# set up hidden service
controller.set_options([
('HiddenServiceDir', hidserv_dir),
('HiddenServicePort', '80 127.0.0.1:{0}'.format(self.port))
])
# figure out the .onion hostname
hostname_file = '{0}/hostname'.format(hidserv_dir)
self.onion_host = open(hostname_file, 'r').read().strip()
def tails_root():
# if running in Tails and as root, do only the things that require root
if get_platform() == 'Tails' and is_root():
if helpers.get_platform() == 'Tails' and helpers.is_root():
parser = argparse.ArgumentParser()
parser.add_argument('port', nargs=1, help=translated("help_tails_port"))
parser.add_argument('port', nargs=1, help=strings._("help_tails_port"))
args = parser.parse_args()
try:
port = int(args.port[0])
except ValueError:
sys.stderr.write('{0}\n'.format(translated("error_tails_invalid_port")))
sys.stderr.write('{0}\n'.format(strings._("error_tails_invalid_port")))
sys.exit(-1)
# open hole in firewall
@ -322,74 +129,50 @@ def tails_root():
time.sleep(1)
def main():
load_strings()
strings.load_strings()
tails_root()
# parse arguments
parser = argparse.ArgumentParser()
parser.add_argument('--local-only', action='store_true', dest='local_only', help=translated("help_local_only"))
parser.add_argument('--stay-open', action='store_true', dest='stay_open', help=translated("help_stay_open"))
parser.add_argument('--debug', action='store_true', dest='debug', help=translated("help_debug"))
parser.add_argument('filename', nargs=1, help=translated("help_filename"))
parser.add_argument('--local-only', action='store_true', dest='local_only', help=strings._("help_local_only"))
parser.add_argument('--stay-open', action='store_true', dest='stay_open', help=strings._("help_stay_open"))
parser.add_argument('--debug', action='store_true', dest='debug', help=strings._("help_debug"))
parser.add_argument('filename', nargs=1, help=strings._("help_filename"))
args = parser.parse_args()
filename = os.path.abspath(args.filename[0])
local_only = bool(args.local_only)
debug = bool(args.debug)
if debug:
debug_mode()
global stay_open
stay_open = bool(args.stay_open)
if not (filename and os.path.isfile(filename)):
sys.exit(translated("not_a_file").format(filename))
sys.exit(strings._("not_a_file").format(filename))
filename = os.path.abspath(filename)
port = choose_port()
local_host = "127.0.0.1:{0}".format(port)
if get_platform() == 'Tails':
# if this is tails, start the root process
root_p = subprocess.Popen(['/usr/bin/sudo', '--', '/usr/bin/onionshare', str(port)], stderr=subprocess.PIPE, stdout=subprocess.PIPE)
stdout = root_p.stdout.read(22) # .onion URLs are 22 chars long
if stdout:
onion_host = stdout
else:
if root_p.poll() == -1:
sys.exit(root_p.stderr.read())
else:
sys.exit(translated("error_tails_unknown_root"))
else:
# if not tails, start hidden service normally
if not local_only:
# try starting hidden service
print translated("connecting_ctrlport").format(port)
try:
onion_host = start_hidden_service(port)
except NoTor as e:
sys.exit(e.args[0])
# start the onionshare app
try:
app = OnionShare(debug, local_only, stay_open)
app.start_hidden_service()
except NoTor as e:
sys.exit(e.args[0])
except TailsError as e:
sys.exit(e.args[0])
# startup
print translated("calculating_sha1")
filehash, filesize = file_crunching(filename)
set_file_info(filename, filehash, filesize)
print '\n' + translated("give_this_url")
if local_only:
print 'http://{0}/{1}'.format(local_host, slug)
else:
print 'http://{0}/{1}'.format(onion_host, slug)
print strings._("calculating_sha1")
filehash, filesize = helpers.file_crunching(filename)
web.set_file_info(filename, filehash, filesize)
print '\n' + strings._("give_this_url")
print 'http://{0}/{1}'.format(app.onion_host, web.slug)
print ''
print translated("ctrlc_to_stop")
print strings._("ctrlc_to_stop")
# start the web server
app.run(port=port)
web.start(app.port, app.stay_open)
print '\n'
# shutdown
execute_cleanup_handlers()
app.cleanup()
if __name__ == '__main__':
main()

22
onionshare/strings.py Normal file
View File

@ -0,0 +1,22 @@
import json, locale
import helpers
strings = {}
def load_strings(default="en"):
global strings
translated = json.loads(open('{0}/strings.json'.format(helpers.get_onionshare_dir())).read())
strings = translated[default]
lc, enc = locale.getdefaultlocale()
if lc:
lang = lc[:2]
if lang in translated:
# if a string doesn't exist, fallback to English
for key in translated[default]:
if key in translated[lang]:
strings[key] = translated[lang][key]
def translated(k):
return strings[k].encode("utf-8")
_ = translated

134
onionshare/web.py Normal file
View File

@ -0,0 +1,134 @@
import Queue, mimetypes, platform, os, sys
from flask import Flask, Response, request, render_template_string, abort
import strings, helpers
app = Flask(__name__)
# information about the file
filename = filesize = filehash = None
def set_file_info(new_filename, new_filehash, new_filesize):
global filename, filehash, filesize
filename = new_filename
filehash = new_filehash
filesize = new_filesize
REQUEST_LOAD = 0
REQUEST_DOWNLOAD = 1
REQUEST_PROGRESS = 2
REQUEST_OTHER = 3
q = Queue.Queue()
def add_request(type, path, data=None):
global q
q.put({
'type': type,
'path': path,
'data': data
})
slug = helpers.random_string(16)
download_count = 0
stay_open = False
def set_stay_open(new_stay_open):
global stay_open
stay_open = new_stay_open
def get_stay_open():
return stay_open
def debug_mode():
import logging
if platform.system() == 'Windows':
temp_dir = os.environ['Temp'].replace('\\', '/')
else:
temp_dir = '/tmp/'
log_handler = logging.FileHandler('{0}/onionshare_server.log'.format(temp_dir))
log_handler.setLevel(logging.WARNING)
app.logger.addHandler(log_handler)
@app.route("/<slug_candidate>")
def index(slug_candidate):
if not helpers.constant_time_compare(slug.encode('ascii'), slug_candidate.encode('ascii')):
abort(404)
add_request(REQUEST_LOAD, request.path)
return render_template_string(
open('{0}/index.html'.format(helpers.get_onionshare_dir())).read(),
slug=slug,
filename=os.path.basename(filename).decode("utf-8"),
filehash=filehash,
filesize=filesize,
filesize_human=helpers.human_readable_filesize(filesize),
strings=strings.strings
)
@app.route("/<slug_candidate>/download")
def download(slug_candidate):
global download_count
if not helpers.constant_time_compare(slug.encode('ascii'), slug_candidate.encode('ascii')):
abort(404)
# each download has a unique id
download_id = download_count
download_count += 1
# prepare some variables to use inside generate() function below
# which is outsie of the request context
shutdown_func = request.environ.get('werkzeug.server.shutdown')
path = request.path
# tell GUI the download started
add_request(REQUEST_DOWNLOAD, path, { 'id':download_id })
dirname = os.path.dirname(filename)
basename = os.path.basename(filename)
def generate():
chunk_size = 102400 # 100kb
fp = open(filename, 'rb')
done = False
while not done:
chunk = fp.read(102400)
if chunk == '':
done = True
else:
yield chunk
# tell GUI the progress
downloaded_bytes = fp.tell()
percent = round((1.0 * downloaded_bytes / filesize) * 100, 2);
sys.stdout.write("\r{0}, {1}% ".format(helpers.human_readable_filesize(downloaded_bytes), percent))
sys.stdout.flush()
add_request(REQUEST_PROGRESS, path, { 'id':download_id, 'bytes':downloaded_bytes })
fp.close()
sys.stdout.write("\n")
# download is finished, close the server
if not stay_open:
print strings._("closing_automatically")
if shutdown_func is None:
raise RuntimeError('Not running with the Werkzeug Server')
shutdown_func()
r = Response(generate())
r.headers.add('Content-Length', filesize)
r.headers.add('Content-Disposition', 'attachment', filename=basename)
# guess content type
(content_type, _) = mimetypes.guess_type(basename, strict=False)
if content_type is not None:
r.headers.add('Content-Type', content_type)
return r
@app.errorhandler(404)
def page_not_found(e):
add_request(REQUEST_OTHER, request.path)
return render_template_string(open('{0}/404.html'.format(helpers.get_onionshare_dir())).read())
def start(port, stay_open=False):
set_stay_open(stay_open)
app.run(port=port)