Repository: searxng/searxng

Issue:
Add ability to disable RSS/JSON/CSV
<!-- PLEASE FILL THESE FIELDS, IT REALLY HELPS THE MAINTAINERS OF SEARX -->

**Is your feature request related to a problem? Please describe.**
Bots are the only users of this feature on a public instance, and they abuse it too much that the engines rate limit pretty quickly the IP address of the instance.

**Describe the solution you'd like**
I would like to disable the results in RSS/JSON/CSV format.

**Describe alternatives you've considered**
Using filtron in order to block the use of the RSS/JSON/CSV.

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->



Discussion:
@dalf what do you think, should we make the format configurable (on/off) .. or should we add an option to configure a kind of "secret key" parameter for each format? (hope I was clear)
A secret key is similar to the token the engines: we can imagine a token for different features / configuration / preferences. But I don't think there is a need for that ?

So, we can start with a simple on / off.

Resolving patch:
diff --git a/searx/utils.py b/searx/utils.py
--- a/searx/utils.py
+++ b/searx/utils.py
@@ -8,6 +8,7 @@
 from random import choice
 from html.parser import HTMLParser
 from urllib.parse import urljoin, urlparse
+from collections.abc import Mapping
 
 from lxml import html
 from lxml.etree import ElementBase, XPath, XPathError, XPathSyntaxError, _ElementStringResult, _ElementUnicodeResult
@@ -500,6 +501,58 @@ def get_engine_from_settings(name):
     return {}
 
 
+NOT_EXISTS = object()
+"""Singleton used by :py:obj:`get_value` if a key does not exists."""
+
+
+def get_value(dictionary, *keys, default=NOT_EXISTS):
+    """Return the value from a *deep* mapping type (e.g. the ``settings`` object
+    from yaml).  If the path to the *key* does not exists a :py:obj:`NOT_EXISTS`
+    is returned (non ``KeyError`` exception is raised).
+
+    .. code: python
+
+       >>> from searx import settings
+       >>> from searx.utils import get_value, NOT_EXISTS
+       >>> get_value(settings, 'checker', 'additional_tests', 'rosebud', 'result_container')
+       ['not_empty', ['one_title_contains', 'citizen kane']]
+
+       >>> get_value(settings, 'search', 'xxx') is NOT_EXISTS
+       True
+       >>> get_value(settings, 'search', 'formats')
+       ['html', 'csv', 'json', 'rss']
+
+    The list returned from the ``search.format`` key is not a mapping type, you
+    can't traverse along non-mapping types.  If you try it, you will get a
+    :py:ref:`NOT_EXISTS`:
+
+    .. code: python
+
+       >>> get_value(settings, 'search', 'format', 'csv') is NOT_EXISTS
+       True
+       >>> get_value(settings, 'search', 'formats')[1]
+       'csv'
+
+    For convenience you can replace :py:ref:`NOT_EXISTS` by a default value of
+    your choice:
+
+    .. code: python
+
+       if 'csv' in get_value(settings, 'search', 'formats', default=[]):
+           print("csv format is denied")
+
+    """
+
+    obj = dictionary
+    for k in keys:
+        if not isinstance(obj, Mapping):
+            raise TypeError("expected mapping type, got %s" % type(obj))
+        obj = obj.get(k, default)
+        if obj is default:
+            return obj
+    return obj
+
+
 def get_xpath(xpath_spec):
     """Return cached compiled XPath
 
diff --git a/searx/webapp.py b/searx/webapp.py
--- a/searx/webapp.py
+++ b/searx/webapp.py
@@ -31,6 +31,8 @@
 from werkzeug.middleware.proxy_fix import ProxyFix
 from werkzeug.serving import WSGIRequestHandler
 
+import flask
+
 from flask import (
     Flask,
     request,
@@ -86,6 +88,7 @@
     gen_useragent,
     dict_subset,
     match_language,
+    get_value,
 )
 from searx.version import VERSION_STRING
 from searx.query import RawTextQuery
@@ -161,6 +164,8 @@ def new_thread_init(self, *args, **kwargs):
     for (dirpath, dirnames, filenames) in os.walk(theme_img_path):
         global_favicons[indice].extend(filenames)
 
+OUTPUT_FORMATS = ['html', 'csv', 'json', 'rss']
+
 STATS_SORT_PARAMETERS = {
     'name': (False, 'name', ''),
     'score': (True, 'score', 0),
@@ -511,6 +516,11 @@ def render(template_name, override_theme=None, **kwargs):
 
     kwargs['preferences'] = request.preferences
 
+    kwargs['search_formats'] = [
+        x for x in get_value(
+            settings, 'search', 'formats', default=OUTPUT_FORMATS)
+        if x != 'html']
+
     kwargs['brand'] = brand
 
     kwargs['translations'] = json.dumps(get_translations(), separators=(',', ':'))
@@ -683,9 +693,12 @@ def search():
 
     # output_format
     output_format = request.form.get('format', 'html')
-    if output_format not in ['html', 'csv', 'json', 'rss']:
+    if output_format not in OUTPUT_FORMATS:
         output_format = 'html'
 
+    if output_format not in get_value(settings, 'search', 'formats', default=OUTPUT_FORMATS):
+        flask.abort(403)
+
     # check if there is query (not None and not an empty string)
     if not request.form.get('q'):
         if output_format == 'html':


Test patch: