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