Completed
Push — 5.2-unstable ( 1c6b27 )
by Felipe A.
01:00
created

get_cached_response()   C

Complexity

Conditions 7

Size

Total Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
c 0
b 0
f 0
dl 0
loc 28
rs 5.5
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
import logging
5
import os
6
import os.path
7
import json
8
import base64
9
import io
10
import gzip
11
import time
12
13
from flask import Flask, request, render_template, redirect, send_file, \
14
                  url_for, send_from_directory, stream_with_context, \
15
                  make_response, current_app
16
from werkzeug.exceptions import NotFound
17
18
from .__meta__ import __app__, __version__, __license__, __author__  # noqa
19
from .file import Node, OutsideRemovableBase, OutsideDirectoryBase, \
20
                  secure_filename
21
from .cache import cachedview
22
from . import compat
23
from . import manager
24
25
__basedir__ = os.path.abspath(os.path.dirname(compat.fsdecode(__file__)))
26
27
logger = logging.getLogger(__name__)
28
29
app = Flask(
30
    __name__,
31
    static_url_path='/static',
32
    static_folder=os.path.join(__basedir__, 'static'),
33
    template_folder=os.path.join(__basedir__, 'templates')
34
    )
35
app.config.update(
36
    directory_base=compat.getcwd(),
37
    directory_start=compat.getcwd(),
38
    directory_remove=None,
39
    directory_upload=None,
40
    directory_tar_buffsize=262144,
41
    directory_downloadable=True,
42
    use_binary_multiples=True,
43
    plugin_modules=[],
44
    plugin_namespaces=(
45
        'browsepy.plugin',
46
        'browsepy_',
47
        '',
48
        ),
49
    disk_cache_enable=True,
50
    cache_class='browsepy.cache:SimpleLRUCache',
51
    cache_kwargs={'maxsize': 32},
52
    cache_browse_key='view/browse<{sort}>/{path}',
53
    browse_sort_properties=['text', 'type', 'modified', 'size']
54
    )
55
app.jinja_env.add_extension('browsepy.extensions.HTMLCompress')
56
app.jinja_env.add_extension('browsepy.extensions.JSONCompress')
57
58
if "BROWSEPY_SETTINGS" in os.environ:
59
    app.config.from_envvar("BROWSEPY_SETTINGS")
60
61
plugin_manager = manager.PluginManager(app)
62
63
64
def iter_cookie_browse_sorting():
65
    '''
66
    Get sorting-cookie data of current request.
67
68
    :yields: tuple of path and sorting property
69
    :ytype: 2-tuple of strings
70
    '''
71
    try:
72
        data = request.cookies.get('browse-sorting', 'e30=').encode('ascii')
73
        valid = current_app.config.get('browse_sort_properties', ())
74
        for path, prop in json.loads(base64.b64decode(data).decode('utf-8')):
75
            if prop.startswith('-'):
76
                if prop[1:] in valid:
77
                    yield path, prop
78
            elif prop in valid:
79
                yield path, prop
80
    except (ValueError, TypeError, KeyError) as e:
81
        logger.exception(e)
82
83
84
def get_cookie_browse_sorting(path, default):
85
    '''
86
    Get sorting-cookie data for path of current request.
87
88
    :returns: sorting property
89
    :rtype: string
90
    '''
91
    for cpath, cprop in iter_cookie_browse_sorting():
92
        if path == cpath:
93
            return cprop
94
    return default
95
96
97
def browse_sortkey_reverse(prop):
98
    '''
99
    Get sorting function for browse
100
101
    :returns: tuple with sorting gunction and reverse bool
102
    :rtype: tuple of a dict and a bool
103
    '''
104
    if prop.startswith('-'):
105
        prop = prop[1:]
106
        reverse = True
107
    else:
108
        reverse = False
109
110
    if prop == 'text':
111
        return (
112
            lambda x: (
113
                x.is_directory == reverse,
114
                x.link.text.lower() if x.link and x.link.text else x.name
115
                ),
116
            reverse
117
            )
118
    if prop == 'size':
119
        return (
120
            lambda x: (
121
                x.is_directory == reverse,
122
                x.stats.st_size
123
                ),
124
            reverse
125
            )
126
    return (
127
        lambda x: (
128
            x.is_directory == reverse,
129
            getattr(x, prop, None)
130
            ),
131
        reverse
132
        )
133
134
135
def cache_template_stream(key, stream):
136
    '''
137
    Yield and cache jinja template stream.
138
139
    :param key: cache key
140
    :type key: str
141
    :param stream: jinja template stream
142
    :type stream: iterable
143
    :yields: rendered jinja template chunks
144
    :ytype: str
145
    '''
146
    ts = time.time()
147
    buffer = io.BytesIO()
148
    with gzip.GzipFile(mode='wb', fileobj=buffer) as f:
149
        for part in stream:
150
            yield part
151
            f.write(part.encode('utf-8'))
152
    cache = current_app.extensions['plugin_manager'].cache
153
    cache.set(key, (buffer.getvalue(), request.url_root, ts))
154
155
156
def stream_template(template_name, **context):
157
    '''
158
    Some templates can be huge, this function returns an streaming response,
159
    sending the content in chunks and preventing from timeout.
160
161
    :param template_name: template
162
    :param **context: parameters for templates.
163
    :yields: HTML strings
164
    '''
165
    cache_key = context.pop('cache_key', None)
166
    app.update_template_context(context)
167
    stream = app.jinja_env.get_template(template_name).generate(context)
168
    if cache_key:
169
        stream = cache_template_stream(cache_key, stream)
170
    return current_app.response_class(stream_with_context(stream))
171
172
173
def get_cached_response(key, cancel_key=None):
174
    '''
175
    Get cached response object from key.
176
177
    :param key: cache key
178
    :type key: str
179
    :return: response object
180
    :rtype: flask.Response
181
    '''
182
    cache = current_app.extensions['plugin_manager'].cache
183
    cached, mints = cache.get_many(
184
        key,
185
        cancel_key or 'meta/cancel/{}'.format(key)
186
        )
187
    if not cached:
188
        return
189
    data, url_root, ts = cached
190
    if data and url_root < request.url_root and (ts is None or ts > mints):
191
        if 'gzip' in request.headers.get('Accept-Encoding', '').lower():
192
            response = current_app.response_class(data)
193
            response.headers['Content-Encoding'] = 'gzip'
194
            response.headers['Vary'] = 'Accept-Encoding'
195
            response.headers['Content-Length'] = len(data)
196
            return response
197
        return send_file(
198
            gzip.GzipFile(mode='rb', fileobj=io.BytesIO(data)),
199
            mimetype='text/html',
200
            as_attachment=False
201
            )
202
203
204
@app.context_processor
205
def template_globals():
206
    return {
207
        'manager': app.extensions['plugin_manager'],
208
        'len': len,
209
        }
210
211
212
@app.route('/app/browserconfig.xml', endpoint='msapplication-config')
213
@cachedview
214
def msapplication_config():
215
    return render_template('msapplication-config.xml')
216
217
218
@app.route('/app/manifest.json', endpoint='android-manifest')
219
@cachedview
220
def android_manifest():
221
    return render_template('android-manifest.json')
222
223
224
@app.route('/sort/<string:property>', defaults={"path": ""})
225
@app.route('/sort/<string:property>/<path:path>')
226
def sort(property, path):
227
    try:
228
        directory = Node.from_urlpath(path)
229
    except OutsideDirectoryBase:
230
        return NotFound()
231
232
    if not directory.is_directory:
233
        return NotFound()
234
235
    data = [
236
        (cpath, cprop)
237
        for cpath, cprop in iter_cookie_browse_sorting()
238
        if cpath != path
239
        ]
240
    data.append((path, property))
241
    raw_data = base64.b64encode(json.dumps(data).encode('utf-8'))
242
243
    # prevent cookie becoming too large
244
    while len(raw_data) > 3975:  # 4000 - len('browse-sorting=""; Path=/')
245
        data.pop(0)
246
        raw_data = base64.b64encode(json.dumps(data).encode('utf-8'))
247
248
    response = redirect(url_for(".browse", path=directory.urlpath))
249
    response.set_cookie('browse-sorting', raw_data)
250
    return response
251
252
253
@app.route("/browse", defaults={"path": ""})
254
@app.route('/browse/<path:path>')
255
def browse(path):
256
    sort_property = get_cookie_browse_sorting(path, 'text')
257
258
    if current_app.config['disk_cache_enable']:
259
        cache_key = current_app.config.get('cache_browse_key', '').format(
260
            sort=sort_property,
261
            path=path
262
            )
263
        cached_response = get_cached_response(cache_key)
264
        if cached_response:
265
            return cached_response
266
    else:
267
        cache_key = None  # disables response cache
268
269
    sort_fnc, sort_reverse = browse_sortkey_reverse(sort_property)
270
271
    try:
272
        directory = Node.from_urlpath(path)
273
        if directory.is_directory:
274
            return stream_template(
275
                'browse.html',
276
                cache_key=cache_key,
277
                file=directory,
278
                sort_property=sort_property,
279
                sort_fnc=sort_fnc,
280
                sort_reverse=sort_reverse
281
                )
282
    except OutsideDirectoryBase:
283
        pass
284
    return NotFound()
285
286
287
@app.route('/open/<path:path>', endpoint="open")
288
def open_file(path):
289
    try:
290
        file = Node.from_urlpath(path)
291
        if file.is_file:
292
            return send_from_directory(file.parent.path, file.name)
293
    except OutsideDirectoryBase:
294
        pass
295
    return NotFound()
296
297
298
@app.route("/download/file/<path:path>")
299
def download_file(path):
300
    try:
301
        file = Node.from_urlpath(path)
302
        if file.is_file:
303
            return file.download()
304
    except OutsideDirectoryBase:
305
        pass
306
    return NotFound()
307
308
309
@app.route("/download/directory/<path:path>.tgz")
310
def download_directory(path):
311
    try:
312
        directory = Node.from_urlpath(path)
313
        if directory.is_directory:
314
            return directory.download()
315
    except OutsideDirectoryBase:
316
        pass
317
    return NotFound()
318
319
320
@app.route("/remove/<path:path>", methods=("GET", "POST"))
321
def remove(path):
322
    try:
323
        file = Node.from_urlpath(path)
324
    except OutsideDirectoryBase:
325
        return NotFound()
326
    if request.method == 'GET':
327
        if not file.can_remove:
328
            return NotFound()
329
        return render_template('remove.html', file=file)
330
    parent = file.parent
331
    if parent is None:
332
        # base is not removable
333
        return NotFound()
334
335
    try:
336
        file.remove()
337
    except OutsideRemovableBase:
338
        return NotFound()
339
340
    return redirect(url_for(".browse", path=parent.urlpath))
341
342
343
@app.route("/upload", defaults={'path': ''}, methods=("POST",))
344
@app.route("/upload/<path:path>", methods=("POST",))
345
def upload(path):
346
    try:
347
        directory = Node.from_urlpath(path)
348
    except OutsideDirectoryBase:
349
        return NotFound()
350
351
    if not directory.is_directory or not directory.can_upload:
352
        return NotFound()
353
354
    for v in request.files.listvalues():
355
        for f in v:
356
            filename = secure_filename(f.filename)
357
            if filename:
358
                filename = directory.choose_filename(filename)
359
                filepath = os.path.join(directory.path, filename)
360
                f.save(filepath)
361
    return redirect(url_for(".browse", path=directory.urlpath))
362
363
364
@app.route("/")
365
def index():
366
    path = app.config["directory_start"] or app.config["directory_base"]
367
    try:
368
        urlpath = Node(path).urlpath
369
    except OutsideDirectoryBase:
370
        return NotFound()
371
    return browse(urlpath)
372
373
374
@app.after_request
375
def page_not_found(response):
376
    if response.status_code == 404:
377
        return make_response((render_template('404.html'), 404))
378
    return response
379
380
381
@app.errorhandler(404)
382
def page_not_found_error(e):
383
    return render_template('404.html'), 404
384
385
386
@app.errorhandler(500)
387
def internal_server_error(e):  # pragma: no cover
388
    logger.exception(e)
389
    return getattr(e, 'message', 'Internal server error'), 500
390