.oO SearXNG Developer Documentation Oo.
Loading...
Searching...
No Matches
webapp.py
Go to the documentation of this file.
1#!/usr/bin/env python
2# SPDX-License-Identifier: AGPL-3.0-or-later
3"""WebbApp
4
5"""
6# pylint: disable=use-dict-literal
7
8import hashlib
9import hmac
10import json
11import os
12import sys
13import base64
14
15from timeit import default_timer
16from html import escape
17from io import StringIO
18import typing
19from typing import List, Dict, Iterable
20
21import urllib
22import urllib.parse
23from urllib.parse import urlencode, urlparse, unquote
24
25import httpx
26
27from pygments import highlight
28from pygments.lexers import get_lexer_by_name
29from pygments.formatters import HtmlFormatter # pylint: disable=no-name-in-module
30
31import flask
32
33from flask import (
34 Flask,
35 render_template,
36 url_for,
37 make_response,
38 redirect,
39 send_from_directory,
40)
41from flask.wrappers import Response
42from flask.json import jsonify
43
44from flask_babel import (
45 Babel,
46 gettext,
47 format_decimal,
48)
49
50from searx import (
51 logger,
52 get_setting,
53 settings,
54 searx_debug,
55)
56
57from searx import infopage
58from searx import limiter
59from searx.botdetection import link_token
60
61from searx.data import ENGINE_DESCRIPTIONS
62from searx.results import Timing
63from searx.settings_defaults import OUTPUT_FORMATS
64from searx.settings_loader import DEFAULT_SETTINGS_FILE
65from searx.exceptions import SearxParameterException
66from searx.engines import (
67 DEFAULT_CATEGORY,
68 categories,
69 engines,
70 engine_shortcuts,
71)
72
73from searx import webutils
74from searx.webutils import (
75 highlight_content,
76 get_static_files,
77 get_result_templates,
78 get_themes,
79 exception_classname_to_text,
80 new_hmac,
81 is_hmac_of,
82 is_flask_run_cmdline,
83 group_engines_in_tab,
84)
85from searx.webadapter import (
86 get_search_query_from_webapp,
87 get_selected_categories,
88 parse_lang,
89)
90from searx.utils import (
91 gen_useragent,
92 dict_subset,
93)
94from searx.version import VERSION_STRING, GIT_URL, GIT_BRANCH
95from searx.query import RawTextQuery
96from searx.plugins import Plugin, plugins, initialize as plugin_initialize
97from searx.plugins.oa_doi_rewrite import get_doi_resolver
98from searx.preferences import (
99 Preferences,
100 ClientPref,
101 ValidationException,
102)
103from searx.answerers import (
104 answerers,
105 ask,
106)
107from searx.metrics import (
108 get_engines_stats,
109 get_engine_errors,
110 get_reliabilities,
111 histogram,
112 counter,
113)
114from searx.flaskfix import patch_application
115
116from searx.locales import (
117 LOCALE_NAMES,
118 RTL_LOCALES,
119 localeselector,
120 locales_initialize,
121 match_locale,
122)
123
124# renaming names from searx imports ...
125from searx.autocomplete import search_autocomplete, backends as autocomplete_backends
126from searx import favicons
127
128from searx.redisdb import initialize as redis_initialize
129from searx.sxng_locales import sxng_locales
130from searx.search import SearchWithPlugins, initialize as search_initialize
131from searx.network import stream as http_stream, set_context_network_name
132from searx.search.checker import get_result as checker_get_result
133
134logger = logger.getChild('webapp')
135
136# check secret_key
137if not searx_debug and settings['server']['secret_key'] == 'ultrasecretkey':
138 logger.error('server.secret_key is not changed. Please use something else instead of ultrasecretkey.')
139 sys.exit(1)
140
141# about static
142logger.debug('static directory is %s', settings['ui']['static_path'])
143static_files = get_static_files(settings['ui']['static_path'])
144
145# about templates
146logger.debug('templates directory is %s', settings['ui']['templates_path'])
147default_theme = settings['ui']['default_theme']
148templates_path = settings['ui']['templates_path']
149themes = get_themes(templates_path)
150result_templates = get_result_templates(templates_path)
151
152STATS_SORT_PARAMETERS = {
153 'name': (False, 'name', ''),
154 'score': (True, 'score_per_result', 0),
155 'result_count': (True, 'result_count', 0),
156 'time': (False, 'total', 0),
157 'reliability': (False, 'reliability', 100),
158}
159
160# Flask app
161app = Flask(__name__, static_folder=settings['ui']['static_path'], template_folder=templates_path)
162
163app.jinja_env.trim_blocks = True
164app.jinja_env.lstrip_blocks = True
165app.jinja_env.add_extension('jinja2.ext.loopcontrols') # pylint: disable=no-member
166app.jinja_env.filters['group_engines_in_tab'] = group_engines_in_tab # pylint: disable=no-member
167app.secret_key = settings['server']['secret_key']
168
169
170class ExtendedRequest(flask.Request):
171 """This class is never initialized and only used for type checking."""
172
173 preferences: Preferences
174 errors: List[str]
175 user_plugins: List[Plugin]
176 form: Dict[str, str]
177 start_time: float
178 render_time: float
179 timings: List[Timing]
180
181
182request = typing.cast(ExtendedRequest, flask.request)
183
184
186 locale = localeselector()
187 logger.debug("%s uses locale `%s`", urllib.parse.quote(request.url), locale)
188 return locale
189
190
191babel = Babel(app, locale_selector=get_locale)
192
193
194def _get_browser_language(req, lang_list):
195 client = ClientPref.from_http_request(req)
196 locale = match_locale(client.locale_tag, lang_list, fallback='en')
197 return locale
198
199
201 """Get locale name for <html lang="...">
202 Chrom* browsers don't detect the language when there is a subtag (ie a territory).
203 For example "zh-TW" is detected but not "zh-Hant-TW".
204 This function returns a locale without the subtag.
205 """
206 parts = locale.split('-')
207 return parts[0].lower() + '-' + parts[-1].upper()
208
209
210# code-highlighter
211@app.template_filter('code_highlighter')
212def code_highlighter(codelines, language=None):
213 if not language:
214 language = 'text'
215
216 try:
217 # find lexer by programming language
218 lexer = get_lexer_by_name(language, stripall=True)
219
220 except Exception as e: # pylint: disable=broad-except
221 logger.warning("pygments lexer: %s " % e)
222 # if lexer is not found, using default one
223 lexer = get_lexer_by_name('text', stripall=True)
224
225 html_code = ''
226 tmp_code = ''
227 last_line = None
228 line_code_start = None
229
230 # parse lines
231 for line, code in codelines:
232 if not last_line:
233 line_code_start = line
234
235 # new codeblock is detected
236 if last_line is not None and last_line + 1 != line:
237
238 # highlight last codepart
239 formatter = HtmlFormatter(linenos='inline', linenostart=line_code_start, cssclass="code-highlight")
240 html_code = html_code + highlight(tmp_code, lexer, formatter)
241
242 # reset conditions for next codepart
243 tmp_code = ''
244 line_code_start = line
245
246 # add codepart
247 tmp_code += code + '\n'
248
249 # update line
250 last_line = line
251
252 # highlight last codepart
253 formatter = HtmlFormatter(linenos='inline', linenostart=line_code_start, cssclass="code-highlight")
254 html_code = html_code + highlight(tmp_code, lexer, formatter)
255
256 return html_code
257
258
259def get_result_template(theme_name: str, template_name: str):
260 themed_path = theme_name + '/result_templates/' + template_name
261 if themed_path in result_templates:
262 return themed_path
263 return 'result_templates/' + template_name
264
265
266def custom_url_for(endpoint: str, **values):
267 suffix = ""
268 if endpoint == 'static' and values.get('filename'):
269 file_hash = static_files.get(values['filename'])
270 if not file_hash:
271 # try file in the current theme
272 theme_name = request.preferences.get_value('theme')
273 filename_with_theme = "themes/{}/{}".format(theme_name, values['filename'])
274 file_hash = static_files.get(filename_with_theme)
275 if file_hash:
276 values['filename'] = filename_with_theme
277 if get_setting('ui.static_use_hash') and file_hash:
278 suffix = "?" + file_hash
279 if endpoint == 'info' and 'locale' not in values:
280 locale = request.preferences.get_value('locale')
281 if infopage.INFO_PAGES.get_page(values['pagename'], locale) is None:
282 locale = infopage.INFO_PAGES.locale_default
283 values['locale'] = locale
284 return url_for(endpoint, **values) + suffix
285
286
287def morty_proxify(url: str):
288 if url.startswith('//'):
289 url = 'https:' + url
290
291 if not settings['result_proxy']['url']:
292 return url
293
294 url_params = dict(mortyurl=url)
295
296 if settings['result_proxy']['key']:
297 url_params['mortyhash'] = hmac.new(settings['result_proxy']['key'], url.encode(), hashlib.sha256).hexdigest()
298
299 return '{0}?{1}'.format(settings['result_proxy']['url'], urlencode(url_params))
300
301
302def image_proxify(url: str):
303
304 if url.startswith('//'):
305 url = 'https:' + url
306
307 if not request.preferences.get_value('image_proxy'):
308 return url
309
310 if url.startswith('data:image/'):
311 # 50 is an arbitrary number to get only the beginning of the image.
312 partial_base64 = url[len('data:image/') : 50].split(';')
313 if (
314 len(partial_base64) == 2
315 and partial_base64[0] in ['gif', 'png', 'jpeg', 'pjpeg', 'webp', 'tiff', 'bmp']
316 and partial_base64[1].startswith('base64,')
317 ):
318 return url
319 return None
320
321 if settings['result_proxy']['url']:
322 return morty_proxify(url)
323
324 h = new_hmac(settings['server']['secret_key'], url.encode())
325
326 return '{0}?{1}'.format(url_for('image_proxy'), urlencode(dict(url=url.encode(), h=h)))
327
328
330 return {
331 # when there is autocompletion
332 'no_item_found': gettext('No item found'),
333 # /preferences: the source of the engine description (wikipedata, wikidata, website)
334 'Source': gettext('Source'),
335 # infinite scroll
336 'error_loading_next_page': gettext('Error loading the next page'),
337 }
338
339
340def get_enabled_categories(category_names: Iterable[str]):
341 """The categories in ``category_names```for which there is no active engine
342 are filtered out and a reduced list is returned."""
343
344 enabled_engines = [item[0] for item in request.preferences.engines.get_enabled()]
345 enabled_categories = set()
346 for engine_name in enabled_engines:
347 enabled_categories.update(engines[engine_name].categories)
348 return [x for x in category_names if x in enabled_categories]
349
350
351def get_pretty_url(parsed_url: urllib.parse.ParseResult):
352 path = parsed_url.path
353 path = path[:-1] if len(path) > 0 and path[-1] == '/' else path
354 path = unquote(path.replace("/", " › "))
355 return [parsed_url.scheme + "://" + parsed_url.netloc, path]
356
357
359 req_pref = request.preferences
360 return {
361 'autocomplete_provider': req_pref.get_value('autocomplete'),
362 'autocomplete_min': get_setting('search.autocomplete_min'),
363 'http_method': req_pref.get_value('method'),
364 'infinite_scroll': req_pref.get_value('infinite_scroll'),
365 'translations': get_translations(),
366 'search_on_category_select': req_pref.get_value('search_on_category_select'),
367 'hotkeys': req_pref.get_value('hotkeys'),
368 'theme_static_path': custom_url_for('static', filename='themes/simple'),
369 }
370
371
372def render(template_name: str, **kwargs):
373 # pylint: disable=too-many-statements
374 kwargs['client_settings'] = str(
375 base64.b64encode(
376 bytes(
377 json.dumps(get_client_settings()),
378 encoding='utf-8',
379 )
380 ),
381 encoding='utf-8',
382 )
383
384 # values from the HTTP requests
385 kwargs['endpoint'] = 'results' if 'q' in kwargs else request.endpoint
386 kwargs['cookies'] = request.cookies
387 kwargs['errors'] = request.errors
388 kwargs['link_token'] = link_token.get_token()
389
390 # values from the preferences
391 kwargs['preferences'] = request.preferences
392 kwargs['autocomplete'] = request.preferences.get_value('autocomplete')
393 kwargs['favicon_resolver'] = request.preferences.get_value('favicon_resolver')
394 kwargs['infinite_scroll'] = request.preferences.get_value('infinite_scroll')
395 kwargs['search_on_category_select'] = request.preferences.get_value('search_on_category_select')
396 kwargs['hotkeys'] = request.preferences.get_value('hotkeys')
397 kwargs['results_on_new_tab'] = request.preferences.get_value('results_on_new_tab')
398 kwargs['advanced_search'] = request.preferences.get_value('advanced_search')
399 kwargs['query_in_title'] = request.preferences.get_value('query_in_title')
400 kwargs['safesearch'] = str(request.preferences.get_value('safesearch'))
401 kwargs['theme'] = request.preferences.get_value('theme')
402 kwargs['method'] = request.preferences.get_value('method')
403 kwargs['categories_as_tabs'] = list(settings['categories_as_tabs'].keys())
404 kwargs['categories'] = get_enabled_categories(settings['categories_as_tabs'].keys())
405 kwargs['DEFAULT_CATEGORY'] = DEFAULT_CATEGORY
406
407 # i18n
408 kwargs['sxng_locales'] = [l for l in sxng_locales if l[0] in settings['search']['languages']]
409
410 locale = request.preferences.get_value('locale')
411 kwargs['locale_rfc5646'] = _get_locale_rfc5646(locale)
412
413 if locale in RTL_LOCALES and 'rtl' not in kwargs:
414 kwargs['rtl'] = True
415
416 if 'current_language' not in kwargs:
417 kwargs['current_language'] = parse_lang(request.preferences, {}, RawTextQuery('', []))
418
419 # values from settings
420 kwargs['search_formats'] = [x for x in settings['search']['formats'] if x != 'html']
421 kwargs['instance_name'] = get_setting('general.instance_name')
422 kwargs['searx_version'] = VERSION_STRING
423 kwargs['searx_git_url'] = GIT_URL
424 kwargs['enable_metrics'] = get_setting('general.enable_metrics')
425 kwargs['get_setting'] = get_setting
426 kwargs['get_pretty_url'] = get_pretty_url
427
428 # values from settings: donation_url
429 donation_url = get_setting('general.donation_url')
430 if donation_url is True:
431 donation_url = custom_url_for('info', pagename='donate')
432 kwargs['donation_url'] = donation_url
433
434 # helpers to create links to other pages
435 kwargs['url_for'] = custom_url_for # override url_for function in templates
436 kwargs['image_proxify'] = image_proxify
437 kwargs['favicon_url'] = favicons.favicon_url
438 kwargs['proxify'] = morty_proxify if settings['result_proxy']['url'] is not None else None
439 kwargs['proxify_results'] = settings['result_proxy']['proxify_results']
440 kwargs['cache_url'] = settings['ui']['cache_url']
441 kwargs['get_result_template'] = get_result_template
442 kwargs['doi_resolver'] = get_doi_resolver(request.preferences)
443 kwargs['opensearch_url'] = (
444 url_for('opensearch')
445 + '?'
446 + urlencode(
447 {
448 'method': request.preferences.get_value('method'),
449 'autocomplete': request.preferences.get_value('autocomplete'),
450 }
451 )
452 )
453 kwargs['urlparse'] = urlparse
454
455 # scripts from plugins
456 kwargs['scripts'] = set()
457 for plugin in request.user_plugins:
458 for script in plugin.js_dependencies:
459 kwargs['scripts'].add(script)
460
461 # styles from plugins
462 kwargs['styles'] = set()
463 for plugin in request.user_plugins:
464 for css in plugin.css_dependencies:
465 kwargs['styles'].add(css)
466
467 start_time = default_timer()
468 result = render_template('{}/{}'.format(kwargs['theme'], template_name), **kwargs)
469 request.render_time += default_timer() - start_time # pylint: disable=assigning-non-slot
470
471 return result
472
473
474@app.before_request
476 request.start_time = default_timer() # pylint: disable=assigning-non-slot
477 request.render_time = 0 # pylint: disable=assigning-non-slot
478 request.timings = [] # pylint: disable=assigning-non-slot
479 request.errors = [] # pylint: disable=assigning-non-slot
480
481 client_pref = ClientPref.from_http_request(request)
482 # pylint: disable=redefined-outer-name
483 preferences = Preferences(themes, list(categories.keys()), engines, plugins, client_pref)
484
485 user_agent = request.headers.get('User-Agent', '').lower()
486 if 'webkit' in user_agent and 'android' in user_agent:
487 preferences.key_value_settings['method'].value = 'GET'
488 request.preferences = preferences # pylint: disable=assigning-non-slot
489
490 try:
491 preferences.parse_dict(request.cookies)
492
493 except Exception as e: # pylint: disable=broad-except
494 logger.exception(e, exc_info=True)
495 request.errors.append(gettext('Invalid settings, please edit your preferences'))
496
497 # merge GET, POST vars
498 # request.form
499 request.form = dict(request.form.items()) # pylint: disable=assigning-non-slot
500 for k, v in request.args.items():
501 if k not in request.form:
502 request.form[k] = v
503
504 if request.form.get('preferences'):
505 preferences.parse_encoded_data(request.form['preferences'])
506 else:
507 try:
508 preferences.parse_dict(request.form)
509 except Exception as e: # pylint: disable=broad-except
510 logger.exception(e, exc_info=True)
511 request.errors.append(gettext('Invalid settings'))
512
513 # language is defined neither in settings nor in preferences
514 # use browser headers
515 if not preferences.get_value("language"):
516 language = _get_browser_language(request, settings['search']['languages'])
517 preferences.parse_dict({"language": language})
518 logger.debug('set language %s (from browser)', preferences.get_value("language"))
519
520 # UI locale is defined neither in settings nor in preferences
521 # use browser headers
522 if not preferences.get_value("locale"):
523 locale = _get_browser_language(request, LOCALE_NAMES.keys())
524 preferences.parse_dict({"locale": locale})
525 logger.debug('set locale %s (from browser)', preferences.get_value("locale"))
526
527 # request.user_plugins
528 request.user_plugins = [] # pylint: disable=assigning-non-slot
529 allowed_plugins = preferences.plugins.get_enabled()
530 disabled_plugins = preferences.plugins.get_disabled()
531 for plugin in plugins:
532 if (plugin.default_on and plugin.id not in disabled_plugins) or plugin.id in allowed_plugins:
533 request.user_plugins.append(plugin)
534
535
536@app.after_request
537def add_default_headers(response: flask.Response):
538 # set default http headers
539 for header, value in settings['server']['default_http_headers'].items():
540 if header in response.headers:
541 continue
542 response.headers[header] = value
543 return response
544
545
546@app.after_request
547def post_request(response: flask.Response):
548 total_time = default_timer() - request.start_time
549 timings_all = [
550 'total;dur=' + str(round(total_time * 1000, 3)),
551 'render;dur=' + str(round(request.render_time * 1000, 3)),
552 ]
553 if len(request.timings) > 0:
554 timings = sorted(request.timings, key=lambda t: t.total)
555 timings_total = [
556 'total_' + str(i) + '_' + t.engine + ';dur=' + str(round(t.total * 1000, 3)) for i, t in enumerate(timings)
557 ]
558 timings_load = [
559 'load_' + str(i) + '_' + t.engine + ';dur=' + str(round(t.load * 1000, 3))
560 for i, t in enumerate(timings)
561 if t.load
562 ]
563 timings_all = timings_all + timings_total + timings_load
564 response.headers.add('Server-Timing', ', '.join(timings_all))
565 return response
566
567
568def index_error(output_format: str, error_message: str):
569 if output_format == 'json':
570 return Response(json.dumps({'error': error_message}), mimetype='application/json')
571 if output_format == 'csv':
572 response = Response('', mimetype='application/csv')
573 cont_disp = 'attachment;Filename=searx.csv'
574 response.headers.add('Content-Disposition', cont_disp)
575 return response
576
577 if output_format == 'rss':
578 response_rss = render(
579 'opensearch_response_rss.xml',
580 results=[],
581 q=request.form['q'] if 'q' in request.form else '',
582 number_of_results=0,
583 error_message=error_message,
584 )
585 return Response(response_rss, mimetype='text/xml')
586
587 # html
588 request.errors.append(gettext('search error'))
589 return render(
590 # fmt: off
591 'index.html',
592 selected_categories=get_selected_categories(request.preferences, request.form),
593 # fmt: on
594 )
595
596
597@app.route('/', methods=['GET', 'POST'])
598def index():
599 """Render index page."""
600
601 # redirect to search if there's a query in the request
602 if request.form.get('q'):
603 query = ('?' + request.query_string.decode()) if request.query_string else ''
604 return redirect(url_for('search') + query, 308)
605
606 return render(
607 # fmt: off
608 'index.html',
609 selected_categories=get_selected_categories(request.preferences, request.form),
610 current_locale = request.preferences.get_value("locale"),
611 # fmt: on
612 )
613
614
615@app.route('/healthz', methods=['GET'])
616def health():
617 return Response('OK', mimetype='text/plain')
618
619
620@app.route('/client<token>.css', methods=['GET', 'POST'])
621def client_token(token=None):
622 link_token.ping(request, token)
623 return Response('', mimetype='text/css')
624
625
626@app.route('/search', methods=['GET', 'POST'])
627def search():
628 """Search query in q and return results.
629
630 Supported outputs: html, json, csv, rss.
631 """
632 # pylint: disable=too-many-locals, too-many-return-statements, too-many-branches
633 # pylint: disable=too-many-statements
634
635 # output_format
636 output_format = request.form.get('format', 'html')
637 if output_format not in OUTPUT_FORMATS:
638 output_format = 'html'
639
640 if output_format not in settings['search']['formats']:
641 flask.abort(403)
642
643 # check if there is query (not None and not an empty string)
644 if not request.form.get('q'):
645 if output_format == 'html':
646 return render(
647 # fmt: off
648 'index.html',
649 selected_categories=get_selected_categories(request.preferences, request.form),
650 # fmt: on
651 )
652 return index_error(output_format, 'No query'), 400
653
654 # search
655 search_query = None
656 raw_text_query = None
657 result_container = None
658 try:
659 search_query, raw_text_query, _, _, selected_locale = get_search_query_from_webapp(
660 request.preferences, request.form
661 )
662 search = SearchWithPlugins(search_query, request.user_plugins, request) # pylint: disable=redefined-outer-name
663 result_container = search.search()
664
665 except SearxParameterException as e:
666 logger.exception('search error: SearxParameterException')
667 return index_error(output_format, e.message), 400
668 except Exception as e: # pylint: disable=broad-except
669 logger.exception(e, exc_info=True)
670 return index_error(output_format, gettext('search error')), 500
671
672 # 1. check if the result is a redirect for an external bang
673 if result_container.redirect_url:
674 return redirect(result_container.redirect_url)
675
676 # 2. add Server-Timing header for measuring performance characteristics of
677 # web applications
678 request.timings = result_container.get_timings() # pylint: disable=assigning-non-slot
679
680 # 3. formats without a template
681
682 if output_format == 'json':
683
684 response = webutils.get_json_response(search_query, result_container)
685 return Response(response, mimetype='application/json')
686
687 if output_format == 'csv':
688
689 csv = webutils.CSVWriter(StringIO())
690 webutils.write_csv_response(csv, result_container)
691 csv.stream.seek(0)
692
693 response = Response(csv.stream.read(), mimetype='application/csv')
694 cont_disp = 'attachment;Filename=searx_-_{0}.csv'.format(search_query.query)
695 response.headers.add('Content-Disposition', cont_disp)
696 return response
697
698 # 4. formats rendered by a template / RSS & HTML
699
700 current_template = None
701 previous_result = None
702
703 results = result_container.get_ordered_results()
704
705 if search_query.redirect_to_first_result and results:
706 return redirect(results[0]['url'], 302)
707
708 for result in results:
709 if output_format == 'html':
710 if 'content' in result and result['content']:
711 result['content'] = highlight_content(escape(result['content'][:1024]), search_query.query)
712 if 'title' in result and result['title']:
713 result['title'] = highlight_content(escape(result['title'] or ''), search_query.query)
714
715 if 'url' in result:
716 result['pretty_url'] = webutils.prettify_url(result['url'])
717 if result.get('publishedDate'): # do not try to get a date from an empty string or a None type
718 try: # test if publishedDate >= 1900 (datetime module bug)
719 result['pubdate'] = result['publishedDate'].strftime('%Y-%m-%d %H:%M:%S%z')
720 except ValueError:
721 result['publishedDate'] = None
722 else:
723 result['publishedDate'] = webutils.searxng_l10n_timespan(result['publishedDate'])
724
725 # set result['open_group'] = True when the template changes from the previous result
726 # set result['close_group'] = True when the template changes on the next result
727 if current_template != result.get('template'):
728 result['open_group'] = True
729 if previous_result:
730 previous_result['close_group'] = True # pylint: disable=unsupported-assignment-operation
731 current_template = result.get('template')
732 previous_result = result
733
734 if previous_result:
735 previous_result['close_group'] = True
736
737 # 4.a RSS
738
739 if output_format == 'rss':
740 response_rss = render(
741 'opensearch_response_rss.xml',
742 results=results,
743 answers=result_container.answers,
744 corrections=result_container.corrections,
745 suggestions=result_container.suggestions,
746 q=request.form['q'],
747 number_of_results=result_container.number_of_results,
748 )
749 return Response(response_rss, mimetype='text/xml')
750
751 # 4.b HTML
752
753 # suggestions: use RawTextQuery to get the suggestion URLs with the same bang
754 suggestion_urls = list(
755 map(
756 lambda suggestion: {'url': raw_text_query.changeQuery(suggestion).getFullQuery(), 'title': suggestion},
757 result_container.suggestions,
758 )
759 )
760
761 correction_urls = list(
762 map(
763 lambda correction: {'url': raw_text_query.changeQuery(correction).getFullQuery(), 'title': correction},
764 result_container.corrections,
765 )
766 )
767
768 # engine_timings: get engine response times sorted from slowest to fastest
769 engine_timings = sorted(result_container.get_timings(), reverse=True, key=lambda e: e.total)
770 max_response_time = engine_timings[0].total if engine_timings else None
771 engine_timings_pairs = [(timing.engine, timing.total) for timing in engine_timings]
772
773 # search_query.lang contains the user choice (all, auto, en, ...)
774 # when the user choice is "auto", search.search_query.lang contains the detected language
775 # otherwise it is equals to search_query.lang
776 return render(
777 # fmt: off
778 'results.html',
779 results = results,
780 q=request.form['q'],
781 selected_categories = search_query.categories,
782 pageno = search_query.pageno,
783 time_range = search_query.time_range or '',
784 number_of_results = format_decimal(result_container.number_of_results),
785 suggestions = suggestion_urls,
786 answers = result_container.answers,
787 corrections = correction_urls,
788 infoboxes = result_container.infoboxes,
789 engine_data = result_container.engine_data,
790 paging = result_container.paging,
791 unresponsive_engines = webutils.get_translated_errors(
792 result_container.unresponsive_engines
793 ),
794 current_locale = request.preferences.get_value("locale"),
795 current_language = selected_locale,
796 search_language = match_locale(
797 search.search_query.lang,
798 settings['search']['languages'],
799 fallback=request.preferences.get_value("language")
800 ),
801 timeout_limit = request.form.get('timeout_limit', None),
802 timings = engine_timings_pairs,
803 max_response_time = max_response_time
804 # fmt: on
805 )
806
807
808@app.route('/about', methods=['GET'])
809def about():
810 """Redirect to about page"""
811 # custom_url_for is going to add the locale
812 return redirect(custom_url_for('info', pagename='about'))
813
814
815@app.route('/info/<locale>/<pagename>', methods=['GET'])
816def info(pagename, locale):
817 """Render page of online user documentation"""
818 page = infopage.INFO_PAGES.get_page(pagename, locale)
819 if page is None:
820 flask.abort(404)
821
822 user_locale = request.preferences.get_value('locale')
823 return render(
824 'info.html',
825 all_pages=infopage.INFO_PAGES.iter_pages(user_locale, fallback_to_default=True),
826 active_page=page,
827 active_pagename=pagename,
828 )
829
830
831@app.route('/autocompleter', methods=['GET', 'POST'])
833 """Return autocompleter results"""
834
835 # run autocompleter
836 results = []
837
838 # set blocked engines
839 disabled_engines = request.preferences.engines.get_disabled()
840
841 # parse query
842 raw_text_query = RawTextQuery(request.form.get('q', ''), disabled_engines)
843 sug_prefix = raw_text_query.getQuery()
844
845 # normal autocompletion results only appear if no inner results returned
846 # and there is a query part
847 if len(raw_text_query.autocomplete_list) == 0 and len(sug_prefix) > 0:
848
849 # get SearXNG's locale and autocomplete backend from cookie
850 sxng_locale = request.preferences.get_value('language')
851 backend_name = request.preferences.get_value('autocomplete')
852
853 for result in search_autocomplete(backend_name, sug_prefix, sxng_locale):
854 # attention: this loop will change raw_text_query object and this is
855 # the reason why the sug_prefix was stored before (see above)
856 if result != sug_prefix:
857 results.append(raw_text_query.changeQuery(result).getFullQuery())
858
859 if len(raw_text_query.autocomplete_list) > 0:
860 for autocomplete_text in raw_text_query.autocomplete_list:
861 results.append(raw_text_query.get_autocomplete_full_query(autocomplete_text))
862
863 for answers in ask(raw_text_query):
864 for answer in answers:
865 results.append(str(answer['answer']))
866
867 if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
868 # the suggestion request comes from the searx search form
869 suggestions = json.dumps(results)
870 mimetype = 'application/json'
871 else:
872 # the suggestion request comes from browser's URL bar
873 suggestions = json.dumps([sug_prefix, results])
874 mimetype = 'application/x-suggestions+json'
875
876 suggestions = escape(suggestions, False)
877 return Response(suggestions, mimetype=mimetype)
878
879
880@app.route('/preferences', methods=['GET', 'POST'])
882 """Render preferences page && save user preferences"""
883
884 # pylint: disable=too-many-locals, too-many-return-statements, too-many-branches
885 # pylint: disable=too-many-statements
886
887 # save preferences using the link the /preferences?preferences=...
888 if request.args.get('preferences') or request.form.get('preferences'):
889 resp = make_response(redirect(url_for('index', _external=True)))
890 return request.preferences.save(resp)
891
892 # save preferences
893 if request.method == 'POST':
894 resp = make_response(redirect(url_for('index', _external=True)))
895 try:
896 request.preferences.parse_form(request.form)
897 except ValidationException:
898 request.errors.append(gettext('Invalid settings, please edit your preferences'))
899 return resp
900 return request.preferences.save(resp)
901
902 # render preferences
903 image_proxy = request.preferences.get_value('image_proxy') # pylint: disable=redefined-outer-name
904 disabled_engines = request.preferences.engines.get_disabled()
905 allowed_plugins = request.preferences.plugins.get_enabled()
906
907 # stats for preferences page
908 filtered_engines = dict(filter(lambda kv: request.preferences.validate_token(kv[1]), engines.items()))
909
910 engines_by_category = {}
911
912 for c in categories: # pylint: disable=consider-using-dict-items
913 engines_by_category[c] = [e for e in categories[c] if e.name in filtered_engines]
914 # sort the engines alphabetically since the order in settings.yml is meaningless.
915 list.sort(engines_by_category[c], key=lambda e: e.name)
916
917 # get first element [0], the engine time,
918 # and then the second element [1] : the time (the first one is the label)
919 stats = {} # pylint: disable=redefined-outer-name
920 max_rate95 = 0
921 for _, e in filtered_engines.items():
922 h = histogram('engine', e.name, 'time', 'total')
923 median = round(h.percentage(50), 1) if h.count > 0 else None
924 rate80 = round(h.percentage(80), 1) if h.count > 0 else None
925 rate95 = round(h.percentage(95), 1) if h.count > 0 else None
926
927 max_rate95 = max(max_rate95, rate95 or 0)
928
929 result_count_sum = histogram('engine', e.name, 'result', 'count').sum
930 successful_count = counter('engine', e.name, 'search', 'count', 'successful')
931 result_count = int(result_count_sum / float(successful_count)) if successful_count else 0
932
933 stats[e.name] = {
934 'time': median,
935 'rate80': rate80,
936 'rate95': rate95,
937 'warn_timeout': e.timeout > settings['outgoing']['request_timeout'],
938 'supports_selected_language': e.traits.is_locale_supported(
939 str(request.preferences.get_value('language') or 'all')
940 ),
941 'result_count': result_count,
942 }
943 # end of stats
944
945 # reliabilities
946 reliabilities = {}
947 engine_errors = get_engine_errors(filtered_engines)
948 checker_results = checker_get_result()
949 checker_results = (
950 checker_results['engines'] if checker_results['status'] == 'ok' and 'engines' in checker_results else {}
951 )
952 for _, e in filtered_engines.items():
953 checker_result = checker_results.get(e.name, {})
954 checker_success = checker_result.get('success', True)
955 errors = engine_errors.get(e.name) or []
956 if counter('engine', e.name, 'search', 'count', 'sent') == 0:
957 # no request
958 reliability = None
959 elif checker_success and not errors:
960 reliability = 100
961 elif 'simple' in checker_result.get('errors', {}):
962 # the basic (simple) test doesn't work: the engine is broken according to the checker
963 # even if there is no exception
964 reliability = 0
965 else:
966 # pylint: disable=consider-using-generator
967 reliability = 100 - sum([error['percentage'] for error in errors if not error.get('secondary')])
968
969 reliabilities[e.name] = {
970 'reliability': reliability,
971 'errors': [],
972 'checker': checker_results.get(e.name, {}).get('errors', {}).keys(),
973 }
974 # keep the order of the list checker_results[e.name]['errors'] and deduplicate.
975 # the first element has the highest percentage rate.
976 reliabilities_errors = []
977 for error in errors:
978 error_user_text = None
979 if error.get('secondary') or 'exception_classname' not in error:
980 continue
981 error_user_text = exception_classname_to_text.get(error.get('exception_classname'))
982 if not error:
983 error_user_text = exception_classname_to_text[None]
984 if error_user_text not in reliabilities_errors:
985 reliabilities_errors.append(error_user_text)
986 reliabilities[e.name]['errors'] = reliabilities_errors
987
988 # supports
989 supports = {}
990 for _, e in filtered_engines.items():
991 supports_selected_language = e.traits.is_locale_supported(
992 str(request.preferences.get_value('language') or 'all')
993 )
994 safesearch = e.safesearch
995 time_range_support = e.time_range_support
996 for checker_test_name in checker_results.get(e.name, {}).get('errors', {}):
997 if supports_selected_language and checker_test_name.startswith('lang_'):
998 supports_selected_language = '?'
999 elif safesearch and checker_test_name == 'safesearch':
1000 safesearch = '?'
1001 elif time_range_support and checker_test_name == 'time_range':
1002 time_range_support = '?'
1003 supports[e.name] = {
1004 'supports_selected_language': supports_selected_language,
1005 'safesearch': safesearch,
1006 'time_range_support': time_range_support,
1007 }
1008
1009 return render(
1010 # fmt: off
1011 'preferences.html',
1012 selected_categories = get_selected_categories(request.preferences, request.form),
1013 locales = LOCALE_NAMES,
1014 current_locale = request.preferences.get_value("locale"),
1015 image_proxy = image_proxy,
1016 engines_by_category = engines_by_category,
1017 stats = stats,
1018 max_rate95 = max_rate95,
1019 reliabilities = reliabilities,
1020 supports = supports,
1021 answerers = [
1022 {'info': a.self_info(), 'keywords': a.keywords}
1023 for a in answerers
1024 ],
1025 disabled_engines = disabled_engines,
1026 autocomplete_backends = autocomplete_backends,
1027 favicon_resolver_names = favicons.proxy.CFG.resolver_map.keys(),
1028 shortcuts = {y: x for x, y in engine_shortcuts.items()},
1029 themes = themes,
1030 plugins = plugins,
1031 doi_resolvers = settings['doi_resolvers'],
1032 current_doi_resolver = get_doi_resolver(request.preferences),
1033 allowed_plugins = allowed_plugins,
1034 preferences_url_params = request.preferences.get_as_url_params(),
1035 locked_preferences = settings['preferences']['lock'],
1036 preferences = True
1037 # fmt: on
1038 )
1039
1040
1041app.add_url_rule('/favicon_proxy', methods=['GET'], endpoint="favicon_proxy", view_func=favicons.favicon_proxy)
1042
1043
1044@app.route('/image_proxy', methods=['GET'])
1046 # pylint: disable=too-many-return-statements, too-many-branches
1047
1048 url = request.args.get('url')
1049 if not url:
1050 return '', 400
1051
1052 if not is_hmac_of(settings['server']['secret_key'], url.encode(), request.args.get('h', '')):
1053 return '', 400
1054
1055 maximum_size = 5 * 1024 * 1024
1056 forward_resp = False
1057 resp = None
1058 try:
1059 request_headers = {
1060 'User-Agent': gen_useragent(),
1061 'Accept': 'image/webp,*/*',
1062 'Accept-Encoding': 'gzip, deflate',
1063 'Sec-GPC': '1',
1064 'DNT': '1',
1065 }
1066 set_context_network_name('image_proxy')
1067 resp, stream = http_stream(method='GET', url=url, headers=request_headers, allow_redirects=True)
1068 content_length = resp.headers.get('Content-Length')
1069 if content_length and content_length.isdigit() and int(content_length) > maximum_size:
1070 return 'Max size', 400
1071
1072 if resp.status_code != 200:
1073 logger.debug('image-proxy: wrong response code: %i', resp.status_code)
1074 if resp.status_code >= 400:
1075 return '', resp.status_code
1076 return '', 400
1077
1078 if not resp.headers.get('Content-Type', '').startswith('image/') and not resp.headers.get(
1079 'Content-Type', ''
1080 ).startswith('binary/octet-stream'):
1081 logger.debug('image-proxy: wrong content-type: %s', resp.headers.get('Content-Type', ''))
1082 return '', 400
1083
1084 forward_resp = True
1085 except httpx.HTTPError:
1086 logger.exception('HTTP error')
1087 return '', 400
1088 finally:
1089 if resp and not forward_resp:
1090 # the code is about to return an HTTP 400 error to the browser
1091 # we make sure to close the response between searxng and the HTTP server
1092 try:
1093 resp.close()
1094 except httpx.HTTPError:
1095 logger.exception('HTTP error on closing')
1096
1097 def close_stream():
1098 nonlocal resp, stream
1099 try:
1100 if resp:
1101 resp.close()
1102 del resp
1103 del stream
1104 except httpx.HTTPError as e:
1105 logger.debug('Exception while closing response', e)
1106
1107 try:
1108 headers = dict_subset(resp.headers, {'Content-Type', 'Content-Encoding', 'Content-Length', 'Length'})
1109 response = Response(stream, mimetype=resp.headers['Content-Type'], headers=headers, direct_passthrough=True)
1110 response.call_on_close(close_stream)
1111 return response
1112 except httpx.HTTPError:
1113 close_stream()
1114 return '', 400
1115
1116
1117@app.route('/engine_descriptions.json', methods=['GET'])
1119 locale = get_locale().split('_')[0]
1120 result = ENGINE_DESCRIPTIONS['en'].copy()
1121 if locale != 'en':
1122 for engine, description in ENGINE_DESCRIPTIONS.get(locale, {}).items():
1123 result[engine] = description
1124 for engine, description in result.items():
1125 if len(description) == 2 and description[1] == 'ref':
1126 ref_engine, ref_lang = description[0].split(':')
1127 description = ENGINE_DESCRIPTIONS[ref_lang][ref_engine]
1128 if isinstance(description, str):
1129 description = [description, 'wikipedia']
1130 result[engine] = description
1131
1132 # overwrite by about:description (from settings)
1133 for engine_name, engine_mod in engines.items():
1134 descr = getattr(engine_mod, 'about', {}).get('description', None)
1135 if descr is not None:
1136 result[engine_name] = [descr, "SearXNG config"]
1137
1138 return jsonify(result)
1139
1140
1141@app.route('/stats', methods=['GET'])
1142def stats():
1143 """Render engine statistics page."""
1144 sort_order = request.args.get('sort', default='name', type=str)
1145 selected_engine_name = request.args.get('engine', default=None, type=str)
1146
1147 filtered_engines = dict(filter(lambda kv: request.preferences.validate_token(kv[1]), engines.items()))
1148 if selected_engine_name:
1149 if selected_engine_name not in filtered_engines:
1150 selected_engine_name = None
1151 else:
1152 filtered_engines = [selected_engine_name]
1153
1154 checker_results = checker_get_result()
1155 checker_results = (
1156 checker_results['engines'] if checker_results['status'] == 'ok' and 'engines' in checker_results else {}
1157 )
1158
1159 engine_stats = get_engines_stats(filtered_engines)
1160 engine_reliabilities = get_reliabilities(filtered_engines, checker_results)
1161
1162 if sort_order not in STATS_SORT_PARAMETERS:
1163 sort_order = 'name'
1164
1165 reverse, key_name, default_value = STATS_SORT_PARAMETERS[sort_order]
1166
1167 def get_key(engine_stat):
1168 reliability = engine_reliabilities.get(engine_stat['name'], {}).get('reliability', 0)
1169 reliability_order = 0 if reliability else 1
1170 if key_name == 'reliability':
1171 key = reliability
1172 reliability_order = 0
1173 else:
1174 key = engine_stat.get(key_name) or default_value
1175 if reverse:
1176 reliability_order = 1 - reliability_order
1177 return (reliability_order, key, engine_stat['name'])
1178
1179 technical_report = []
1180 for error in engine_reliabilities.get(selected_engine_name, {}).get('errors', []):
1181 technical_report.append(
1182 f"\
1183 Error: {error['exception_classname'] or error['log_message']} \
1184 Parameters: {error['log_parameters']} \
1185 File name: {error['filename'] }:{ error['line_no'] } \
1186 Error Function: {error['function']} \
1187 Code: {error['code']} \
1188 ".replace(
1189 ' ' * 12, ''
1190 ).strip()
1191 )
1192 technical_report = ' '.join(technical_report)
1193
1194 engine_stats['time'] = sorted(engine_stats['time'], reverse=reverse, key=get_key)
1195 return render(
1196 # fmt: off
1197 'stats.html',
1198 sort_order = sort_order,
1199 engine_stats = engine_stats,
1200 engine_reliabilities = engine_reliabilities,
1201 selected_engine_name = selected_engine_name,
1202 searx_git_branch = GIT_BRANCH,
1203 technical_report = technical_report,
1204 # fmt: on
1205 )
1206
1207
1208@app.route('/stats/errors', methods=['GET'])
1210 filtered_engines = dict(filter(lambda kv: request.preferences.validate_token(kv[1]), engines.items()))
1211 result = get_engine_errors(filtered_engines)
1212 return jsonify(result)
1213
1214
1215@app.route('/stats/checker', methods=['GET'])
1217 result = checker_get_result()
1218 return jsonify(result)
1219
1220
1221@app.route('/robots.txt', methods=['GET'])
1223 return Response(
1224 """User-agent: *
1225Allow: /info/en/about
1226Disallow: /stats
1227Disallow: /image_proxy
1228Disallow: /preferences
1229Disallow: /*?*q=*
1230""",
1231 mimetype='text/plain',
1232 )
1233
1234
1235@app.route('/opensearch.xml', methods=['GET'])
1237 method = request.preferences.get_value('method')
1238 autocomplete = request.preferences.get_value('autocomplete')
1239
1240 # chrome/chromium only supports HTTP GET....
1241 if request.headers.get('User-Agent', '').lower().find('webkit') >= 0:
1242 method = 'GET'
1243
1244 if method not in ('POST', 'GET'):
1245 method = 'POST'
1246
1247 ret = render('opensearch.xml', opensearch_method=method, autocomplete=autocomplete)
1248 resp = Response(response=ret, status=200, mimetype="application/opensearchdescription+xml")
1249 return resp
1250
1251
1252@app.route('/favicon.ico')
1254 theme = request.preferences.get_value("theme")
1255 return send_from_directory(
1256 os.path.join(app.root_path, settings['ui']['static_path'], 'themes', theme, 'img'), # pyright: ignore
1257 'favicon.png',
1258 mimetype='image/vnd.microsoft.icon',
1259 )
1260
1261
1262@app.route('/clear_cookies')
1264 resp = make_response(redirect(url_for('index', _external=True)))
1265 for cookie_name in request.cookies:
1266 resp.delete_cookie(cookie_name)
1267 return resp
1268
1269
1270@app.route('/config')
1272 """Return configuration in JSON format."""
1273 _engines = []
1274 for name, engine in engines.items():
1275 if not request.preferences.validate_token(engine):
1276 continue
1277
1278 _languages = engine.traits.languages.keys()
1279 _engines.append(
1280 {
1281 'name': name,
1282 'categories': engine.categories,
1283 'shortcut': engine.shortcut,
1284 'enabled': not engine.disabled,
1285 'paging': engine.paging,
1286 'language_support': engine.language_support,
1287 'languages': list(_languages),
1288 'regions': list(engine.traits.regions.keys()),
1289 'safesearch': engine.safesearch,
1290 'time_range_support': engine.time_range_support,
1291 'timeout': engine.timeout,
1292 }
1293 )
1294
1295 _plugins = []
1296 for _ in plugins:
1297 _plugins.append({'name': _.name, 'enabled': _.default_on})
1298
1299 _limiter_cfg = limiter.get_cfg()
1300
1301 return jsonify(
1302 {
1303 'categories': list(categories.keys()),
1304 'engines': _engines,
1305 'plugins': _plugins,
1306 'instance_name': settings['general']['instance_name'],
1307 'locales': LOCALE_NAMES,
1308 'default_locale': settings['ui']['default_locale'],
1309 'autocomplete': settings['search']['autocomplete'],
1310 'safe_search': settings['search']['safe_search'],
1311 'default_theme': settings['ui']['default_theme'],
1312 'version': VERSION_STRING,
1313 'brand': {
1314 'PRIVACYPOLICY_URL': get_setting('general.privacypolicy_url'),
1315 'CONTACT_URL': get_setting('general.contact_url'),
1316 'GIT_URL': GIT_URL,
1317 'GIT_BRANCH': GIT_BRANCH,
1318 'DOCS_URL': get_setting('brand.docs_url'),
1319 },
1320 'limiter': {
1321 'enabled': limiter.is_installed(),
1322 'botdetection.ip_limit.link_token': _limiter_cfg.get('botdetection.ip_limit.link_token'),
1323 'botdetection.ip_lists.pass_searxng_org': _limiter_cfg.get('botdetection.ip_lists.pass_searxng_org'),
1324 },
1325 'doi_resolvers': list(settings['doi_resolvers'].keys()),
1326 'default_doi_resolver': settings['default_doi_resolver'],
1327 'public_instance': settings['server']['public_instance'],
1328 }
1329 )
1330
1331
1332@app.errorhandler(404)
1334 return render('404.html'), 404
1335
1336
1337# see https://flask.palletsprojects.com/en/1.1.x/cli/
1338# True if "FLASK_APP=searx/webapp.py FLASK_ENV=development flask run"
1339flask_run_development = (
1340 os.environ.get("FLASK_APP") is not None and os.environ.get("FLASK_ENV") == 'development' and is_flask_run_cmdline()
1341)
1342
1343# True if reload feature is activated of werkzeug, False otherwise (including uwsgi, etc..)
1344# __name__ != "__main__" if searx.webapp is imported (make test, make docs, uwsgi...)
1345# see run() at the end of this file : searx_debug activates the reload feature.
1346werkzeug_reloader = flask_run_development or (searx_debug and __name__ == "__main__")
1347
1348# initialize the engines except on the first run of the werkzeug server.
1349if not werkzeug_reloader or (werkzeug_reloader and os.environ.get("WERKZEUG_RUN_MAIN") == "true"):
1350 locales_initialize()
1351 redis_initialize()
1352 plugin_initialize(app)
1353 search_initialize(enable_checker=True, check_network=True, enable_metrics=settings['general']['enable_metrics'])
1354 limiter.initialize(app, settings)
1355 favicons.init()
1356
1357
1358def run():
1359 logger.debug('starting webserver on %s:%s', settings['server']['bind_address'], settings['server']['port'])
1360 app.run(
1361 debug=searx_debug,
1362 use_debugger=searx_debug,
1363 port=settings['server']['port'],
1364 host=settings['server']['bind_address'],
1365 threaded=True,
1366 extra_files=[DEFAULT_SETTINGS_FILE],
1367 )
1368
1369
1370application = app
1371patch_application(app)
1372
1373if __name__ == "__main__":
1374 run()
::1337x
Definition 1337x.py:1
_get_browser_language(req, lang_list)
Definition webapp.py:194
engine_descriptions()
Definition webapp.py:1118
morty_proxify(str url)
Definition webapp.py:287
info(pagename, locale)
Definition webapp.py:816
autocompleter()
Definition webapp.py:832
index_error(str output_format, str error_message)
Definition webapp.py:568
image_proxify(str url)
Definition webapp.py:302
get_client_settings()
Definition webapp.py:358
client_token(token=None)
Definition webapp.py:621
code_highlighter(codelines, language=None)
Definition webapp.py:212
get_translations()
Definition webapp.py:329
_get_locale_rfc5646(locale)
Definition webapp.py:200
get_enabled_categories(Iterable[str] category_names)
Definition webapp.py:340
render(str template_name, **kwargs)
Definition webapp.py:372
get_result_template(str theme_name, str template_name)
Definition webapp.py:259
add_default_headers(flask.Response response)
Definition webapp.py:537
post_request(flask.Response response)
Definition webapp.py:547
get_pretty_url(urllib.parse.ParseResult parsed_url)
Definition webapp.py:351
page_not_found(_e)
Definition webapp.py:1333
custom_url_for(str endpoint, **values)
Definition webapp.py:266