summaryrefslogtreecommitdiff
path: root/onionshare/web.py
blob: e9c4d43ba08bbc1f873131b863e91b31dacb5b49 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# -*- coding: utf-8 -*-
"""
OnionShare | https://onionshare.org/

Copyright (C) 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 base64
from distutils.version import LooseVersion as Version
from urllib.request import urlopen

from flask import (
    Flask, Response, request, render_template_string, abort, make_response,
    __version__ as flask_version
)

from . import strings, common


def _safe_select_jinja_autoescape(self, filename):
    if filename is None:
        return True
    return filename.endswith(('.html', '.htm', '.xml', '.xhtml'))

# 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 = _safe_select_jinja_autoescape

app = Flask(__name__)

# information about the file
file_info = []
zip_filename = None
zip_filesize = None

security_headers = [
    ('Content-Security-Policy', 'default-src \'self\'; style-src \'unsafe-inline\'; script-src \'unsafe-inline\'; 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')
]


def set_file_info(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.
    """
    global file_info, zip_filename, zip_filesize

    # build file info list
    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'] = common.human_readable_filesize(info['size'])
            file_info['files'].append(info)
        if os.path.isdir(filename):
            info['size'] = common.dir_size(filename)
            info['size_human'] = common.human_readable_filesize(info['size'])
            file_info['dirs'].append(info)
    file_info['files'] = sorted(file_info['files'], key=lambda k: k['basename'])
    file_info['dirs'] = sorted(file_info['dirs'], key=lambda k: k['basename'])

    # zip up the files and folders
    z = common.ZipWriter(processed_size_callback=processed_size_callback)
    for info in file_info['files']:
        z.add_file(info['filename'])
    for info in file_info['dirs']:
        z.add_dir(info['filename'])
    z.close()
    zip_filename = z.zip_filename
    zip_filesize = os.path.getsize(zip_filename)


REQUEST_LOAD = 0
REQUEST_DOWNLOAD = 1
REQUEST_PROGRESS = 2
REQUEST_OTHER = 3
REQUEST_CANCELED = 4
REQUEST_RATE_LIMIT = 5
q = queue.Queue()


def add_request(request_type, path, data=None):
    """
    Add a request to the queue, to communicate with the GUI.
    """
    global q
    q.put({
        'type': request_type,
        'path': path,
        'data': data
    })


# Load and base64 encode images to pass into templates
favicon_b64 = base64.b64encode(open(common.get_resource_path('images/favicon.ico'), 'rb').read()).decode()
logo_b64 = base64.b64encode(open(common.get_resource_path('images/logo.png'), 'rb').read()).decode()
folder_b64 = base64.b64encode(open(common.get_resource_path('images/web_folder.png'), 'rb').read()).decode()
file_b64 = base64.b64encode(open(common.get_resource_path('images/web_file.png'), 'rb').read()).decode()

slug = None


def generate_slug(persistent_slug=''):
    global slug
    if persistent_slug:
        slug = persistent_slug
    else:
        slug = common.build_slug()

download_count = 0
error404_count = 0

stay_open = False


def set_stay_open(new_stay_open):
    """
    Set stay_open variable.
    """
    global stay_open
    stay_open = new_stay_open


def get_stay_open():
    """
    Get stay_open variable.
    """
    return stay_open


# Are we running in GUI mode?
gui_mode = False


def set_gui_mode():
    """
    Tell the web service that we're running in GUI mode
    """
    global gui_mode
    gui_mode = True


def debug_mode():
    """
    Turn on debugging mode, which will log flask errors to a debug file.

    This is commented out (it's only needed for debugging, and not needed
    for OnionShare 1.3.2) as a hotfix to resolve this issue:
    https://github.com/micahflee/onionshare/issues/837
    """
    pass
    """
    temp_dir = tempfile.gettempdir()
    log_handler = logging.FileHandler(
        os.path.join(temp_dir, 'onionshare_server.log'))
    log_handler.setLevel(logging.WARNING)
    app.logger.addHandler(log_handler)
    """


def check_slug_candidate(slug_candidate, slug_compare=None):
    if not slug_compare:
        slug_compare = slug
    if not hmac.compare_digest(slug_compare, slug_candidate):
        abort(404)


# If "Stop After First Download" is checked (stay_open == False), only allow
# one download at a time.
download_in_progress = False

done = False

@app.route("/<slug_candidate>")
def index(slug_candidate):
    """
    Render the template for the onionshare landing page.
    """
    check_slug_candidate(slug_candidate)

    add_request(REQUEST_LOAD, request.path)

    # Deny new downloads if "Stop After First Download" is checked and there is
    # currently a download
    global stay_open, download_in_progress
    deny_download = not stay_open and download_in_progress
    if deny_download:
        r = make_response(render_template_string(
            open(common.get_resource_path('html/denied.html')).read(),
            favicon_b64=favicon_b64
        ))
        for header, value in security_headers:
            r.headers.set(header, value)
        return r

    # If download is allowed to continue, serve download page

    r = make_response(render_template_string(
        open(common.get_resource_path('html/index.html')).read(),
        favicon_b64=favicon_b64,
        logo_b64=logo_b64,
        folder_b64=folder_b64,
        file_b64=file_b64,
        slug=slug,
        file_info=file_info,
        filename=os.path.basename(zip_filename),
        filesize=zip_filesize,
        filesize_human=common.human_readable_filesize(zip_filesize)))
    for header, value in security_headers:
        r.headers.set(header, value)
    return r


# 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.
client_cancel = False


@app.route("/<slug_candidate>/download")
def download(slug_candidate):
    """
    Download the zip file.
    """
    check_slug_candidate(slug_candidate)

    # Deny new downloads if "Stop After First Download" is checked and there is
    # currently a download
    global stay_open, download_in_progress, done
    deny_download = not stay_open and download_in_progress
    if deny_download:
        r = make_response(render_template_string(
            open(common.get_resource_path('html/denied.html')).read(),
            favicon_b64=favicon_b64
        ))
        for header,value in security_headers:
            r.headers.set(header, value)
        return r

    global download_count

    # each download has a unique id
    download_id = download_count
    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
    add_request(REQUEST_DOWNLOAD, path, {'id': download_id})

    dirname = os.path.dirname(zip_filename)
    basename = os.path.basename(zip_filename)

    def generate():
        # The user hasn't canceled the download
        global client_cancel, gui_mode
        client_cancel = False

        # Starting a new download
        global stay_open, download_in_progress, done
        if not stay_open:
            download_in_progress = True

        chunk_size = 102400  # 100kb

        fp = open(zip_filename, 'rb')
        done = False
        canceled = False
        while not done:
            # The user has canceled the download, so stop serving the file
            if client_cancel:
                add_request(REQUEST_CANCELED, path, {'id': download_id})
                break

            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 / zip_filesize) * 100

                    # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304)
                    plat = common.get_platform()
                    if not gui_mode or plat == 'Linux' or plat == 'BSD':
                        sys.stdout.write(
                            "\r{0:s}, {1:.2f}%          ".format(common.human_readable_filesize(downloaded_bytes), percent))
                        sys.stdout.flush()

                    add_request(REQUEST_PROGRESS, path, {'id': download_id, 'bytes': downloaded_bytes})
                    done = False
                except:
                    # looks like the download was canceled
                    done = True
                    canceled = True

                    # tell the GUI the download has canceled
                    add_request(REQUEST_CANCELED, path, {'id': download_id})

        fp.close()

        if common.get_platform() != 'Darwin':
            sys.stdout.write("\n")

        # Download is finished
        if not stay_open:
            download_in_progress = False

        # Close the server, if necessary
        if not stay_open and not canceled:
            print(strings._("closing_automatically"))
            if shutdown_func is None:
                raise RuntimeError('Not running with the Werkzeug Server')
            shutdown_func()

    r = Response(generate())
    r.headers.set('Content-Length', zip_filesize)
    r.headers.set('Content-Disposition', 'attachment', filename=basename)
    for header,value in security_headers:
        r.headers.set(header, value)
    # 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


@app.errorhandler(404)
def page_not_found(e):
    """
    404 error page.
    """
    add_request(REQUEST_OTHER, request.path)

    global error404_count
    if request.path != '/favicon.ico':
        error404_count += 1
        if error404_count == 20:
            add_request(REQUEST_RATE_LIMIT, request.path)
            force_shutdown()
            print(strings._('error_rate_limit'))

    r = make_response(render_template_string(
        open(common.get_resource_path('html/404.html')).read(),
        favicon_b64=favicon_b64
    ), 404)
    for header, value in security_headers:
        r.headers.set(header, value)
    return r


# shutting down the server only works within the context of flask, so the easiest way to do it is over http
shutdown_slug = common.random_string(16)


@app.route("/<slug_candidate>/shutdown")
def shutdown(slug_candidate):
    """
    Stop the flask web server, from the context of an http request.
    """
    check_slug_candidate(slug_candidate, shutdown_slug)
    force_shutdown()
    return ""


def force_shutdown():
    """
    Stop the flask web server, from the context of the flask app.
    """
    # shutdown the flask service
    func = request.environ.get('werkzeug.server.shutdown')
    if func is None:
        raise RuntimeError('Not running with the Werkzeug Server')
    func()


def start(port, stay_open=False, persistent_slug=''):
    """
    Start the flask web server.
    """
    generate_slug(persistent_slug)

    set_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'

    app.run(host=host, port=port, threaded=True)


def stop(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
    global client_cancel
    client_cancel = True

    # to stop flask, load http://127.0.0.1:<port>/<shutdown_slug>/shutdown
    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(shutdown_slug))
    except:
        try:
            urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, shutdown_slug)).read()
        except:
            pass