Repository: searx/searx

Issue:
Add minimum safe search option based on #1392
## What does this PR do?

This PR is the renovated version of #1392. It adds a new options to `settings.yml` called `minimum_safe_search`. Thus, instance administrators can control the minimum level of safe search on their instances. Possible values are `0`, `1` and `2`.

This PR is also superior to the previous because if an admin configures this option, only the engines which meet the criteria are displayed on the preferences page.

## Why is this change important?

This PR gives more control to instance admins by letting them enforce minimal safe search requirements.

## Related issues

Closes #1392



Discussion:
Come to think about the feature, the instance admin already configures `safe_search`. Instead of configuring an "enforced" value, we should consider adding an option for admins where they could provide a list of options which cannot be overwritten by user preferences.
> we should consider adding an option for admins where they could provide a list of options which cannot be overwritten by user preferences.

👍 

How to write that information ?


#### value and boolean
```yaml
    safe_search : 0 # Value not enforced
```

```yaml
    safe_search : [0, False] # Value not enforced
```

```yaml
    safe_search : [0, True] # Enforced value
```

#### value and choices
```yaml
    safe_search : 0 # No enforced value
```

```yaml
    safe_search : 0, [0,1,2] # No enforced value, equivalent
```

```yaml
    safe_search : 1, [1,2] # Limit the possible value to 1 or 2 with a default value of 1
```


The options you described seem too intrusive to me. I would not change the existing settings at all. I would rather add a new option which expects a list of option names (from the Preferences page) which are locked.

For example:
```yaml
general:
  locked_preferences:
    - safesearch
    - method
```
| cookie name / preference name  / query parameter name | YAML path |
| ------------------------ | --------- |
| locale			| ui.default_locale |
| theme			| ui.default_theme |
| oscar-style		| ui.theme_args.oscar_style |
| results_on_new_tab	| ui.results_on_new_tab |
| language		| search.default_lang |
| safesearch		| search.safe_search |
| autocomplete	| search.autocomplete |
| image_proxy		| server.image_proxy |
| method			| server.method |
|  doi_resolver	| hard coded to 'oadoi.org' |

Without some renaming it will super confusing (?). For example:
```yaml
ui:
    default_theme: "legacy"
general:
  locked_preferences:
    - theme
```

Perhaps:
```yaml
ui:
    default_theme: "legacy"
locked_preferences:
  - ui.default_theme
```
?

But then:
```yaml
search:
    safe_search : 0 # Filter results. 0: None, 1: Moderate, 2: Strict
    autocomplete : "" # Existing autocomplete backends: "dbpedia", "duckduckgo", "google", "startpage", "swisscows", "qwant", "wikipedia" - leave blank to turn it off by default
    default_lang : "" # Default search language - leave blank to detect from browser information or use codes from 'languages.py'
    ban_time_on_fail : 5 # ban time in seconds after engine errors
    max_ban_time_on_fail : 120 # max ban time in seconds after engine errors
```

Why ```search.safe_search``` is allowed and not ```search.ban_time_on_fail``` ? Yes, the later one is not in the preferences.
I do not mind how we refer to locked options; by their name in settings.yml or in preferences. Searx can "translate" the options to preferences when these are initialized. But I think this option would not be widely used by admins, so I would rather just create a documentation page with the mapping of the options than add the translation functionality of searx.

> Why search.safe_search is allowed and not search.ban_time_on_fail ? Yes, the later one is not in the preferences.

I am not sure I understand this question. The whole point of this feature is to limit the options that can be configured using the Preferences page. `ban_time_on_fail` is not exposed, thus, there is no need to lock it.
I've tried this scenario: _"I'm new to Searx, I would like to set and lock a default language and a default safe_search, how can I do that ?"_

If the language setting is named in two different ways inside the settings.yml, the admin will have to the read the documentation at the same time (even if it makes sense because on one side this the default value, on the other side this is the preferences). With the `search.default_lang` notation, once understood, there is no need to read the documentation at the same.

About ```ban_time_on_fail ``` : _"Ok I can lock safe_search, oh can I lock ban_time_on_fail  ? Ah yes it doesn't make sense, this is not a preference"_ (so just ignore this paragraph, sorry).


Resolving patch:
diff --git a/searx/preferences.py b/searx/preferences.py
--- a/searx/preferences.py
+++ b/searx/preferences.py
@@ -35,9 +35,10 @@ class ValidationException(Exception):
 class Setting:
     """Base class of user settings"""
 
-    def __init__(self, default_value, **kwargs):
+    def __init__(self, default_value, locked=False, **kwargs):
         super().__init__()
         self.value = default_value
+        self.locked = locked
         for key, value in kwargs.items():
             setattr(self, key, value)
 
@@ -115,6 +116,9 @@ def parse(self, data):
         self.value = elements
 
     def parse_form(self, data):  # pylint: disable=missing-function-docstring
+        if self.locked:
+            return
+
         self.value = []
         for choice in data:
             if choice in self.choices and choice not in self.value:  # pylint: disable=no-member
@@ -149,6 +153,9 @@ def parse(self, data):
             self.values.add(element)
 
     def parse_form(self, data):  # pylint: disable=missing-function-docstring
+        if self.locked:
+            return
+
         elements = data.split(',')
         self.values = set(elements)  # pylint: disable=attribute-defined-outside-init
 
@@ -233,6 +240,9 @@ def parse_cookie(self, data):   # pylint: disable=missing-function-docstring
             self.enabled = set(data[ENABLED].split(','))
 
     def parse_form(self, items):   # pylint: disable=missing-function-docstring
+        if self.locked:
+            return
+
         items = self.transform_form_items(items)
         self.disabled = set()  # pylint: disable=attribute-defined-outside-init
         self.enabled = set()   # pylint: disable=attribute-defined-outside-init
@@ -317,22 +327,28 @@ def __init__(self, themes, categories, engines, plugins):
 
         self.key_value_settings = {
             'categories': MultipleChoiceSetting(
-                ['general'], choices=categories + ['none']
+                ['general'],
+                is_locked('categories'),
+                choices=categories + ['none']
             ),
             'language': SearchLanguageSetting(
                 settings['search'].get('default_lang', ''),
+                is_locked('language'),
                 choices=list(LANGUAGE_CODES) + ['']
             ),
             'locale': EnumStringSetting(
                 settings['ui'].get('default_locale', ''),
+                is_locked('locale'),
                 choices=list(settings['locales'].keys()) + ['']
             ),
             'autocomplete': EnumStringSetting(
                 settings['search'].get('autocomplete', ''),
+                is_locked('autocomplete'),
                 choices=list(autocomplete.backends.keys()) + ['']
             ),
             'image_proxy': MapSetting(
                 settings['server'].get('image_proxy', False),
+                is_locked('image_proxy'),
                 map={
                     '': settings['server'].get('image_proxy', 0),
                     '0': False,
@@ -343,10 +359,12 @@ def __init__(self, themes, categories, engines, plugins):
             ),
             'method': EnumStringSetting(
                 settings['server'].get('method', 'POST'),
+                is_locked('method'),
                 choices=('GET', 'POST')
             ),
             'safesearch': MapSetting(
                 settings['search'].get('safe_search', 0),
+                is_locked('safesearch'),
                 map={
                     '0': 0,
                     '1': 1,
@@ -355,10 +373,12 @@ def __init__(self, themes, categories, engines, plugins):
             ),
             'theme': EnumStringSetting(
                 settings['ui'].get('default_theme', 'oscar'),
+                is_locked('theme'),
                 choices=themes
             ),
             'results_on_new_tab': MapSetting(
                 settings['ui'].get('results_on_new_tab', False),
+                is_locked('results_on_new_tab'),
                 map={
                     '0': False,
                     '1': True,
@@ -367,10 +387,13 @@ def __init__(self, themes, categories, engines, plugins):
                 }
             ),
             'doi_resolver': MultipleChoiceSetting(
-                ['oadoi.org'], choices=DOI_RESOLVERS
+                ['oadoi.org'],
+                is_locked('doi_resolver'),
+                choices=DOI_RESOLVERS
             ),
             'oscar-style': EnumStringSetting(
                 settings['ui'].get('theme_args', {}).get('oscar_style', 'logicodev'),
+                is_locked('oscar-style'),
                 choices=['', 'logicodev', 'logicodev-dark', 'pointhi']),
         }
 
@@ -383,6 +406,8 @@ def get_as_url_params(self):
         """Return preferences as URL parameters"""
         settings_kv = {}
         for k, v in self.key_value_settings.items():
+            if v.locked:
+                continue
             if isinstance(v, MultipleChoiceSetting):
                 settings_kv[k] = ','.join(v.get_value())
             else:
@@ -410,6 +435,8 @@ def parse_dict(self, input_data):
         """parse preferences from request (``flask.request.form``)"""
         for user_setting_name, user_setting in input_data.items():
             if user_setting_name in self.key_value_settings:
+                if self.key_value_settings[user_setting_name].locked:
+                    continue
                 self.key_value_settings[user_setting_name].parse(user_setting)
             elif user_setting_name == 'disabled_engines':
                 self.engines.parse_cookie((input_data.get('disabled_engines', ''),
@@ -464,6 +491,8 @@ def save(self, resp):
         """Save cookie in the HTTP reponse obect
         """
         for user_setting_name, user_setting in self.key_value_settings.items():
+            if self.key_value_settings[user_setting_name].locked:
+                continue
             user_setting.save(user_setting_name, resp)
         self.engines.save(resp)
         self.plugins.save(resp)
@@ -482,3 +511,13 @@ def validate_token(self, engine):  # pylint: disable=missing-function-docstring
                     break
 
         return valid
+
+
+def is_locked(setting_name):
+    """Checks if a given setting name is locked by settings.yml
+    """
+    if 'preferences' not in settings:
+        return False
+    if 'lock' not in settings['preferences']:
+        return False
+    return setting_name in settings['preferences']['lock']
diff --git a/searx/webadapter.py b/searx/webadapter.py
--- a/searx/webadapter.py
+++ b/searx/webadapter.py
@@ -4,7 +4,7 @@
 from searx.query import RawTextQuery
 from searx.engines import categories, engines
 from searx.search import SearchQuery, EngineRef
-from searx.preferences import Preferences
+from searx.preferences import Preferences, is_locked
 
 
 # remove duplicate queries.
@@ -48,6 +48,8 @@ def parse_pageno(form: Dict[str, str]) -> int:
 
 
 def parse_lang(preferences: Preferences, form: Dict[str, str], raw_text_query: RawTextQuery) -> str:
+    if is_locked('language'):
+        return preferences.get_value('langueage')
     # get language
     # set specific language if set on request, query or preferences
     # TODO support search with multible languages
@@ -66,6 +68,9 @@ def parse_lang(preferences: Preferences, form: Dict[str, str], raw_text_query: R
 
 
 def parse_safesearch(preferences: Preferences, form: Dict[str, str]) -> int:
+    if is_locked('safesearch'):
+        return preferences.get_value('safesearch')
+
     if 'safesearch' in form:
         query_safesearch = form.get('safesearch')
         # first check safesearch
@@ -117,6 +122,9 @@ def parse_specific(raw_text_query: RawTextQuery) -> Tuple[List[EngineRef], List[
 
 
 def parse_category_form(query_categories: List[str], name: str, value: str) -> None:
+    if is_locked('categories'):
+        return preferences.get_value('categories')
+
     if name == 'categories':
         query_categories.extend(categ for categ in map(str.strip, value.split(',')) if categ in categories)
     elif name.startswith('category_'):
diff --git a/searx/webapp.py b/searx/webapp.py
--- a/searx/webapp.py
+++ b/searx/webapp.py
@@ -828,6 +828,10 @@ def preferences():
             stats[engine_stat.get('name')]['warn_time'] = True
     # end of stats
 
+    locked_preferences = list()
+    if 'preferences' in settings and 'lock' in settings['preferences']:
+        locked_preferences = settings['preferences']['lock']
+
     return render('preferences.html',
                   selected_categories=get_selected_categories(request.preferences, request.form),
                   all_categories=_get_ordered_categories(),
@@ -848,6 +852,7 @@ def preferences():
                   theme=get_current_theme_name(),
                   preferences_url_params=request.preferences.get_as_url_params(),
                   base_url=get_base_url(),
+                  locked_preferences=locked_preferences,
                   preferences=True)
 
 


Test patch:
diff --git a/tests/unit/test_preferences.py b/tests/unit/test_preferences.py
--- a/tests/unit/test_preferences.py
+++ b/tests/unit/test_preferences.py
@@ -141,4 +141,4 @@ def test_encode(self):
         pref.parse_encoded_data(url_params)
         self.assertEqual(
             vars(pref.key_value_settings['categories']),
-            {'value': ['general'], 'choices': ['general', 'none']})
+            {'value': ['general'], 'locked': False, 'choices': ['general', 'none']})