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