.oO SearXNG Developer Documentation Oo.
Loading...
Searching...
No Matches
preferences.py
Go to the documentation of this file.
1# SPDX-License-Identifier: AGPL-3.0-or-later
2"""Searx preferences implementation.
3"""
4from __future__ import annotations
5
6# pylint: disable=useless-object-inheritance
7
8from base64 import urlsafe_b64encode, urlsafe_b64decode
9from zlib import compress, decompress
10from urllib.parse import parse_qs, urlencode
11from typing import Iterable, Dict, List, Optional
12from collections import OrderedDict
13
14import flask
15import babel
16
17import searx.plugins
18
19from searx import settings, autocomplete, favicons
20from searx.enginelib import Engine
21from searx.engines import DEFAULT_CATEGORY
22from searx.extended_types import SXNG_Request
23from searx.locales import LOCALE_NAMES
24from searx.webutils import VALID_LANGUAGE_CODE
25
26
27COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 5 # 5 years
28DOI_RESOLVERS = list(settings['doi_resolvers'])
29
30MAP_STR2BOOL: Dict[str, bool] = OrderedDict(
31 [
32 ('0', False),
33 ('1', True),
34 ('on', True),
35 ('off', False),
36 ('True', True),
37 ('False', False),
38 ('none', False),
39 ]
40)
41
42
43class ValidationException(Exception):
44 """Exption from ``cls.__init__`` when configuration value is invalid."""
45
46
47class Setting:
48 """Base class of user settings"""
49
50 def __init__(self, default_value, locked: bool = False):
51 super().__init__()
52 self.value = default_value
53 self.locked = locked
54
55 def parse(self, data: str):
56 """Parse ``data`` and store the result at ``self.value``
57
58 If needed, its overwritten in the inheritance.
59 """
60 self.value = data
61
62 def get_value(self):
63 """Returns the value of the setting
64
65 If needed, its overwritten in the inheritance.
66 """
67 return self.value
68
69 def save(self, name: str, resp: flask.Response):
70 """Save cookie ``name`` in the HTTP response object
71
72 If needed, its overwritten in the inheritance."""
73 resp.set_cookie(name, self.value, max_age=COOKIE_MAX_AGE)
74
75
77 """Setting of plain string values"""
78
79
80class EnumStringSetting(Setting):
81 """Setting of a value which can only come from the given choices"""
82
83 def __init__(self, default_value: str, choices: Iterable[str], locked=False):
84 super().__init__(default_value, locked)
85 self.choices = choices
87
88 def _validate_selection(self, selection: str):
89 if selection not in self.choices:
90 raise ValidationException('Invalid value: "{0}"'.format(selection))
91
92 def parse(self, data: str):
93 """Parse and validate ``data`` and store the result at ``self.value``"""
94 self._validate_selection(data)
95 self.value = data
96
97
99 """Setting of values which can only come from the given choices"""
100
101 def __init__(self, default_value: List[str], choices: Iterable[str], locked=False):
102 super().__init__(default_value, locked)
103 self.choices = choices
105
106 def _validate_selections(self, selections: List[str]):
107 for item in selections:
108 if item not in self.choices:
109 raise ValidationException('Invalid value: "{0}"'.format(selections))
110
111 def parse(self, data: str):
112 """Parse and validate ``data`` and store the result at ``self.value``"""
113 if data == '':
114 self.value = []
115 return
116
117 elements = data.split(',')
118 self._validate_selections(elements)
119 self.value = elements
120
121 def parse_form(self, data: List[str]):
122 if self.locked:
123 return
124
125 self.value = []
126 for choice in data:
127 if choice in self.choices and choice not in self.value:
128 self.value.append(choice)
129
130 def save(self, name: str, resp: flask.Response):
131 """Save cookie ``name`` in the HTTP response object"""
132 resp.set_cookie(name, ','.join(self.value), max_age=COOKIE_MAX_AGE)
133
134
136 """Setting of values of type ``set`` (comma separated string)"""
137
138 def __init__(self, *args, **kwargs):
139 super().__init__(*args, **kwargs)
140 self.values = set()
141
142 def get_value(self):
143 """Returns a string with comma separated values."""
144 return ','.join(self.values)
145
146 def parse(self, data: str):
147 """Parse and validate ``data`` and store the result at ``self.value``"""
148 if data == '':
149 self.values = set()
150 return
151
152 elements = data.split(',')
153 for element in elements:
154 self.values.add(element)
155
156 def parse_form(self, data: str):
157 if self.locked:
158 return
159
160 elements = data.split(',')
161 self.values = set(elements)
162
163 def save(self, name: str, resp: flask.Response):
164 """Save cookie ``name`` in the HTTP response object"""
165 resp.set_cookie(name, ','.join(self.values), max_age=COOKIE_MAX_AGE)
166
167
169 """Available choices may change, so user's value may not be in choices anymore"""
170
171 def _validate_selection(self, selection):
172 if selection != '' and selection != 'auto' and not VALID_LANGUAGE_CODE.match(selection):
173 raise ValidationException('Invalid language code: "{0}"'.format(selection))
174
175 def parse(self, data: str):
176 """Parse and validate ``data`` and store the result at ``self.value``"""
177 if data not in self.choices and data != self.value:
178 # hack to give some backwards compatibility with old language cookies
179 data = str(data).replace('_', '-')
180 lang = data.split('-', maxsplit=1)[0]
181
182 if data in self.choices:
183 pass
184 elif lang in self.choices:
185 data = lang
186 else:
187 data = self.value
188 self._validate_selection(data)
189 self.value = data
190
191
193 """Setting of a value that has to be translated in order to be storable"""
194
195 def __init__(self, default_value, map: Dict[str, object], locked=False): # pylint: disable=redefined-builtin
196 super().__init__(default_value, locked)
197 self.map = map
198
199 if self.value not in self.map.values():
200 raise ValidationException('Invalid default value')
201
202 def parse(self, data: str):
203 """Parse and validate ``data`` and store the result at ``self.value``"""
204
205 if data not in self.map:
206 raise ValidationException('Invalid choice: {0}'.format(data))
207 self.value = self.map[data]
208 self.key = data # pylint: disable=attribute-defined-outside-init
209
210 def save(self, name: str, resp: flask.Response):
211 """Save cookie ``name`` in the HTTP response object"""
212 if hasattr(self, 'key'):
213 resp.set_cookie(name, self.key, max_age=COOKIE_MAX_AGE)
214
215
217 """Setting of a boolean value that has to be translated in order to be storable"""
218
219 def normalized_str(self, val):
220 for v_str, v_obj in MAP_STR2BOOL.items():
221 if val == v_obj:
222 return v_str
223 raise ValueError("Invalid value: %s (%s) is not a boolean!" % (repr(val), type(val)))
224
225 def parse(self, data: str):
226 """Parse and validate ``data`` and store the result at ``self.value``"""
227 self.value = MAP_STR2BOOL[data]
228 self.key = self.normalized_str(self.value) # pylint: disable=attribute-defined-outside-init
229
230 def save(self, name: str, resp: flask.Response):
231 """Save cookie ``name`` in the HTTP response object"""
232 if hasattr(self, 'key'):
233 resp.set_cookie(name, self.key, max_age=COOKIE_MAX_AGE)
234
235
237 """Maps strings to booleans that are either true or false."""
238
239 def __init__(self, name: str, choices: Dict[str, bool], locked: bool = False):
240 self.name = name
241 self.choices = choices
242 self.locked = locked
243 self.default_choices = dict(choices)
244
245 def transform_form_items(self, items):
246 return items
247
248 def transform_values(self, values):
249 return values
250
251 def parse_cookie(self, data_disabled: str, data_enabled: str):
252 for disabled in data_disabled.split(','):
253 if disabled in self.choices:
254 self.choices[disabled] = False
255
256 for enabled in data_enabled.split(','):
257 if enabled in self.choices:
258 self.choices[enabled] = True
259
260 def parse_form(self, items: List[str]):
261 if self.locked:
262 return
263
264 disabled = self.transform_form_items(items)
265 for setting in self.choices:
266 self.choices[setting] = setting not in disabled
267
268 @property
269 def enabled(self):
270 return (k for k, v in self.choices.items() if v)
271
272 @property
273 def disabled(self):
274 return (k for k, v in self.choices.items() if not v)
275
276 def save(self, resp: flask.Response):
277 """Save cookie in the HTTP response object"""
278 disabled_changed = (k for k in self.disabled if self.default_choices[k])
279 enabled_changed = (k for k in self.enabled if not self.default_choices[k])
280 resp.set_cookie('disabled_{0}'.format(self.name), ','.join(disabled_changed), max_age=COOKIE_MAX_AGE)
281 resp.set_cookie('enabled_{0}'.format(self.name), ','.join(enabled_changed), max_age=COOKIE_MAX_AGE)
282
283 def get_disabled(self):
284 return self.transform_values(list(self.disabled))
285
286 def get_enabled(self):
287 return self.transform_values(list(self.enabled))
288
289
291 """Engine settings"""
292
293 def __init__(self, default_value, engines: Iterable[Engine]):
294 choices = {}
295 for engine in engines:
296 for category in engine.categories:
297 if not category in list(settings['categories_as_tabs'].keys()) + [DEFAULT_CATEGORY]:
298 continue
299 choices['{}__{}'.format(engine.name, category)] = not engine.disabled
300 super().__init__(default_value, choices)
301
302 def transform_form_items(self, items):
303 return [item[len('engine_') :].replace('_', ' ').replace(' ', '__') for item in items]
304
305 def transform_values(self, values):
306 if len(values) == 1 and next(iter(values)) == '':
307 return []
308 transformed_values = []
309 for value in values:
310 engine, category = value.split('__')
311 transformed_values.append((engine, category))
312 return transformed_values
313
314
316 """Plugin settings"""
317
318 def __init__(self, default_value, plugins: Iterable[searx.plugins.Plugin]):
319 super().__init__(default_value, {plugin.id: plugin.default_on for plugin in plugins})
320
321 def transform_form_items(self, items):
322 return [item[len('plugin_') :] for item in items]
323
324
326 """Container to assemble client prefferences and settings."""
327
328 # hint: searx.webapp.get_client_settings should be moved into this class
329
330 locale: babel.Locale
331 """Locale preferred by the client."""
332
333 def __init__(self, locale: Optional[babel.Locale] = None):
334 self.locale = locale
335
336 @property
337 def locale_tag(self):
338 if self.locale is None:
339 return None
340 tag = self.locale.language
341 if self.locale.territory:
342 tag += '-' + self.locale.territory
343 return tag
344
345 @classmethod
346 def from_http_request(cls, http_request: SXNG_Request):
347 """Build ClientPref object from HTTP request.
348
349 - `Accept-Language used for locale setting
350 <https://www.w3.org/International/questions/qa-accept-lang-locales.en>`__
351
352 """
353 al_header = http_request.headers.get("Accept-Language")
354 if not al_header:
355 return cls(locale=None)
356
357 pairs = []
358 for l in al_header.split(','):
359 # fmt: off
360 lang, qvalue = [_.strip() for _ in (l.split(';') + ['q=1',])[:2]]
361 # fmt: on
362 try:
363 qvalue = float(qvalue.split('=')[-1])
364 locale = babel.Locale.parse(lang, sep='-')
365 except (ValueError, babel.core.UnknownLocaleError):
366 continue
367 pairs.append((locale, qvalue))
368
369 locale = None
370 if pairs:
371 pairs.sort(reverse=True, key=lambda x: x[1])
372 locale = pairs[0][0]
373 return cls(locale=locale)
374
375
377 """Validates and saves preferences to cookies"""
378
380 self,
381 themes: list[str],
382 categories: list[str],
383 engines: dict[str, Engine],
385 client: ClientPref | None = None,
386 ):
387
388 super().__init__()
389
390 self.key_value_settings: Dict[str, Setting] = {
391 # fmt: off
392 'categories': MultipleChoiceSetting(
393 ['general'],
394 locked=is_locked('categories'),
395 choices=categories + ['none']
396 ),
397 'language': SearchLanguageSetting(
398 settings['search']['default_lang'],
399 locked=is_locked('language'),
400 choices=settings['search']['languages'] + ['']
401 ),
402 'locale': EnumStringSetting(
403 settings['ui']['default_locale'],
404 locked=is_locked('locale'),
405 choices=list(LOCALE_NAMES.keys()) + ['']
406 ),
407 'autocomplete': EnumStringSetting(
408 settings['search']['autocomplete'],
409 locked=is_locked('autocomplete'),
410 choices=list(autocomplete.backends.keys()) + ['']
411 ),
412 'favicon_resolver': EnumStringSetting(
413 settings['search']['favicon_resolver'],
414 locked=is_locked('favicon_resolver'),
415 choices=list(favicons.proxy.CFG.resolver_map.keys()) + ['']
416 ),
417 'image_proxy': BooleanSetting(
418 settings['server']['image_proxy'],
419 locked=is_locked('image_proxy')
420 ),
421 'method': EnumStringSetting(
422 settings['server']['method'],
423 locked=is_locked('method'),
424 choices=('GET', 'POST')
425 ),
426 'safesearch': MapSetting(
427 settings['search']['safe_search'],
428 locked=is_locked('safesearch'),
429 map={
430 '0': 0,
431 '1': 1,
432 '2': 2
433 }
434 ),
435 'theme': EnumStringSetting(
436 settings['ui']['default_theme'],
437 locked=is_locked('theme'),
438 choices=themes
439 ),
440 'results_on_new_tab': BooleanSetting(
441 settings['ui']['results_on_new_tab'],
442 locked=is_locked('results_on_new_tab')
443 ),
444 'doi_resolver': MultipleChoiceSetting(
445 [settings['default_doi_resolver'], ],
446 locked=is_locked('doi_resolver'),
447 choices=DOI_RESOLVERS
448 ),
449 'simple_style': EnumStringSetting(
450 settings['ui']['theme_args']['simple_style'],
451 locked=is_locked('simple_style'),
452 choices=['', 'auto', 'light', 'dark', 'black']
453 ),
454 'center_alignment': BooleanSetting(
455 settings['ui']['center_alignment'],
456 locked=is_locked('center_alignment')
457 ),
458 'advanced_search': BooleanSetting(
459 settings['ui']['advanced_search'],
460 locked=is_locked('advanced_search')
461 ),
462 'query_in_title': BooleanSetting(
463 settings['ui']['query_in_title'],
464 locked=is_locked('query_in_title')
465 ),
466 'infinite_scroll': BooleanSetting(
467 settings['ui']['infinite_scroll'],
468 locked=is_locked('infinite_scroll')
469 ),
470 'search_on_category_select': BooleanSetting(
471 settings['ui']['search_on_category_select'],
472 locked=is_locked('search_on_category_select')
473 ),
474 'hotkeys': EnumStringSetting(
475 settings['ui']['hotkeys'],
476 choices=['default', 'vim']
477 ),
478 'url_formatting': EnumStringSetting(
479 settings['ui']['url_formatting'],
480 choices=['pretty', 'full', 'host']
481 ),
482 # fmt: on
483 }
484
485 self.engines = EnginesSetting('engines', engines=engines.values())
486 self.plugins = PluginsSetting('plugins', plugins=plugins)
487 self.tokens = SetSetting('tokens')
488 self.client = client or ClientPref()
489
491 """Return preferences as URL parameters"""
492 settings_kv = {}
493 for k, v in self.key_value_settings.items():
494 if v.locked:
495 continue
496 if isinstance(v, MultipleChoiceSetting):
497 settings_kv[k] = ','.join(v.get_value())
498 else:
499 settings_kv[k] = v.get_value()
500
501 settings_kv['disabled_engines'] = ','.join(self.engines.disabled)
502 settings_kv['enabled_engines'] = ','.join(self.engines.enabled)
503
504 settings_kv['disabled_plugins'] = ','.join(self.plugins.disabled)
505 settings_kv['enabled_plugins'] = ','.join(self.plugins.enabled)
506
507 settings_kv['tokens'] = ','.join(self.tokens.values)
508
509 return urlsafe_b64encode(compress(urlencode(settings_kv).encode())).decode()
510
511 def parse_encoded_data(self, input_data: str):
512 """parse (base64) preferences from request (``flask.request.form['preferences']``)"""
513 bin_data = decompress(urlsafe_b64decode(input_data))
514 dict_data = {}
515 for x, y in parse_qs(bin_data.decode('ascii'), keep_blank_values=True).items():
516 dict_data[x] = y[0]
517 self.parse_dict(dict_data)
518
519 def parse_dict(self, input_data: Dict[str, str]):
520 """parse preferences from request (``flask.request.form``)"""
521 for user_setting_name, user_setting in input_data.items():
522 if user_setting_name in self.key_value_settings:
523 if self.key_value_settings[user_setting_name].locked:
524 continue
525 self.key_value_settings[user_setting_name].parse(user_setting)
526 elif user_setting_name == 'disabled_engines':
527 self.engines.parse_cookie(input_data.get('disabled_engines', ''), input_data.get('enabled_engines', ''))
528 elif user_setting_name == 'disabled_plugins':
529 self.plugins.parse_cookie(input_data.get('disabled_plugins', ''), input_data.get('enabled_plugins', ''))
530 elif user_setting_name == 'tokens':
531 self.tokens.parse(user_setting)
532
533 def parse_form(self, input_data: Dict[str, str]):
534 """Parse formular (``<input>``) data from a ``flask.request.form``"""
535 disabled_engines = []
536 enabled_categories = []
537 disabled_plugins = []
538
539 # boolean preferences are not sent by the form if they're false,
540 # so we have to add them as false manually if they're not sent (then they would be true)
541 for key, setting in self.key_value_settings.items():
542 if key not in input_data.keys() and isinstance(setting, BooleanSetting):
543 input_data[key] = 'False'
544
545 for user_setting_name, user_setting in input_data.items():
546 if user_setting_name in self.key_value_settings:
547 self.key_value_settings[user_setting_name].parse(user_setting)
548 elif user_setting_name.startswith('engine_'):
549 disabled_engines.append(user_setting_name)
550 elif user_setting_name.startswith('category_'):
551 enabled_categories.append(user_setting_name[len('category_') :])
552 elif user_setting_name.startswith('plugin_'):
553 disabled_plugins.append(user_setting_name)
554 elif user_setting_name == 'tokens':
555 self.tokens.parse_form(user_setting)
556
557 self.key_value_settings['categories'].parse_form(enabled_categories)
558 self.engines.parse_form(disabled_engines)
559 self.plugins.parse_form(disabled_plugins)
560
561 # cannot be used in case of engines or plugins
562 def get_value(self, user_setting_name: str):
563 """Returns the value for ``user_setting_name``"""
564 ret_val = None
565 if user_setting_name in self.key_value_settings:
566 ret_val = self.key_value_settings[user_setting_name].get_value()
567 return ret_val
568
569 def save(self, resp: flask.Response):
570 """Save cookie in the HTTP response object"""
571 for user_setting_name, user_setting in self.key_value_settings.items():
572 # pylint: disable=unnecessary-dict-index-lookup
573 if self.key_value_settings[user_setting_name].locked:
574 continue
575 user_setting.save(user_setting_name, resp)
576 self.engines.save(resp)
577 self.plugins.save(resp)
578 self.tokens.save('tokens', resp)
579 return resp
580
581 def validate_token(self, engine):
582 valid = True
583 if hasattr(engine, 'tokens') and engine.tokens:
584 valid = False
585 for token in self.tokens.values:
586 if token in engine.tokens:
587 valid = True
588 break
589
590 return valid
591
592
593def is_locked(setting_name: str):
594 """Checks if a given setting name is locked by settings.yml"""
595 if 'preferences' not in settings:
596 return False
597 if 'lock' not in settings['preferences']:
598 return False
599 return setting_name in settings['preferences']['lock']
save(self, flask.Response resp)
parse_form(self, List[str] items)
__init__(self, str name, Dict[str, bool] choices, bool locked=False)
parse_cookie(self, str data_disabled, str data_enabled)
save(self, str name, flask.Response resp)
from_http_request(cls, SXNG_Request http_request)
__init__(self, Optional[babel.Locale] locale=None)
__init__(self, default_value, Iterable[Engine] engines)
__init__(self, str default_value, Iterable[str] choices, locked=False)
_validate_selection(self, str selection)
__init__(self, default_value, Dict[str, object] map, locked=False)
save(self, str name, flask.Response resp)
__init__(self, List[str] default_value, Iterable[str] choices, locked=False)
_validate_selections(self, List[str] selections)
save(self, str name, flask.Response resp)
__init__(self, default_value, Iterable[searx.plugins.Plugin] plugins)
parse_form(self, Dict[str, str] input_data)
parse_encoded_data(self, str input_data)
__init__(self, list[str] themes, list[str] categories, dict[str, Engine] engines, searx.plugins.PluginStorage plugins, ClientPref|None client=None)
save(self, flask.Response resp)
parse_dict(self, Dict[str, str] input_data)
get_value(self, str user_setting_name)
save(self, str name, flask.Response resp)
__init__(self, *args, **kwargs)
save(self, str name, flask.Response resp)
__init__(self, default_value, bool locked=False)
parse(self, str data)
::1337x
Definition 1337x.py:1
is_locked(str setting_name)