.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
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]):
119 if self.locked:
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):
154 if self.locked:
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.choices 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.choices:
180 pass
181 elif lang in self.choices:
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.choices = 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.choices:
251 self.choices[disabled] = False
252
253 for enabled in data_enabled.split(','):
254 if enabled in self.choices:
255 self.choices[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.choices:
263 self.choices[setting] = setting not in disabled
264
265 @property
266 def enabled(self):
267 return (k for k, v in self.choices.items() if v)
268
269 @property
270 def disabled(self):
271 return (k for k, v in self.choices.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.localelocale = locale
332
333 @property
334 def locale_tag(self):
335 if self.localelocale is None:
336 return None
337 tag = self.localelocale.language
338 if self.localelocale.territory:
339 tag += '-' + self.localelocale.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_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 'image_proxy': BooleanSetting(
410 settings['server']['image_proxy'],
411 locked=is_locked('image_proxy')
412 ),
413 'method': EnumStringSetting(
414 settings['server']['method'],
415 locked=is_locked('method'),
416 choices=('GET', 'POST')
417 ),
418 'safesearch': MapSetting(
419 settings['search']['safe_search'],
420 locked=is_locked('safesearch'),
421 map={
422 '0': 0,
423 '1': 1,
424 '2': 2
425 }
426 ),
427 'theme': EnumStringSetting(
428 settings['ui']['default_theme'],
429 locked=is_locked('theme'),
430 choices=themes
431 ),
432 'results_on_new_tab': BooleanSetting(
433 settings['ui']['results_on_new_tab'],
434 locked=is_locked('results_on_new_tab')
435 ),
436 'doi_resolver': MultipleChoiceSetting(
437 [settings['default_doi_resolver'], ],
438 locked=is_locked('doi_resolver'),
439 choices=DOI_RESOLVERS
440 ),
441 'simple_style': EnumStringSetting(
442 settings['ui']['theme_args']['simple_style'],
443 locked=is_locked('simple_style'),
444 choices=['', 'auto', 'light', 'dark']
445 ),
446 'center_alignment': BooleanSetting(
447 settings['ui']['center_alignment'],
448 locked=is_locked('center_alignment')
449 ),
450 'advanced_search': BooleanSetting(
451 settings['ui']['advanced_search'],
452 locked=is_locked('advanced_search')
453 ),
454 'query_in_title': BooleanSetting(
455 settings['ui']['query_in_title'],
456 locked=is_locked('query_in_title')
457 ),
458 'infinite_scroll': BooleanSetting(
459 settings['ui']['infinite_scroll'],
460 locked=is_locked('infinite_scroll')
461 ),
462 'search_on_category_select': BooleanSetting(
463 settings['ui']['search_on_category_select'],
464 locked=is_locked('search_on_category_select')
465 ),
466 'hotkeys': EnumStringSetting(
467 settings['ui']['hotkeys'],
468 choices=['default', 'vim']
469 ),
470 # fmt: on
471 }
472
473 self.engines = EnginesSetting('engines', engines=engines.values())
474 self.plugins = PluginsSetting('plugins', plugins=plugins)
475 self.tokens = SetSetting('tokens')
476 self.client = client or ClientPref()
477 self.unknown_params: Dict[str, str] = {}
478
480 """Return preferences as URL parameters"""
481 settings_kv = {}
482 for k, v in self.key_value_settings.items():
483 if v.locked:
484 continue
485 if isinstance(v, MultipleChoiceSetting):
486 settings_kv[k] = ','.join(v.get_value())
487 else:
488 settings_kv[k] = v.get_value()
489
490 settings_kv['disabled_engines'] = ','.join(self.engines.disabled)
491 settings_kv['enabled_engines'] = ','.join(self.engines.enabled)
492
493 settings_kv['disabled_plugins'] = ','.join(self.plugins.disabled)
494 settings_kv['enabled_plugins'] = ','.join(self.plugins.enabled)
495
496 settings_kv['tokens'] = ','.join(self.tokens.values)
497
498 return urlsafe_b64encode(compress(urlencode(settings_kv).encode())).decode()
499
500 def parse_encoded_data(self, input_data: str):
501 """parse (base64) preferences from request (``flask.request.form['preferences']``)"""
502 bin_data = decompress(urlsafe_b64decode(input_data))
503 dict_data = {}
504 for x, y in parse_qs(bin_data.decode('ascii'), keep_blank_values=True).items():
505 dict_data[x] = y[0]
506 self.parse_dict(dict_data)
507
508 def parse_dict(self, input_data: Dict[str, str]):
509 """parse preferences from request (``flask.request.form``)"""
510 for user_setting_name, user_setting in input_data.items():
511 if user_setting_name in self.key_value_settings:
512 if self.key_value_settings[user_setting_name].locked:
513 continue
514 self.key_value_settings[user_setting_name].parse(user_setting)
515 elif user_setting_name == 'disabled_engines':
516 self.engines.parse_cookie(input_data.get('disabled_engines', ''), input_data.get('enabled_engines', ''))
517 elif user_setting_name == 'disabled_plugins':
518 self.plugins.parse_cookie(input_data.get('disabled_plugins', ''), input_data.get('enabled_plugins', ''))
519 elif user_setting_name == 'tokens':
520 self.tokens.parse(user_setting)
521 elif not any(
522 user_setting_name.startswith(x) for x in ['enabled_', 'disabled_', 'engine_', 'category_', 'plugin_']
523 ):
524 self.unknown_params[user_setting_name] = user_setting
525
526 def parse_form(self, input_data: Dict[str, str]):
527 """Parse formular (``<input>``) data from a ``flask.request.form``"""
528 disabled_engines = []
529 enabled_categories = []
530 disabled_plugins = []
531
532 # boolean preferences are not sent by the form if they're false,
533 # so we have to add them as false manually if they're not sent (then they would be true)
534 for key, setting in self.key_value_settings.items():
535 if key not in input_data.keys() and isinstance(setting, BooleanSetting):
536 input_data[key] = 'False'
537
538 for user_setting_name, user_setting in input_data.items():
539 if user_setting_name in self.key_value_settings:
540 self.key_value_settings[user_setting_name].parse(user_setting)
541 elif user_setting_name.startswith('engine_'):
542 disabled_engines.append(user_setting_name)
543 elif user_setting_name.startswith('category_'):
544 enabled_categories.append(user_setting_name[len('category_') :])
545 elif user_setting_name.startswith('plugin_'):
546 disabled_plugins.append(user_setting_name)
547 elif user_setting_name == 'tokens':
548 self.tokens.parse_form(user_setting)
549 else:
550 self.unknown_params[user_setting_name] = user_setting
551 self.key_value_settings['categories'].parse_form(enabled_categories)
552 self.engines.parse_form(disabled_engines)
553 self.plugins.parse_form(disabled_plugins)
554
555 # cannot be used in case of engines or plugins
556 def get_value(self, user_setting_name: str):
557 """Returns the value for ``user_setting_name``"""
558 ret_val = None
559 if user_setting_name in self.key_value_settings:
560 ret_val = self.key_value_settings[user_setting_name].get_value()
561 if user_setting_name in self.unknown_params:
562 ret_val = self.unknown_params[user_setting_name]
563 return ret_val
564
565 def save(self, resp: flask.Response):
566 """Save cookie in the HTTP response object"""
567 for user_setting_name, user_setting in self.key_value_settings.items():
568 # pylint: disable=unnecessary-dict-index-lookup
569 if self.key_value_settings[user_setting_name].locked:
570 continue
571 user_setting.save(user_setting_name, resp)
572 self.engines.save(resp)
573 self.plugins.save(resp)
574 self.tokens.save('tokens', resp)
575 for k, v in self.unknown_params.items():
576 resp.set_cookie(k, v, max_age=COOKIE_MAX_AGE)
577 return resp
578
579 def validate_token(self, engine):
580 valid = True
581 if hasattr(engine, 'tokens') and engine.tokens:
582 valid = False
583 for token in self.tokens.values:
584 if token in engine.tokens:
585 valid = True
586 break
587
588 return valid
589
590
591def is_locked(setting_name: str):
592 """Checks if a given setting name is locked by settings.yml"""
593 if 'preferences' not in settings:
594 return False
595 if 'lock' not in settings['preferences']:
596 return False
597 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)