preferences.py 22 KB

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