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