Edit on GitHub

common.config_manager

  1import itertools
  2import threading
  3import pickle
  4import time
  5import json
  6
  7from pymemcache.client.base import Client as MemcacheClient
  8from pymemcache.exceptions import MemcacheError
  9from pymemcache import serde
 10from pathlib import Path
 11from common.lib.database import Database
 12
 13from common.lib.exceptions import ConfigException
 14from common.lib.config_definition import config_definition
 15
 16import configparser
 17import os
 18
 19class CacheMiss:
 20    """
 21    Helper class to distinguish memcache misses from true `None` values
 22    """
 23    pass
 24
 25
 26class ConfigManager:
 27    db = None
 28    dbconn = None
 29    cache = {}
 30    memcache = None
 31
 32    core_settings = {}
 33    config_definition = {}
 34
 35    def __init__(self, db=None):
 36        # ensure core settings (including database config) are loaded
 37        self.load_core_settings()
 38        self.load_user_settings()
 39        self.memcache = self.load_memcache()
 40
 41        # establish database connection if none available
 42        if db:
 43            self.with_db(db)
 44
 45    def with_db(self, db=None):
 46        """
 47        Initialise database
 48
 49        Not done on init, because something may need core settings before the
 50        database can be initialised
 51
 52        :param db:  Database object. If None, initialise it using the core config
 53        """
 54        if db or not self.db:
 55            # Replace w/ db if provided else only initialise if not already
 56            self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"),
 57                                         password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"),
 58                                         port=self.get("DB_PORT"), appname="config-reader")
 59        else:
 60            # self.db already initialized and no db provided
 61            pass
 62
 63    def load_user_settings(self):
 64        """
 65        Load settings configurable by the user
 66
 67        Does not load the settings themselves, but rather the definition so
 68        values can be validated, etc
 69        """
 70        # basic 4CAT settings
 71        self.config_definition.update(config_definition)
 72
 73        # module settings can't be loaded directly because modules need the
 74        # config manager to load, so that becomes circular
 75        # instead, this is cached on startup and then loaded here
 76        module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin")
 77        if module_config_path.exists():
 78            try:
 79                with module_config_path.open("rb") as infile:
 80                    retries = 0
 81                    module_config = None
 82                    # if 4CAT is being run in two different containers
 83                    # (front-end and back-end) they might both be running this
 84                    # bit of code at the same time. If the file is half-written
 85                    # loading it will fail, so allow for a few retries
 86                    while retries < 3:
 87                        try:
 88                            module_config = pickle.load(infile)
 89                            break
 90                        except Exception:  # this can be a number of exceptions, all with the same recovery path
 91                            time.sleep(0.1)
 92                            retries += 1
 93                            continue
 94
 95                    if module_config is None:
 96                        # not really a way to gracefully recover from this, but
 97                        # we can at least describe the error
 98                        raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of "
 99                                           "preventing this. Shame on them!")
100
101                    self.config_definition.update(module_config)
102            except (ValueError, TypeError):
103                pass
104
105    def load_core_settings(self):
106        """
107        Load 4CAT core settings
108
109        These are (mostly) stored in config.ini and cannot be changed from the
110        web interface.
111
112        :return:
113        """
114        config_file = Path(__file__).parent.parent.joinpath("config/config.ini")
115
116        config_reader = configparser.ConfigParser()
117        in_docker = False
118        if config_file.exists():
119            config_reader.read(config_file)
120            if config_reader["DOCKER"].getboolean("use_docker_config"):
121                # Can use throughtout 4CAT to know if Docker environment
122                in_docker = True
123        else:
124            # config should be created!
125            raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.")
126
127        self.core_settings.update({
128            "CONFIG_FILE": config_file.resolve(),
129            "USING_DOCKER": in_docker,
130            "DB_HOST": config_reader["DATABASE"].get("db_host"),
131            "DB_PORT": config_reader["DATABASE"].get("db_port"),
132            "DB_USER": config_reader["DATABASE"].get("db_user"),
133            "DB_NAME": config_reader["DATABASE"].get("db_name"),
134            "DB_PASSWORD": config_reader["DATABASE"].get("db_password"),
135
136            "API_HOST": config_reader["API"].get("api_host"),
137            "API_PORT": config_reader["API"].getint("api_port"),
138
139            "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}),
140
141            "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath(
142                "..").resolve(),  # better don"t change this
143            "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")),
144            "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")),
145            "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")),
146            "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")),
147            "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")),
148
149            "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"),
150            "SECRET_KEY": config_reader["GENERATE"].get("secret_key")
151        })
152
153
154    def load_memcache(self):
155        """
156        Initialise memcache client
157
158        The config reader can optionally use Memcache to keep fetched values in
159        memory.
160        """
161        if self.get("MEMCACHE_SERVER"):
162            try:
163                # do one test fetch to test if connection is valid
164                memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config")
165                memcache.set("4cat-init-dummy", time.time())
166                memcache.init_thread_id = threading.get_ident()
167                return memcache
168            except (SystemError, ValueError, MemcacheError, ConnectionError, OSError):
169                # we have no access to the logger here so we simply pass
170                # later we can detect elsewhere that a memcache address is
171                # configured but no connection is there - then we can log
172                # config reader still works without memcache
173                pass
174
175        return None
176
177    def ensure_database(self):
178        """
179        Ensure the database is in sync with the config definition
180
181        Deletes all stored settings not defined in 4CAT, and creates a global
182        setting for all settings not yet in the database.
183        """
184        self.with_db()
185
186        # create global values for known keys with the default
187        known_settings = self.get_all_setting_names()
188        for setting, parameters in self.config_definition.items():
189            if setting in known_settings:
190                continue
191
192            self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}")
193            self.set(setting, parameters.get("default", ""))
194
195        # make sure settings and user table are in sync
196        user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")])))
197        known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")]
198        tag_order = self.get("flask.tag_order")
199
200        for tag in known_tags:
201            # add tags used by a setting to tag order
202            if tag and tag not in tag_order:
203                tag_order.append(tag)
204
205        for tag in user_tags:
206            # add tags used by a user to tag order
207            if tag and tag not in tag_order:
208                tag_order.append(tag)
209
210        # admin tag should always be first in order
211        if "admin" in tag_order:
212            tag_order.remove("admin")
213
214        tag_order.insert(0, "admin")
215
216        self.set("flask.tag_order", tag_order)
217        self.db.commit()
218
219    def get_all_setting_names(self, with_core=True):
220        """
221        Get names of all settings
222
223        For when the value doesn't matter!
224
225        :param bool with_core:  Also include core (i.e. config.ini) settings
226        :return list:  List of setting names known by the database and core settings
227        """
228        # attempt to initialise the database connection so we can include
229        # user settings
230        if not self.db:
231            self.with_db()
232
233        settings = list(self.core_settings.keys()) if with_core else []
234        settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")])
235
236        return settings
237
238    def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None):
239        """
240        Get all known settings
241
242        This is *not optimised* but used rarely enough that that doesn't
243        matter so much.
244
245        :param bool is_json:  if True, the value is returned as stored and not
246        interpreted as JSON if it comes from the database
247        :param user:  User object or name. Adds a tag `user:[username]` in
248        front of the tag list.
249        :param tags:  Tag or tags for the required setting. If a tag is
250        provided, the method checks if a special value for the setting exists
251        with the given tag, and returns that if one exists. First matching tag
252        wins.
253        :param bool with_core:  Also include core (i.e. config.ini) settings
254        :param MemcacheClient memcache:  Memcache client. If `None` and
255        `self.memcache` exists, use that instead.
256
257        :return dict: Setting value, as a dictionary with setting names as keys
258        and setting values as values.
259        """
260        for setting in self.get_all_setting_names(with_core=with_core):
261            yield setting, self.get(setting, None, is_json, user, tags, memcache)
262
263
264    def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None):
265        """
266        Get a setting's value from the database
267
268        If the setting does not exist, the provided fallback value is returned.
269
270        :param str attribute_name:  Setting to return.
271        :param default:  Value to return if setting does not exist
272        :param bool is_json:  if True, the value is returned as stored and not
273        interpreted as JSON if it comes from the database
274        :param user:  User object or name. Adds a tag `user:[username]` in
275        front of the tag list.
276        :param tags:  Tag or tags for the required setting. If a tag is
277        provided, the method checks if a special value for the setting exists
278        with the given tag, and returns that if one exists. First matching tag
279        wins.
280        :param MemcacheClient memcache:  Memcache client. If `None` and
281        `self.memcache` exists, use that instead.
282
283        :return:  Setting value, or the provided fallback, or `None`.
284        """
285        # core settings are not from the database
286        # they are therefore also not memcached - too little gain
287        if type(attribute_name) is not str:
288            raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given")
289
290        if attribute_name in self.core_settings:
291            # we never get to the database or memcache part of this method if
292            # this is a core setting we already know
293            return self.core_settings[attribute_name]
294
295        # if trying to access a setting that's not a core setting, attempt to
296        # initialise the database connection
297        if not self.db:
298            self.with_db()
299
300        # get tags to look for
301        # copy() because else we keep adding onto the same list, which
302        # interacts badly with get_all()
303        if tags:
304            tags = tags.copy()
305        tags = self.get_active_tags(user, tags, memcache)
306
307        # now we have all tags - get the config values for each (if available)
308        # and then return the first matching one. Add the 'empty' tag at the
309        # end to fall back to the global value if no specific one exists.
310        tags.append("")
311
312        # short-circuit via memcache if appropriate
313        if not memcache and self.memcache:
314            memcache = self.memcache
315
316        # first check if we have all the values in memcache, in which case we
317        # do not need a database query
318        if memcache:
319            if threading.get_ident() != memcache.init_thread_id:
320                raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration "
321                                   "wrapper to read with a thread-local memcache connection.")
322
323            cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags}
324
325        else:
326            cached_values = {t: CacheMiss for t in tags}
327
328        # for the tags we could not get from memcache, run a database query
329        # (and save to cache if possible)
330        missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss]
331        if missing_tags:
332            # query database for any values within the required tags
333            query = "SELECT * FROM settings WHERE name = %s AND tag IN %s"
334            replacements = (attribute_name, tuple(missing_tags))
335            queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)}
336
337            if memcache:
338                for tag, value in queried_settings.items():
339                    memcache.set(self._get_memcache_id(attribute_name, tag), value)
340
341            cached_values.update(queried_settings)
342
343        # there may be some tags for which we still do not have a value at
344        # this point. these simply do not have a tag-specific value but that in
345        # itself is worth caching, otherwise we're going to query for a
346        # non-existent value each time.
347        # so: cache a magic value for such setting/tag combinations, and
348        # replace the magic value with a CacheMiss in the dict that will be
349        # parsed
350        unconfigured_magic = "__unconfigured__"
351        if memcache:
352            for tag in [t for t in cached_values if cached_values[t] is CacheMiss]:
353                # should this be more magic?
354                memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic)
355
356            for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]:
357                cached_values[tag] = CacheMiss
358
359        # now we may still have some CacheMisses in the values dict, if there
360        # was no setting in the database with that tag. So, find the first
361        # value that is not a CacheMiss. If nothing matches, try the global tag
362        # and if even that does not match (no setting saved at all) return the
363        # default
364        for tag in tags:
365            if tag in cached_values and cached_values.get(tag) is not CacheMiss:
366                value = cached_values[tag]
367                break
368        else:
369            value = None
370
371        # parse some values...
372        if not is_json and value is not None:
373            value = json.loads(value)
374        # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale
375        elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]:
376            value = self.config_definition[attribute_name]["default"]
377        elif value is None and default is not None:
378            value = default
379
380        return value
381
382    def get_active_tags(self, user=None, tags=None, memcache=None):
383        """
384        Get active tags for given user/tag list
385
386        Used internally to harmonize tag setting for various methods, but can
387        also be called directly to verify tag activation.
388
389        :param user:  User object or name. Adds a tag `user:[username]` in
390        front of the tag list.
391        :param tags:  Tag or tags for the required setting. If a tag is
392        provided, the method checks if a special value for the setting exists
393        with the given tag, and returns that if one exists. First matching tag
394        wins.
395        :param MemcacheClient memcache:  Memcache client. If `None` and
396        `self.memcache` exists, use that instead.
397        :return list:  List of tags
398        """
399        # be flexible about the input types here
400        if tags is None:
401            tags = []
402        elif type(tags) is str:
403            tags = [tags]
404
405        user = self._normalise_user(user)
406
407        # user-specific settings are just a special type of tag (which takes
408        # precedence), same goes for user groups. so if a user was passed, get
409        # that user's tags (including the 'special' user: tag) and add them
410        # to the list
411        if user:
412            user_tags = CacheMiss
413            
414            if not memcache and self.memcache:
415                memcache = self.memcache
416                
417            if memcache:
418                memcache_id = f"_usertags-{user}"
419                user_tags = memcache.get(memcache_id, default=CacheMiss)
420
421            if user_tags is CacheMiss:
422                user_tags = self.db.fetchone("SELECT tags FROM users WHERE name = %s", (user,))
423                if user_tags and memcache:
424                    memcache.set(memcache_id, user_tags)
425
426            if user_tags:
427                try:
428                    tags.extend(user_tags["tags"])
429                except (TypeError, ValueError):
430                    # should be a JSON list, but isn't
431                    pass
432
433            tags.insert(0, f"user:{user}")
434
435        return tags
436
437    def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None):
438        """
439        Insert OR set value for a setting
440
441        If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the
442        setting exists the setting is not updated.
443
444        :param str attribute_name:  Attribute to set
445        :param value:  Value to set (will be serialised as JSON)
446        :param bool is_json:  True for a value that is already a serialised JSON string; False if value is object that needs to
447                          be serialised into a JSON string
448        :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists
449        :param str tag:  Tag to write setting for
450        :param MemcacheClient memcache:  Memcache client. If `None` and
451        `self.memcache` exists, use that instead.
452
453        :return int: number of updated rows
454        """
455        # Check value is valid JSON
456        if is_json:
457            try:
458                json.dumps(json.loads(value))
459            except json.JSONDecodeError:
460                return None
461        else:
462            try:
463                value = json.dumps(value)
464            except json.JSONDecodeError:
465                return None
466
467        if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"):
468            tag = ""
469
470        if overwrite_existing:
471            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value"
472        else:
473            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING"
474
475        self.db.execute(query, (attribute_name, value, tag))
476        updated_rows = self.db.cursor.rowcount
477        self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})")
478
479        if not memcache and self.memcache:
480            memcache = self.memcache
481
482        if memcache:
483            # invalidate any cached value for this setting
484            memcache_id = self._get_memcache_id(attribute_name, tag)
485            memcache.delete(memcache_id)
486
487        return updated_rows
488
489    def delete_for_tag(self, attribute_name, tag):
490        """
491        Delete config override for a given tag
492
493        :param str attribute_name:
494        :param str tag:
495        :return int: number of deleted rows
496        """
497        self.db.delete("settings", where={"name": attribute_name, "tag": tag})
498        updated_rows = self.db.cursor.rowcount
499
500        if self.memcache:
501            self.memcache.delete(self._get_memcache_id(attribute_name, tag))
502
503        return updated_rows
504
505    def clear_cache(self):
506        """
507        Clear cached configuration values
508
509        Called when the backend restarts - helps start with a blank slate.
510        """
511        if not self.memcache:
512            return
513
514        self.memcache.flush_all()
515
516    def uncache_user_tags(self, users):
517        """
518        Clear cached user tags
519
520        User tags are cached with memcache if possible to avoid unnecessary
521        database roundtrips. This method clears the cached user tags, in case
522        a tag is added/deleted from a user.
523
524        :param list users:  List of users, as usernames or User objects
525        """
526        if self.memcache:
527            for user in users:
528                user = self._normalise_user(user)
529                self.memcache.delete(f"_usertags-{user}")
530
531    def _normalise_user(self, user):
532        """
533        Normalise user object
534
535        Users may be passed as a username, a user object, or a proxy of such an
536        object. This method normalises this to a string (the username), or
537        `None` if no user is provided.
538
539        :param user:  User value to normalise
540        :return str|None:  Normalised value
541        """
542
543        # can provide either a string or user object
544        if type(user) is not str:
545            if type(user).__name__ == "LocalProxy":
546                # passed on from Flask
547                user = user._get_current_object()
548
549            if hasattr(user, "get_id"):
550                user = user.get_id()
551            elif user != None:  # noqa: E711
552                # werkzeug.local.LocalProxy (e.g., user not yet logged in) wraps None; use '!=' instead of 'is not'
553                raise TypeError(
554                    f"_normalise_user() expects None, a User object or a string for argument 'user', {type(user).__name__} given"
555                )
556
557        return user
558
559    def _get_memcache_id(self, attribute_name, tags=None):
560        """
561        Generate a memcache key for a config setting request
562
563        This includes the relevant user name/tags because the value may be
564        different depending on the value of these parameters.
565
566        :param str attribute_name:
567        :param str|list tags:
568        :return str:
569        """
570        if tags and isinstance(tags, str):
571            tags = [tags]
572
573        tag_bit = []
574        if tags:
575            tag_bit.append("|".join(tags))
576
577        memcache_id = attribute_name
578        if tag_bit:
579            memcache_id += f"-{'-'.join(tag_bit)}"
580
581        return memcache_id.encode("ascii")
582
583    def __getattr__(self, attr):
584        """
585        Getter so we can directly request values
586
587        :param attr:  Config setting to get
588        :return:  Value
589        """
590
591        if attr in dir(self):
592            # an explicitly defined attribute should always be called in favour
593            # of this passthrough
594            attribute = getattr(self, attr)
595            return attribute
596        else:
597            return self.get(attr)
598
599
600class ConfigWrapper:
601    """
602    Wrapper for the config manager
603
604    Allows setting a default set of tags or user, so that all subsequent calls
605    to `get()` are done for those tags or that user. Can also adjust tags based
606    on the HTTP request, if used in a Flask context.
607    """
608    def __init__(self, config, user=None, tags=None, request=None):
609        """
610        Initialise config wrapper
611
612        :param ConfigManager config:  Initialised config manager
613        :param user:  User to get settings for
614        :param tags:  Tags to get settings for
615        :param request:  Request to get headers from. This can be used to set
616        a particular tag based on the HTTP headers of the request, e.g. to
617        serve 4CAT with a different configuration based on the proxy server
618        used.
619        """
620        if type(config) is ConfigWrapper:
621            # let's not do nested wrappers, but copy properties unless
622            # provided explicitly
623            self.user = user if user else config.user
624            self.tags = tags if tags else config.tags
625            self.request = request if request else config.request
626            self.config = config.config
627            self.memcache = config.memcache
628        else:
629            self.config = config
630            self.user = user
631            self.tags = tags
632            self.request = request
633
634            # this ensures we use our own memcache client, important in threaded
635            # contexts because pymemcache is not thread-safe. Unless we get a
636            # prepared connection...
637            self.memcache = self.config.load_memcache()
638
639        # this ensures the user object in turn reads from the wrapper
640        if self.user:
641            self.user.with_config(self, rewrap=False)
642
643
644    def set(self, *args, **kwargs):
645        """
646        Wrap `set()`
647
648        :param args:
649        :param kwargs:
650        :return:
651        """
652        if "tag" not in kwargs and self.tags:
653            kwargs["tag"] = self.tags
654
655        kwargs["memcache"] = self.memcache
656
657        return self.config.set(*args, **kwargs)
658
659    def get_all(self, *args, **kwargs):
660        """
661        Wrap `get_all()`
662
663        Takes the `user`, `tags` and `request` given when initialised into
664        account. If `tags` is set explicitly, the HTTP header-based override
665        is not applied.
666
667        :param args:
668        :param kwargs:
669        :return:
670        """
671        if "user" not in kwargs and self.user:
672            kwargs["user"] = self.user
673
674        if "tags" not in kwargs:
675            kwargs["tags"] = self.tags if self.tags else []
676            kwargs["tags"] = self.request_override(kwargs["tags"])
677
678        kwargs["memcache"] = self.memcache
679
680        return self.config.get_all(*args, **kwargs)
681
682    def get(self, *args, **kwargs):
683        """
684        Wrap `get()`
685
686        Takes the `user`, `tags` and `request` given when initialised into
687        account. If `tags` is set explicitly, the HTTP header-based override
688        is not applied.
689
690        :param args:
691        :param kwargs:
692        :return:
693        """
694        if "user" not in kwargs:
695            kwargs["user"] = self.user
696
697        if "tags" not in kwargs:
698            kwargs["tags"] = self.tags if self.tags else []
699            kwargs["tags"] = self.request_override(kwargs["tags"])
700
701        kwargs["memcache"] = self.memcache
702
703        return self.config.get(*args, **kwargs)
704
705    def get_active_tags(self, user=None, tags=None):
706        """
707        Wrap `get_active_tags()`
708
709        Takes the `user`, `tags` and `request` given when initialised into
710        account. If `tags` is set explicitly, the HTTP header-based override
711        is not applied.
712
713        :param user:
714        :param tags:
715        :return list:
716        """
717        active_tags = self.config.get_active_tags(user, tags, self.memcache)
718        if not tags:
719            active_tags = self.request_override(active_tags)
720
721        return active_tags
722
723    def request_override(self, tags):
724        """
725        Force tag via HTTP request headers
726
727        To facilitate loading different configurations based on the HTTP
728        request, the request object can be passed to the ConfigWrapper and
729        if a certain request header is set, the value of that header will be
730        added to the list of tags to consider when retrieving settings.
731
732        See the flask.proxy_secret config setting; this is used to prevent
733        users from changing configuration by forging the header.
734
735        :param list|str tags:  List of tags to extend based on request
736        :return list:  Amended list of tags
737        """
738        if type(tags) is str:
739            tags = [tags]
740
741        # use self.config.get here, not self.get, because else we get infinite
742        # recursion (since self.get can call this method)
743        if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \
744            self.config.get("flask.proxy_secret", memcache=self.memcache) and \
745            self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache):
746            # need to ensure not just anyone can add this header to their
747            # request!
748            # to this end, the second header must be set to the secret value;
749            # if it is not set, assume the headers are not being configured by
750            # the proxy server
751            if not tags:
752                tags = []
753
754            # can never set admin tag via headers (should always be user-based)
755            forbidden_overrides = ("admin",)
756            tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides]
757
758        return tags
759
760
761    def __getattr__(self, item):
762        """
763        Generic wrapper
764
765        Just pipe everything through to the config object
766
767        :param item:
768        :return:
769        """
770        if hasattr(self.config, item):
771            return getattr(self.config, item)
772        elif hasattr(self, item):
773            return getattr(self, item)
774        else:
775            raise AttributeError(f"'{self.__name__}' object has no attribute '{item}'")
776
777
778class CoreConfigManager(ConfigManager):
779    """
780    A configuration reader that can only read from core settings
781
782    Can be used in thread-unsafe context and when no database is present.
783    """
784    def with_db(self, db=None):
785        """
786        Raise a RuntimeError when trying to link a database connection
787
788        :param db:
789        """
790        raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")
class CacheMiss:
20class CacheMiss:
21    """
22    Helper class to distinguish memcache misses from true `None` values
23    """
24    pass

Helper class to distinguish memcache misses from true None values

class ConfigManager:
 27class ConfigManager:
 28    db = None
 29    dbconn = None
 30    cache = {}
 31    memcache = None
 32
 33    core_settings = {}
 34    config_definition = {}
 35
 36    def __init__(self, db=None):
 37        # ensure core settings (including database config) are loaded
 38        self.load_core_settings()
 39        self.load_user_settings()
 40        self.memcache = self.load_memcache()
 41
 42        # establish database connection if none available
 43        if db:
 44            self.with_db(db)
 45
 46    def with_db(self, db=None):
 47        """
 48        Initialise database
 49
 50        Not done on init, because something may need core settings before the
 51        database can be initialised
 52
 53        :param db:  Database object. If None, initialise it using the core config
 54        """
 55        if db or not self.db:
 56            # Replace w/ db if provided else only initialise if not already
 57            self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"),
 58                                         password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"),
 59                                         port=self.get("DB_PORT"), appname="config-reader")
 60        else:
 61            # self.db already initialized and no db provided
 62            pass
 63
 64    def load_user_settings(self):
 65        """
 66        Load settings configurable by the user
 67
 68        Does not load the settings themselves, but rather the definition so
 69        values can be validated, etc
 70        """
 71        # basic 4CAT settings
 72        self.config_definition.update(config_definition)
 73
 74        # module settings can't be loaded directly because modules need the
 75        # config manager to load, so that becomes circular
 76        # instead, this is cached on startup and then loaded here
 77        module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin")
 78        if module_config_path.exists():
 79            try:
 80                with module_config_path.open("rb") as infile:
 81                    retries = 0
 82                    module_config = None
 83                    # if 4CAT is being run in two different containers
 84                    # (front-end and back-end) they might both be running this
 85                    # bit of code at the same time. If the file is half-written
 86                    # loading it will fail, so allow for a few retries
 87                    while retries < 3:
 88                        try:
 89                            module_config = pickle.load(infile)
 90                            break
 91                        except Exception:  # this can be a number of exceptions, all with the same recovery path
 92                            time.sleep(0.1)
 93                            retries += 1
 94                            continue
 95
 96                    if module_config is None:
 97                        # not really a way to gracefully recover from this, but
 98                        # we can at least describe the error
 99                        raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of "
100                                           "preventing this. Shame on them!")
101
102                    self.config_definition.update(module_config)
103            except (ValueError, TypeError):
104                pass
105
106    def load_core_settings(self):
107        """
108        Load 4CAT core settings
109
110        These are (mostly) stored in config.ini and cannot be changed from the
111        web interface.
112
113        :return:
114        """
115        config_file = Path(__file__).parent.parent.joinpath("config/config.ini")
116
117        config_reader = configparser.ConfigParser()
118        in_docker = False
119        if config_file.exists():
120            config_reader.read(config_file)
121            if config_reader["DOCKER"].getboolean("use_docker_config"):
122                # Can use throughtout 4CAT to know if Docker environment
123                in_docker = True
124        else:
125            # config should be created!
126            raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.")
127
128        self.core_settings.update({
129            "CONFIG_FILE": config_file.resolve(),
130            "USING_DOCKER": in_docker,
131            "DB_HOST": config_reader["DATABASE"].get("db_host"),
132            "DB_PORT": config_reader["DATABASE"].get("db_port"),
133            "DB_USER": config_reader["DATABASE"].get("db_user"),
134            "DB_NAME": config_reader["DATABASE"].get("db_name"),
135            "DB_PASSWORD": config_reader["DATABASE"].get("db_password"),
136
137            "API_HOST": config_reader["API"].get("api_host"),
138            "API_PORT": config_reader["API"].getint("api_port"),
139
140            "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}),
141
142            "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath(
143                "..").resolve(),  # better don"t change this
144            "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")),
145            "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")),
146            "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")),
147            "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")),
148            "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")),
149
150            "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"),
151            "SECRET_KEY": config_reader["GENERATE"].get("secret_key")
152        })
153
154
155    def load_memcache(self):
156        """
157        Initialise memcache client
158
159        The config reader can optionally use Memcache to keep fetched values in
160        memory.
161        """
162        if self.get("MEMCACHE_SERVER"):
163            try:
164                # do one test fetch to test if connection is valid
165                memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config")
166                memcache.set("4cat-init-dummy", time.time())
167                memcache.init_thread_id = threading.get_ident()
168                return memcache
169            except (SystemError, ValueError, MemcacheError, ConnectionError, OSError):
170                # we have no access to the logger here so we simply pass
171                # later we can detect elsewhere that a memcache address is
172                # configured but no connection is there - then we can log
173                # config reader still works without memcache
174                pass
175
176        return None
177
178    def ensure_database(self):
179        """
180        Ensure the database is in sync with the config definition
181
182        Deletes all stored settings not defined in 4CAT, and creates a global
183        setting for all settings not yet in the database.
184        """
185        self.with_db()
186
187        # create global values for known keys with the default
188        known_settings = self.get_all_setting_names()
189        for setting, parameters in self.config_definition.items():
190            if setting in known_settings:
191                continue
192
193            self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}")
194            self.set(setting, parameters.get("default", ""))
195
196        # make sure settings and user table are in sync
197        user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")])))
198        known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")]
199        tag_order = self.get("flask.tag_order")
200
201        for tag in known_tags:
202            # add tags used by a setting to tag order
203            if tag and tag not in tag_order:
204                tag_order.append(tag)
205
206        for tag in user_tags:
207            # add tags used by a user to tag order
208            if tag and tag not in tag_order:
209                tag_order.append(tag)
210
211        # admin tag should always be first in order
212        if "admin" in tag_order:
213            tag_order.remove("admin")
214
215        tag_order.insert(0, "admin")
216
217        self.set("flask.tag_order", tag_order)
218        self.db.commit()
219
220    def get_all_setting_names(self, with_core=True):
221        """
222        Get names of all settings
223
224        For when the value doesn't matter!
225
226        :param bool with_core:  Also include core (i.e. config.ini) settings
227        :return list:  List of setting names known by the database and core settings
228        """
229        # attempt to initialise the database connection so we can include
230        # user settings
231        if not self.db:
232            self.with_db()
233
234        settings = list(self.core_settings.keys()) if with_core else []
235        settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")])
236
237        return settings
238
239    def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None):
240        """
241        Get all known settings
242
243        This is *not optimised* but used rarely enough that that doesn't
244        matter so much.
245
246        :param bool is_json:  if True, the value is returned as stored and not
247        interpreted as JSON if it comes from the database
248        :param user:  User object or name. Adds a tag `user:[username]` in
249        front of the tag list.
250        :param tags:  Tag or tags for the required setting. If a tag is
251        provided, the method checks if a special value for the setting exists
252        with the given tag, and returns that if one exists. First matching tag
253        wins.
254        :param bool with_core:  Also include core (i.e. config.ini) settings
255        :param MemcacheClient memcache:  Memcache client. If `None` and
256        `self.memcache` exists, use that instead.
257
258        :return dict: Setting value, as a dictionary with setting names as keys
259        and setting values as values.
260        """
261        for setting in self.get_all_setting_names(with_core=with_core):
262            yield setting, self.get(setting, None, is_json, user, tags, memcache)
263
264
265    def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None):
266        """
267        Get a setting's value from the database
268
269        If the setting does not exist, the provided fallback value is returned.
270
271        :param str attribute_name:  Setting to return.
272        :param default:  Value to return if setting does not exist
273        :param bool is_json:  if True, the value is returned as stored and not
274        interpreted as JSON if it comes from the database
275        :param user:  User object or name. Adds a tag `user:[username]` in
276        front of the tag list.
277        :param tags:  Tag or tags for the required setting. If a tag is
278        provided, the method checks if a special value for the setting exists
279        with the given tag, and returns that if one exists. First matching tag
280        wins.
281        :param MemcacheClient memcache:  Memcache client. If `None` and
282        `self.memcache` exists, use that instead.
283
284        :return:  Setting value, or the provided fallback, or `None`.
285        """
286        # core settings are not from the database
287        # they are therefore also not memcached - too little gain
288        if type(attribute_name) is not str:
289            raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given")
290
291        if attribute_name in self.core_settings:
292            # we never get to the database or memcache part of this method if
293            # this is a core setting we already know
294            return self.core_settings[attribute_name]
295
296        # if trying to access a setting that's not a core setting, attempt to
297        # initialise the database connection
298        if not self.db:
299            self.with_db()
300
301        # get tags to look for
302        # copy() because else we keep adding onto the same list, which
303        # interacts badly with get_all()
304        if tags:
305            tags = tags.copy()
306        tags = self.get_active_tags(user, tags, memcache)
307
308        # now we have all tags - get the config values for each (if available)
309        # and then return the first matching one. Add the 'empty' tag at the
310        # end to fall back to the global value if no specific one exists.
311        tags.append("")
312
313        # short-circuit via memcache if appropriate
314        if not memcache and self.memcache:
315            memcache = self.memcache
316
317        # first check if we have all the values in memcache, in which case we
318        # do not need a database query
319        if memcache:
320            if threading.get_ident() != memcache.init_thread_id:
321                raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration "
322                                   "wrapper to read with a thread-local memcache connection.")
323
324            cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags}
325
326        else:
327            cached_values = {t: CacheMiss for t in tags}
328
329        # for the tags we could not get from memcache, run a database query
330        # (and save to cache if possible)
331        missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss]
332        if missing_tags:
333            # query database for any values within the required tags
334            query = "SELECT * FROM settings WHERE name = %s AND tag IN %s"
335            replacements = (attribute_name, tuple(missing_tags))
336            queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)}
337
338            if memcache:
339                for tag, value in queried_settings.items():
340                    memcache.set(self._get_memcache_id(attribute_name, tag), value)
341
342            cached_values.update(queried_settings)
343
344        # there may be some tags for which we still do not have a value at
345        # this point. these simply do not have a tag-specific value but that in
346        # itself is worth caching, otherwise we're going to query for a
347        # non-existent value each time.
348        # so: cache a magic value for such setting/tag combinations, and
349        # replace the magic value with a CacheMiss in the dict that will be
350        # parsed
351        unconfigured_magic = "__unconfigured__"
352        if memcache:
353            for tag in [t for t in cached_values if cached_values[t] is CacheMiss]:
354                # should this be more magic?
355                memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic)
356
357            for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]:
358                cached_values[tag] = CacheMiss
359
360        # now we may still have some CacheMisses in the values dict, if there
361        # was no setting in the database with that tag. So, find the first
362        # value that is not a CacheMiss. If nothing matches, try the global tag
363        # and if even that does not match (no setting saved at all) return the
364        # default
365        for tag in tags:
366            if tag in cached_values and cached_values.get(tag) is not CacheMiss:
367                value = cached_values[tag]
368                break
369        else:
370            value = None
371
372        # parse some values...
373        if not is_json and value is not None:
374            value = json.loads(value)
375        # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale
376        elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]:
377            value = self.config_definition[attribute_name]["default"]
378        elif value is None and default is not None:
379            value = default
380
381        return value
382
383    def get_active_tags(self, user=None, tags=None, memcache=None):
384        """
385        Get active tags for given user/tag list
386
387        Used internally to harmonize tag setting for various methods, but can
388        also be called directly to verify tag activation.
389
390        :param user:  User object or name. Adds a tag `user:[username]` in
391        front of the tag list.
392        :param tags:  Tag or tags for the required setting. If a tag is
393        provided, the method checks if a special value for the setting exists
394        with the given tag, and returns that if one exists. First matching tag
395        wins.
396        :param MemcacheClient memcache:  Memcache client. If `None` and
397        `self.memcache` exists, use that instead.
398        :return list:  List of tags
399        """
400        # be flexible about the input types here
401        if tags is None:
402            tags = []
403        elif type(tags) is str:
404            tags = [tags]
405
406        user = self._normalise_user(user)
407
408        # user-specific settings are just a special type of tag (which takes
409        # precedence), same goes for user groups. so if a user was passed, get
410        # that user's tags (including the 'special' user: tag) and add them
411        # to the list
412        if user:
413            user_tags = CacheMiss
414            
415            if not memcache and self.memcache:
416                memcache = self.memcache
417                
418            if memcache:
419                memcache_id = f"_usertags-{user}"
420                user_tags = memcache.get(memcache_id, default=CacheMiss)
421
422            if user_tags is CacheMiss:
423                user_tags = self.db.fetchone("SELECT tags FROM users WHERE name = %s", (user,))
424                if user_tags and memcache:
425                    memcache.set(memcache_id, user_tags)
426
427            if user_tags:
428                try:
429                    tags.extend(user_tags["tags"])
430                except (TypeError, ValueError):
431                    # should be a JSON list, but isn't
432                    pass
433
434            tags.insert(0, f"user:{user}")
435
436        return tags
437
438    def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None):
439        """
440        Insert OR set value for a setting
441
442        If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the
443        setting exists the setting is not updated.
444
445        :param str attribute_name:  Attribute to set
446        :param value:  Value to set (will be serialised as JSON)
447        :param bool is_json:  True for a value that is already a serialised JSON string; False if value is object that needs to
448                          be serialised into a JSON string
449        :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists
450        :param str tag:  Tag to write setting for
451        :param MemcacheClient memcache:  Memcache client. If `None` and
452        `self.memcache` exists, use that instead.
453
454        :return int: number of updated rows
455        """
456        # Check value is valid JSON
457        if is_json:
458            try:
459                json.dumps(json.loads(value))
460            except json.JSONDecodeError:
461                return None
462        else:
463            try:
464                value = json.dumps(value)
465            except json.JSONDecodeError:
466                return None
467
468        if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"):
469            tag = ""
470
471        if overwrite_existing:
472            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value"
473        else:
474            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING"
475
476        self.db.execute(query, (attribute_name, value, tag))
477        updated_rows = self.db.cursor.rowcount
478        self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})")
479
480        if not memcache and self.memcache:
481            memcache = self.memcache
482
483        if memcache:
484            # invalidate any cached value for this setting
485            memcache_id = self._get_memcache_id(attribute_name, tag)
486            memcache.delete(memcache_id)
487
488        return updated_rows
489
490    def delete_for_tag(self, attribute_name, tag):
491        """
492        Delete config override for a given tag
493
494        :param str attribute_name:
495        :param str tag:
496        :return int: number of deleted rows
497        """
498        self.db.delete("settings", where={"name": attribute_name, "tag": tag})
499        updated_rows = self.db.cursor.rowcount
500
501        if self.memcache:
502            self.memcache.delete(self._get_memcache_id(attribute_name, tag))
503
504        return updated_rows
505
506    def clear_cache(self):
507        """
508        Clear cached configuration values
509
510        Called when the backend restarts - helps start with a blank slate.
511        """
512        if not self.memcache:
513            return
514
515        self.memcache.flush_all()
516
517    def uncache_user_tags(self, users):
518        """
519        Clear cached user tags
520
521        User tags are cached with memcache if possible to avoid unnecessary
522        database roundtrips. This method clears the cached user tags, in case
523        a tag is added/deleted from a user.
524
525        :param list users:  List of users, as usernames or User objects
526        """
527        if self.memcache:
528            for user in users:
529                user = self._normalise_user(user)
530                self.memcache.delete(f"_usertags-{user}")
531
532    def _normalise_user(self, user):
533        """
534        Normalise user object
535
536        Users may be passed as a username, a user object, or a proxy of such an
537        object. This method normalises this to a string (the username), or
538        `None` if no user is provided.
539
540        :param user:  User value to normalise
541        :return str|None:  Normalised value
542        """
543
544        # can provide either a string or user object
545        if type(user) is not str:
546            if type(user).__name__ == "LocalProxy":
547                # passed on from Flask
548                user = user._get_current_object()
549
550            if hasattr(user, "get_id"):
551                user = user.get_id()
552            elif user != None:  # noqa: E711
553                # werkzeug.local.LocalProxy (e.g., user not yet logged in) wraps None; use '!=' instead of 'is not'
554                raise TypeError(
555                    f"_normalise_user() expects None, a User object or a string for argument 'user', {type(user).__name__} given"
556                )
557
558        return user
559
560    def _get_memcache_id(self, attribute_name, tags=None):
561        """
562        Generate a memcache key for a config setting request
563
564        This includes the relevant user name/tags because the value may be
565        different depending on the value of these parameters.
566
567        :param str attribute_name:
568        :param str|list tags:
569        :return str:
570        """
571        if tags and isinstance(tags, str):
572            tags = [tags]
573
574        tag_bit = []
575        if tags:
576            tag_bit.append("|".join(tags))
577
578        memcache_id = attribute_name
579        if tag_bit:
580            memcache_id += f"-{'-'.join(tag_bit)}"
581
582        return memcache_id.encode("ascii")
583
584    def __getattr__(self, attr):
585        """
586        Getter so we can directly request values
587
588        :param attr:  Config setting to get
589        :return:  Value
590        """
591
592        if attr in dir(self):
593            # an explicitly defined attribute should always be called in favour
594            # of this passthrough
595            attribute = getattr(self, attr)
596            return attribute
597        else:
598            return self.get(attr)
ConfigManager(db=None)
36    def __init__(self, db=None):
37        # ensure core settings (including database config) are loaded
38        self.load_core_settings()
39        self.load_user_settings()
40        self.memcache = self.load_memcache()
41
42        # establish database connection if none available
43        if db:
44            self.with_db(db)
db = None
dbconn = None
cache = {}
memcache = None
core_settings = {'CONFIG_FILE': PosixPath('/opt/docs-maker/4cat/config/config.ini'), 'USING_DOCKER': False, 'DB_HOST': 'localhost', 'DB_PORT': '5432', 'DB_USER': 'fourcat', 'DB_NAME': 'fourcat', 'DB_PASSWORD': 'supers3cr3t', 'API_HOST': 'localhost', 'API_PORT': 4444, 'MEMCACHE_SERVER': {}, 'PATH_ROOT': PosixPath('/opt/docs-maker/4cat'), 'PATH_LOGS': PosixPath('logs'), 'PATH_IMAGES': PosixPath('data'), 'PATH_DATA': PosixPath('data'), 'PATH_LOCKFILE': PosixPath('backend'), 'PATH_SESSIONS': PosixPath('sessions'), 'ANONYMISATION_SALT': 'REPLACE_THIS', 'SECRET_KEY': 'REPLACE_THIS'}
config_definition = {'datasources.intro': {'type': 'info', 'help': "Data sources enabled below will be offered to people on the 'Create Dataset' page. Additionally, people can upload datasets for these by for example exporting them with [Zeeschuimer](https://github.com/digitalmethodsinitiative/zeeschuimer) to this 4CAT instance.\n\nSome data sources offer further settings which may be configured on other tabs."}, 'datasources.intro2': {'type': 'info', 'help': "*Warning:* changes take effect immediately. Datasets that would have expired under the new settings will be deleted. You can use the 'Dataset bulk management' module in the control panel to manage the expiration status of existing datasets."}, 'datasources.enabled': {'type': 'datasources', 'default': ['ninegag', 'bsky', 'douban', 'douyin', 'imgur', 'upload', 'instagram', 'import_4cat', 'linkedin', 'media-import', 'telegram', 'tiktok', 'twitter', 'tiktok-comments', 'truthsocial', 'gab'], 'help': 'Data Sources', 'tooltip': 'A list of enabled data sources that people can choose from when creating a dataset page.'}, 'datasources.expiration': {'type': 'json', 'default': {'fourchan': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'eightchan': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'eightkun': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'ninegag': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'bitchute': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'bsky': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'dmi-tcat': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'dmi-tcatv2': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'douban': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'douyin': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'import_4cat': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'gab': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'imgur': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'upload': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'instagram': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'linkedin': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'media-import': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'parler': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'reddit': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'telegram': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'tiktok': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'tiktok-urls': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'truthsocial': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'tumblr': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'twitter': {'enabled': True, 'allow_optout': False, 'timeout': 0}, 'twitterv2': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'usenet': {'enabled': False, 'allow_optout': False, 'timeout': 0}, 'vk': {'enabled': False, 'allow_optout': False, 'timeout': 0}}, 'help': 'Data source-specific expiration', 'tooltip': "Allows setting expiration settings per datasource. Configured by proxy via the 'data sources' setting.", 'indirect': True}, '4cat.name': {'type': 'string', 'default': '4CAT', 'help': 'Short tool name', 'tooltip': "Configure short name for the tool in its web interface. The backend will always refer to '4CAT' - the name of the software, and a 'powered by 4CAT' notice may also show up in the web interface regardless of the value entered here."}, '4cat.name_long': {'type': 'string', 'default': '4CAT: Capture and Analysis Toolkit', 'help': 'Full tool name', 'tooltip': "Used in e.g. the interface header. The backend will always refer to '4CAT' - the name of the software, and a 'powered by 4CAT' notice may also show up in the web interface regardless of the value entered here."}, '4cat.about_this_server': {'type': 'textarea', 'default': '', 'help': 'Server information', 'tooltip': "Custom server information that is displayed on the 'About' page. Can for instance be used to show information about who maintains the tool or what its intended purpose is. Accepts Markdown markup."}, '4cat.crash_message': {'type': 'textarea', 'default': "This processor has crashed; the crash has been logged. 4CAT will try again when it is restarted. Contact your server administrator if this error persists. You can also report issues via 4CAT's [GitHub repository](https://github.com/digitalmethodsinitiative/4cat/issues).", 'help': 'Crash message', 'tooltip': 'This message is shown to users in the interface when a processor crashes while processing their dataset. It can contain Markdown markup.'}, 'privileges.can_create_dataset': {'type': 'toggle', 'default': True, 'help': 'Can create dataset', 'tooltip': "Controls whether users can view and use the 'Create dataset' page. Does NOT control whether users can run processors (which also create datasets); this is a separate setting."}, 'privileges.can_run_processors': {'type': 'toggle', 'default': True, 'help': 'Can run processors', 'tooltip': 'Controls whether processors can be run. There may be processor-specific settings or dependencies that override this.'}, 'privileges.can_view_all_datasets': {'type': 'toggle', 'default': False, 'help': 'Can view global dataset index', 'tooltip': 'Controls whether users can see the global datasets overview, i.e. not just for their own user but for all other users as well.'}, 'privileges.can_view_private_datasets': {'type': 'toggle', 'default': False, 'help': 'Can view private datasets', 'tooltip': 'Controls whether users can see the datasets made private by their owners.'}, 'privileges.can_create_api_token': {'type': 'toggle', 'default': True, 'help': 'Can create API token', 'tooltip': "Controls whether users can create a token for authentication with 4CAT's Web API."}, 'privileges.can_use_explorer': {'type': 'toggle', 'default': True, 'help': 'Can use Explorer', 'tooltip': 'Controls whether users can use the Explorer feature to analyse and annotate datasets.'}, 'privileges.can_export_datasets': {'type': 'toggle', 'default': True, 'help': 'Can export datasets', 'tooltip': 'Allows users to export datasets they own to other 4CAT instances.'}, 'privileges.admin.can_manage_users': {'type': 'toggle', 'default': False, 'help': 'Can manage users', 'tooltip': 'Controls whether users can add, edit and delete other users via the Control Panel'}, 'privileges.admin.can_manage_notifications': {'type': 'toggle', 'default': False, 'help': 'Can manage notifications', 'tooltip': 'Controls whether users can add, edit and delete notifications via the Control Panel'}, 'privileges.admin.can_manage_settings': {'type': 'toggle', 'default': False, 'help': 'Can manage settings', 'tooltip': 'Controls whether users can manipulate 4CAT settings via the Control Panel'}, 'privileges.admin.can_manipulate_all_datasets': {'type': 'toggle', 'default': False, 'help': 'Can manipulate all datasets', 'tooltip': 'Controls whether users can manipulate all datasets as if they were an owner, e.g. sharing it with others, running processors, et cetera.'}, 'privileges.admin.can_restart': {'type': 'toggle', 'default': False, 'help': 'Can restart/upgrade', 'tooltip': 'Controls whether users can restart, upgrade, and manage extensions 4CAT via the Control Panel'}, 'privileges.can_upgrade_to_dev': {'type': 'toggle', 'default': False, 'help': 'Can upgrade to development branch', 'tooltip': "Controls whether users can upgrade 4CAT to a development branch of the code via the Control Panel. This is an easy way to break 4CAT so it is recommended to not enable this unless you're really sure of what you're doing."}, 'privileges.admin.can_manage_tags': {'type': 'toggle', 'default': False, 'help': 'Can manage user tags', 'tooltip': 'Controls whether users can manipulate user tags via the Control Panel'}, 'privileges.admin.can_view_status': {'type': 'toggle', 'default': False, 'help': 'Can view worker status', 'tooltip': 'Controls whether users can view worker status via the Control Panel'}, '4cat.github_url': {'type': 'string', 'default': 'https://github.com/digitalmethodsinitiative/4cat', 'help': 'Repository URL', 'tooltip': 'URL to the github repository for this 4CAT instance', 'global': True}, '4cat.phone_home_url': {'type': 'string', 'default': 'https://ping.4cat.nl', 'help': 'Phone home URL', 'tooltip': 'This URL is called once - when 4CAT is installed. If the installing user consents, information is sent to this URL to help the 4CAT developers (the Digital Methods Initiative) keep track of how much it is used. There should be no need to change this URL after installation.', 'global': True}, '4cat.phone_home_asked': {'type': 'toggle', 'default': True, 'help': 'Shown phone home request?', 'tooltip': "Whether you've seen the 'phone home request'. Set to `False` to see the request again. There should be no need to change this manually.", 'global': True}, '4cat.layout_hue': {'type': 'hue', 'default': 356, 'help': 'Interface accent colour', 'saturation': 87, 'value': 81, 'min': 0, 'max': 360, 'coerce_type': <class 'int'>, 'global': True}, '4cat.layout_hue_secondary': {'type': 'hue', 'default': 86, 'help': 'Interface secondary colour', 'saturation': 87, 'value': 90, 'min': 0, 'max': 360, 'coerce_type': <class 'int'>, 'global': True}, '4cat.allow_access_request': {'type': 'toggle', 'default': True, 'help': 'Allow access requests', 'tooltip': 'When enabled, users can request a 4CAT account via the login page if they do not have one, provided e-mail settings are configured.'}, '4cat.allow_access_request_limiter': {'type': 'string', 'default': '100/day', 'help': 'Access request limit', 'tooltip': "Limit the number of access requests per day. This is a rate limit for the number of requests that can be made per IP address. The format is a number followed by a time unit, e.g. '100/day', '10/hour', '5/minute'. You can also combine these, e.g. '100/day;10/hour'.", 'global': True}, '4cat.sphinx_host': {'type': 'string', 'default': 'localhost', 'help': 'Sphinx host', 'tooltip': 'Sphinx is used for full-text search for collected datasources (e.g., 4chan, 8kun, 8chan) and requires additional setup (see 4CAT wiki on GitHub).', 'global': True}, 'proxies.urls': {'type': 'json', 'default': ['__localhost__'], 'help': 'Proxy URLs', 'tooltip': "A JSON Array of full proxy URLs. Include any proxy login details in the URL itself (e.g. http://username:password@proxy:port). There is one special value, '__localhost__'; this means a direct request, without using a proxy."}, 'proxies.cooloff': {'type': 'string', 'coerce_type': <class 'float'>, 'help': 'Cool-off time', 'tooltip': 'After a request has finished, do not use the proxy again for this many seconds.', 'default': 0.1, 'min': 0.0}, 'proxies.concurrent-overall': {'type': 'string', 'coerce_type': <class 'int'>, 'default': 1, 'min': 1, 'help': 'Max concurrent requests (overall)', 'tooltip': 'Per proxy, this many requests can run concurrently overall.'}, 'proxies.concurrent-host': {'type': 'string', 'coerce_type': <class 'int'>, 'default': 1, 'min': 1, 'help': 'Max concurrent requests (per host)', 'tooltip': 'Per proxy, this many requests can run concurrently per host. Should be lower than or equal to the overall limit.'}, 'logging.slack.level': {'type': 'choice', 'default': 'WARNING', 'options': {'DEBUG': 'Debug', 'INFO': 'Info', 'WARNING': 'Warning', 'ERROR': 'Error', 'CRITICAL': 'Critical'}, 'help': 'Slack alert level', 'tooltip': 'Level of alerts (or higher) to be sent to Slack. Only alerts above this level are sent to the Slack webhook', 'global': True}, 'logging.slack.webhook': {'type': 'string', 'default': '', 'help': 'Slack webhook URL', 'tooltip': 'Slack callback URL to use for alerts', 'global': True}, 'mail.admin_email': {'type': 'string', 'default': '', 'help': 'Admin e-mail', 'tooltip': 'E-mail of admin, to send account requests etc to', 'global': True}, 'mail.server': {'type': 'string', 'default': '', 'help': 'SMTP server', 'tooltip': 'SMTP server to connect to for sending e-mail alerts.', 'global': True}, 'mail.port': {'type': 'string', 'default': 0, 'coerce_type': <class 'int'>, 'help': 'SMTP port', 'tooltip': 'SMTP port to connect to for sending e-mail alerts. "0" defaults to "465" for SMTP_SSL or OS default for SMTP.', 'global': True}, 'mail.ssl': {'type': 'choice', 'default': 'ssl', 'options': {'ssl': 'SSL', 'tls': 'TLS', 'none': 'None'}, 'help': 'SMTP over SSL, TLS, or None', 'tooltip': 'Security scheme to use to connect to e-mail server', 'global': True}, 'mail.username': {'type': 'string', 'default': '', 'help': 'SMTP Username', 'tooltip': 'Only if your SMTP server requires login', 'global': True}, 'mail.password': {'type': 'string', 'default': '', 'help': 'SMTP Password', 'tooltip': 'Only if your SMTP server requires login', 'global': True}, 'mail.noreply': {'type': 'string', 'default': 'noreply@localhost', 'help': 'NoReply e-mail', 'global': True}, 'explorer.basic-explanation': {'type': 'info', 'help': "4CAT's Explorer feature lets you navigate and annotate datasets as if they appared on their original platform. This is intended to facilitate qualitative exploration and manual coding."}, 'explorer.max_posts': {'type': 'string', 'default': 100000, 'help': 'Amount of posts', 'coerce_type': <class 'int'>, 'tooltip': 'Maximum number of posts to be considered by the Explorer (prevents timeouts and memory errors)'}, 'explorer.posts_per_page': {'type': 'string', 'default': 50, 'help': 'Posts per page', 'coerce_type': <class 'int'>, 'tooltip': 'Number of posts to display per page'}, 'explorer.config_explanation': {'type': 'info', 'help': 'Data sources use <em>Explorer templates</em> that determine how they look and what information is displayed. Explorer templates consist of [custom HTML templates](https://github.com/digitalmethodsinitiative/4cat/tree/master/webtool/templates/explorer/datasource-templates) and [custom CSS files](https://github.com/digitalmethodsinitiative/4cat/tree/master/webtool/static/css/explorer). If no template is available for a data source, a <em>generic</em> template is used made of [this HTML file](https://github.com/digitalmethodsinitiative/4cat/blob/master/webtool/templates/explorer/datasource-templates/generic.html) and [this CSS file](https://github.com/digitalmethodsinitiative/4cat/tree/master/webtool/static/css/explorer/generic.css).\n\nYou can request a new data source Explorer template by [creating a GitHub issue](https://github.com/digitalmethodsinitiative/4cat/issues) or adding them yourself and opening a pull request.'}, 'flask.flask_app': {'type': 'string', 'default': 'webtool/fourcat', 'help': 'Flask App Name', 'tooltip': '', 'global': True}, 'flask.server_name': {'type': 'string', 'default': '4cat.local:5000', 'help': 'Host name', 'tooltip': 'e.g., my4CAT.com, localhost, 127.0.0.1. Default is localhost; when running 4CAT in Docker this setting is ignored as any domain/port binding should be handled outside of the Docker container; the Docker container itself will serve on any domain name on the port configured in the .env file.', 'global': True}, 'flask.autologin.hostnames': {'type': 'json', 'default': [], 'help': 'White-listed hostnames', 'tooltip': 'A list of host names or IP addresses to automatically log in. Docker should include localhost and Server Name. Front-end needs to be restarted for changed to apply.', 'global': True}, 'flask.autologin.api': {'type': 'json', 'default': [], 'help': 'White-list for API', 'tooltip': 'A list of host names or IP addresses to allow access to API endpoints with no rate limiting. Docker should include localhost and Server Name. Front-end needs to be restarted for changed to apply.', 'global': True}, 'flask.https': {'type': 'toggle', 'default': False, 'help': 'Use HTTPS', 'tooltip': "If your server is using 'https', set to True and 4CAT will use HTTPS links.", 'global': True}, 'flask.proxy_override': {'type': 'multi_select', 'default': [], 'options': {'x_for': 'X-Forwarded-For', 'x_proto': 'X-Forwarded-Proto', 'x_host': 'X-Forwarded-Host', 'x_port': 'X-Forwarded-Port', 'x_prefix': 'X-Forwarded-Prefix'}, 'help': 'Use proxy headers for URL', 'tooltip': 'These proxy headers will be taken into account when building URLs. For example, if X-Forwarded-Proto is enabled, the URL scheme (http/https) of the built URL will be based on the scheme defined by this header. Use when running 4CAT behind a reverse proxy. Requires a front-end restart to take effect.'}, 'flask.autologin.name': {'type': 'string', 'default': 'Automatic login', 'help': 'Auto-login name', 'tooltip': 'Username for whitelisted hosts (automatically logged in users see this name for themselves)'}, 'flask.secret_key': {'type': 'string', 'default': 'please change me... please...', 'help': 'Secret key', 'tooltip': 'Secret key for Flask, used for session cookies', 'global': True}, 'flask.max_form_parts': {'type': 'string', 'default': 1000, 'help': 'Max form parts per request', 'coerce_type': <class 'int'>, 'global': True, 'tooltip': 'Affects approximate number of files that can be uploaded at once'}, 'flask.tag_order': {'type': 'json', 'default': ['admin'], 'help': 'Tag priority', 'tooltip': "User tag priority order. This can be manipulated from the 'User tags' panel instead of directly.", 'global': True, 'indirect': True}, 'flask.proxy_secret': {'type': 'string', 'default': '', 'help': 'Proxy secret', 'tooltip': 'Secret value to authenticate proxy headers. If the value of the X-4CAT-Config-Via-Proxy header matches this value, the X-4CAT-Config-Tag header can be used to enable a given configuration tag. Leave empty to disable this functionality.'}, 'api.youtube.name': {'type': 'string', 'default': 'youtube', 'help': 'YouTube API Service', 'tooltip': "YouTube API 'service name', e.g. youtube, googleapis, etc.", 'global': True}, 'api.youtube.version': {'type': 'string', 'default': 'v3', 'help': 'YouTube API Version', 'tooltip': "e.g., ''v3'", 'global': True}, 'api.youtube.key': {'type': 'string', 'default': '', 'help': 'YouTube API Key', 'tooltip': 'The developer key from your API console'}, 'dmi-service-manager.aa_DSM-intro-1': {'type': 'info', 'help': "The [DMI Service Manager](https://github.com/digitalmethodsinitiative/dmi_service_manager) is a support tool used to run some advanced processors. These processors generally require high CPU usage, a lot of RAM, or a dedicated GPU and thus do not fit within 4CAT's arcitecture. It is also possible for multiple 4CAT instances to use the same service manager. Please see [this link](https://github.com/digitalmethodsinitiative/dmi_service_manager?tab=readme-ov-file#installation) for instructions on setting up your own instance of the DMI Service Manager."}, 'dmi-service-manager.ab_server_address': {'type': 'string', 'default': '', 'help': 'DMI Service Manager server/URL', 'tooltip': 'The URL of the DMI Service Manager server, e.g. http://localhost:5000', 'global': True}, 'dmi-service-manager.ac_local_or_remote': {'type': 'choice', 'default': 0, 'help': 'DMI Services Local or Remote', 'tooltip': 'Services have local access to 4CAT files or must be transferred from remote via DMI Service Manager', 'options': {'local': 'Local', 'remote': 'Remote'}, 'global': True}, 'ui.homepage': {'type': 'choice', 'options': {'about': "'About' page", 'create-dataset': "'Create dataset' page", 'datasets': 'Dataset overview'}, 'help': '4CAT home page', 'default': 'about'}, 'ui.inline_preview': {'type': 'toggle', 'help': 'Show inline preview', 'default': False, 'tooltip': "Show main dataset preview directly on dataset pages, instead of behind a 'preview' button"}, 'ui.offer_anonymisation': {'type': 'toggle', 'help': 'Offer anonymisation options', 'default': True, 'tooltip': 'Offer users the option to anonymise their datasets at the time of creation. It is strongly recommended to leave this enabled.'}, 'ui.advertise_install': {'type': 'toggle', 'help': 'Advertise local 4CAT', 'default': True, 'tooltip': 'In the login form, remind users of the possibility to install their own 4CAT server.'}, 'ui.show_datasource': {'type': 'toggle', 'help': 'Show data source', 'default': True, 'tooltip': 'Show data source for each dataset. Can be useful to disable if only one data source is enabled.'}, 'ui.nav_pages': {'type': 'multi_select', 'help': 'Pages in navigation', 'options': {'data-policy': 'Data Policy', 'citing': 'How to cite'}, 'default': [], 'tooltip': 'These pages will be included in the navigation bar at the top of the interface.'}, 'ui.prefer_mapped_preview': {'type': 'toggle', 'help': 'Prefer mapped preview', 'default': True, 'tooltip': 'If a dataset is a JSON file but it can be mapped to a CSV file, show the CSV in the preview insteadof the underlying JSON.'}, 'ui.offer_hashing': {'type': 'toggle', 'default': True, 'help': 'Offer pseudonymisation', 'tooltip': "Add a checkbox to the 'create dataset' forum to allow users to toggle pseudonymisation."}, 'ui.offer_private': {'type': 'toggle', 'default': True, 'help': 'Offer create as private', 'tooltip': "Add a checkbox to the 'create dataset' forum to allow users to make a dataset private."}, 'ui.option_email': {'type': 'choice', 'options': {'none': 'No Emails', 'processor_only': 'Processors only', 'datasources_only': 'Create Dataset only', 'both': 'Both datasets and processors'}, 'default': 'none', 'help': 'Show email when complete option', 'tooltip': 'If a mail server is set up, enabling this allow users to request emails when datasets and processors are completed.'}}
def with_db(self, db=None):
46    def with_db(self, db=None):
47        """
48        Initialise database
49
50        Not done on init, because something may need core settings before the
51        database can be initialised
52
53        :param db:  Database object. If None, initialise it using the core config
54        """
55        if db or not self.db:
56            # Replace w/ db if provided else only initialise if not already
57            self.db = db if db else Database(logger=None, dbname=self.get("DB_NAME"), user=self.get("DB_USER"),
58                                         password=self.get("DB_PASSWORD"), host=self.get("DB_HOST"),
59                                         port=self.get("DB_PORT"), appname="config-reader")
60        else:
61            # self.db already initialized and no db provided
62            pass

Initialise database

Not done on init, because something may need core settings before the database can be initialised

Parameters
  • db: Database object. If None, initialise it using the core config
def load_user_settings(self):
 64    def load_user_settings(self):
 65        """
 66        Load settings configurable by the user
 67
 68        Does not load the settings themselves, but rather the definition so
 69        values can be validated, etc
 70        """
 71        # basic 4CAT settings
 72        self.config_definition.update(config_definition)
 73
 74        # module settings can't be loaded directly because modules need the
 75        # config manager to load, so that becomes circular
 76        # instead, this is cached on startup and then loaded here
 77        module_config_path = self.get("PATH_ROOT").joinpath("config/module_config.bin")
 78        if module_config_path.exists():
 79            try:
 80                with module_config_path.open("rb") as infile:
 81                    retries = 0
 82                    module_config = None
 83                    # if 4CAT is being run in two different containers
 84                    # (front-end and back-end) they might both be running this
 85                    # bit of code at the same time. If the file is half-written
 86                    # loading it will fail, so allow for a few retries
 87                    while retries < 3:
 88                        try:
 89                            module_config = pickle.load(infile)
 90                            break
 91                        except Exception:  # this can be a number of exceptions, all with the same recovery path
 92                            time.sleep(0.1)
 93                            retries += 1
 94                            continue
 95
 96                    if module_config is None:
 97                        # not really a way to gracefully recover from this, but
 98                        # we can at least describe the error
 99                        raise RuntimeError("Could not read module_config.bin. The 4CAT developers did a bad job of "
100                                           "preventing this. Shame on them!")
101
102                    self.config_definition.update(module_config)
103            except (ValueError, TypeError):
104                pass

Load settings configurable by the user

Does not load the settings themselves, but rather the definition so values can be validated, etc

def load_core_settings(self):
106    def load_core_settings(self):
107        """
108        Load 4CAT core settings
109
110        These are (mostly) stored in config.ini and cannot be changed from the
111        web interface.
112
113        :return:
114        """
115        config_file = Path(__file__).parent.parent.joinpath("config/config.ini")
116
117        config_reader = configparser.ConfigParser()
118        in_docker = False
119        if config_file.exists():
120            config_reader.read(config_file)
121            if config_reader["DOCKER"].getboolean("use_docker_config"):
122                # Can use throughtout 4CAT to know if Docker environment
123                in_docker = True
124        else:
125            # config should be created!
126            raise ConfigException("No config/config.ini file exists! Update and rename the config.ini-example file.")
127
128        self.core_settings.update({
129            "CONFIG_FILE": config_file.resolve(),
130            "USING_DOCKER": in_docker,
131            "DB_HOST": config_reader["DATABASE"].get("db_host"),
132            "DB_PORT": config_reader["DATABASE"].get("db_port"),
133            "DB_USER": config_reader["DATABASE"].get("db_user"),
134            "DB_NAME": config_reader["DATABASE"].get("db_name"),
135            "DB_PASSWORD": config_reader["DATABASE"].get("db_password"),
136
137            "API_HOST": config_reader["API"].get("api_host"),
138            "API_PORT": config_reader["API"].getint("api_port"),
139
140            "MEMCACHE_SERVER": config_reader.get("MEMCACHE", option="memcache_host", fallback={}),
141
142            "PATH_ROOT": Path(os.path.abspath(os.path.dirname(__file__))).joinpath(
143                "..").resolve(),  # better don"t change this
144            "PATH_LOGS": Path(config_reader["PATHS"].get("path_logs", "")),
145            "PATH_IMAGES": Path(config_reader["PATHS"].get("path_images", "")),
146            "PATH_DATA": Path(config_reader["PATHS"].get("path_data", "")),
147            "PATH_LOCKFILE": Path(config_reader["PATHS"].get("path_lockfile", "")),
148            "PATH_SESSIONS": Path(config_reader["PATHS"].get("path_sessions", "")),
149
150            "ANONYMISATION_SALT": config_reader["GENERATE"].get("anonymisation_salt"),
151            "SECRET_KEY": config_reader["GENERATE"].get("secret_key")
152        })

Load 4CAT core settings

These are (mostly) stored in config.ini and cannot be changed from the web interface.

Returns
def load_memcache(self):
155    def load_memcache(self):
156        """
157        Initialise memcache client
158
159        The config reader can optionally use Memcache to keep fetched values in
160        memory.
161        """
162        if self.get("MEMCACHE_SERVER"):
163            try:
164                # do one test fetch to test if connection is valid
165                memcache = MemcacheClient(self.get("MEMCACHE_SERVER"), serde=serde.pickle_serde, key_prefix=b"4cat-config")
166                memcache.set("4cat-init-dummy", time.time())
167                memcache.init_thread_id = threading.get_ident()
168                return memcache
169            except (SystemError, ValueError, MemcacheError, ConnectionError, OSError):
170                # we have no access to the logger here so we simply pass
171                # later we can detect elsewhere that a memcache address is
172                # configured but no connection is there - then we can log
173                # config reader still works without memcache
174                pass
175
176        return None

Initialise memcache client

The config reader can optionally use Memcache to keep fetched values in memory.

def ensure_database(self):
178    def ensure_database(self):
179        """
180        Ensure the database is in sync with the config definition
181
182        Deletes all stored settings not defined in 4CAT, and creates a global
183        setting for all settings not yet in the database.
184        """
185        self.with_db()
186
187        # create global values for known keys with the default
188        known_settings = self.get_all_setting_names()
189        for setting, parameters in self.config_definition.items():
190            if setting in known_settings:
191                continue
192
193            self.db.log.debug(f"Creating setting: {setting} with default value {parameters.get('default', '')}")
194            self.set(setting, parameters.get("default", ""))
195
196        # make sure settings and user table are in sync
197        user_tags = list(set(itertools.chain(*[u["tags"] for u in self.db.fetchall("SELECT DISTINCT tags FROM users")])))
198        known_tags = [t["tag"] for t in self.db.fetchall("SELECT DISTINCT tag FROM settings")]
199        tag_order = self.get("flask.tag_order")
200
201        for tag in known_tags:
202            # add tags used by a setting to tag order
203            if tag and tag not in tag_order:
204                tag_order.append(tag)
205
206        for tag in user_tags:
207            # add tags used by a user to tag order
208            if tag and tag not in tag_order:
209                tag_order.append(tag)
210
211        # admin tag should always be first in order
212        if "admin" in tag_order:
213            tag_order.remove("admin")
214
215        tag_order.insert(0, "admin")
216
217        self.set("flask.tag_order", tag_order)
218        self.db.commit()

Ensure the database is in sync with the config definition

Deletes all stored settings not defined in 4CAT, and creates a global setting for all settings not yet in the database.

def get_all_setting_names(self, with_core=True):
220    def get_all_setting_names(self, with_core=True):
221        """
222        Get names of all settings
223
224        For when the value doesn't matter!
225
226        :param bool with_core:  Also include core (i.e. config.ini) settings
227        :return list:  List of setting names known by the database and core settings
228        """
229        # attempt to initialise the database connection so we can include
230        # user settings
231        if not self.db:
232            self.with_db()
233
234        settings = list(self.core_settings.keys()) if with_core else []
235        settings.extend([s["name"] for s in self.db.fetchall("SELECT DISTINCT name FROM settings")])
236
237        return settings

Get names of all settings

For when the value doesn't matter!

Parameters
  • bool with_core: Also include core (i.e. config.ini) settings
Returns

List of setting names known by the database and core settings

def get_all( self, is_json=False, user=None, tags=None, with_core=True, memcache=None):
239    def get_all(self, is_json=False, user=None, tags=None, with_core=True, memcache=None):
240        """
241        Get all known settings
242
243        This is *not optimised* but used rarely enough that that doesn't
244        matter so much.
245
246        :param bool is_json:  if True, the value is returned as stored and not
247        interpreted as JSON if it comes from the database
248        :param user:  User object or name. Adds a tag `user:[username]` in
249        front of the tag list.
250        :param tags:  Tag or tags for the required setting. If a tag is
251        provided, the method checks if a special value for the setting exists
252        with the given tag, and returns that if one exists. First matching tag
253        wins.
254        :param bool with_core:  Also include core (i.e. config.ini) settings
255        :param MemcacheClient memcache:  Memcache client. If `None` and
256        `self.memcache` exists, use that instead.
257
258        :return dict: Setting value, as a dictionary with setting names as keys
259        and setting values as values.
260        """
261        for setting in self.get_all_setting_names(with_core=with_core):
262            yield setting, self.get(setting, None, is_json, user, tags, memcache)

Get all known settings

This is not optimised but used rarely enough that that doesn't matter so much.

Parameters
  • bool is_json: if True, the value is returned as stored and not interpreted as JSON if it comes from the database
  • **user: User object or name. Adds a tag user**: [username] in front of the tag list.
  • tags: Tag or tags for the required setting. If a tag is provided, the method checks if a special value for the setting exists with the given tag, and returns that if one exists. First matching tag wins.
  • bool with_core: Also include core (i.e. config.ini) settings
  • MemcacheClient memcache: Memcache client. If None and self.memcache exists, use that instead.
Returns

Setting value, as a dictionary with setting names as keys and setting values as values.

def get( self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None):
265    def get(self, attribute_name, default=None, is_json=False, user=None, tags=None, memcache=None):
266        """
267        Get a setting's value from the database
268
269        If the setting does not exist, the provided fallback value is returned.
270
271        :param str attribute_name:  Setting to return.
272        :param default:  Value to return if setting does not exist
273        :param bool is_json:  if True, the value is returned as stored and not
274        interpreted as JSON if it comes from the database
275        :param user:  User object or name. Adds a tag `user:[username]` in
276        front of the tag list.
277        :param tags:  Tag or tags for the required setting. If a tag is
278        provided, the method checks if a special value for the setting exists
279        with the given tag, and returns that if one exists. First matching tag
280        wins.
281        :param MemcacheClient memcache:  Memcache client. If `None` and
282        `self.memcache` exists, use that instead.
283
284        :return:  Setting value, or the provided fallback, or `None`.
285        """
286        # core settings are not from the database
287        # they are therefore also not memcached - too little gain
288        if type(attribute_name) is not str:
289            raise TypeError(f"attribute_name must be a str, {attribute_name.__class__.__name__} given")
290
291        if attribute_name in self.core_settings:
292            # we never get to the database or memcache part of this method if
293            # this is a core setting we already know
294            return self.core_settings[attribute_name]
295
296        # if trying to access a setting that's not a core setting, attempt to
297        # initialise the database connection
298        if not self.db:
299            self.with_db()
300
301        # get tags to look for
302        # copy() because else we keep adding onto the same list, which
303        # interacts badly with get_all()
304        if tags:
305            tags = tags.copy()
306        tags = self.get_active_tags(user, tags, memcache)
307
308        # now we have all tags - get the config values for each (if available)
309        # and then return the first matching one. Add the 'empty' tag at the
310        # end to fall back to the global value if no specific one exists.
311        tags.append("")
312
313        # short-circuit via memcache if appropriate
314        if not memcache and self.memcache:
315            memcache = self.memcache
316
317        # first check if we have all the values in memcache, in which case we
318        # do not need a database query
319        if memcache:
320            if threading.get_ident() != memcache.init_thread_id:
321                raise RuntimeError("Thread-unsafe use of memcache! Please make sure you are using a configuration "
322                                   "wrapper to read with a thread-local memcache connection.")
323
324            cached_values = {tag: memcache.get(self._get_memcache_id(attribute_name, tag), default=CacheMiss) for tag in tags}
325
326        else:
327            cached_values = {t: CacheMiss for t in tags}
328
329        # for the tags we could not get from memcache, run a database query
330        # (and save to cache if possible)
331        missing_tags = [t for t in cached_values if cached_values[t] is CacheMiss]
332        if missing_tags:
333            # query database for any values within the required tags
334            query = "SELECT * FROM settings WHERE name = %s AND tag IN %s"
335            replacements = (attribute_name, tuple(missing_tags))
336            queried_settings = {setting["tag"]: setting["value"] for setting in self.db.fetchall(query, replacements)}
337
338            if memcache:
339                for tag, value in queried_settings.items():
340                    memcache.set(self._get_memcache_id(attribute_name, tag), value)
341
342            cached_values.update(queried_settings)
343
344        # there may be some tags for which we still do not have a value at
345        # this point. these simply do not have a tag-specific value but that in
346        # itself is worth caching, otherwise we're going to query for a
347        # non-existent value each time.
348        # so: cache a magic value for such setting/tag combinations, and
349        # replace the magic value with a CacheMiss in the dict that will be
350        # parsed
351        unconfigured_magic = "__unconfigured__"
352        if memcache:
353            for tag in [t for t in cached_values if cached_values[t] is CacheMiss]:
354                # should this be more magic?
355                memcache.set(self._get_memcache_id(attribute_name, tag), unconfigured_magic)
356
357            for tag in [t for t in cached_values if cached_values[t] == unconfigured_magic]:
358                cached_values[tag] = CacheMiss
359
360        # now we may still have some CacheMisses in the values dict, if there
361        # was no setting in the database with that tag. So, find the first
362        # value that is not a CacheMiss. If nothing matches, try the global tag
363        # and if even that does not match (no setting saved at all) return the
364        # default
365        for tag in tags:
366            if tag in cached_values and cached_values.get(tag) is not CacheMiss:
367                value = cached_values[tag]
368                break
369        else:
370            value = None
371
372        # parse some values...
373        if not is_json and value is not None:
374            value = json.loads(value)
375        # TODO: Which default should have priority? The provided default feels like it should be the highest priority, but I think that is an old implementation and perhaps should be removed. - Dale
376        elif value is None and attribute_name in self.config_definition and "default" in self.config_definition[attribute_name]:
377            value = self.config_definition[attribute_name]["default"]
378        elif value is None and default is not None:
379            value = default
380
381        return value

Get a setting's value from the database

If the setting does not exist, the provided fallback value is returned.

Parameters
  • str attribute_name: Setting to return.
  • default: Value to return if setting does not exist
  • bool is_json: if True, the value is returned as stored and not interpreted as JSON if it comes from the database
  • **user: User object or name. Adds a tag user**: [username] in front of the tag list.
  • tags: Tag or tags for the required setting. If a tag is provided, the method checks if a special value for the setting exists with the given tag, and returns that if one exists. First matching tag wins.
  • MemcacheClient memcache: Memcache client. If None and self.memcache exists, use that instead.
Returns

Setting value, or the provided fallback, or None.

def get_active_tags(self, user=None, tags=None, memcache=None):
383    def get_active_tags(self, user=None, tags=None, memcache=None):
384        """
385        Get active tags for given user/tag list
386
387        Used internally to harmonize tag setting for various methods, but can
388        also be called directly to verify tag activation.
389
390        :param user:  User object or name. Adds a tag `user:[username]` in
391        front of the tag list.
392        :param tags:  Tag or tags for the required setting. If a tag is
393        provided, the method checks if a special value for the setting exists
394        with the given tag, and returns that if one exists. First matching tag
395        wins.
396        :param MemcacheClient memcache:  Memcache client. If `None` and
397        `self.memcache` exists, use that instead.
398        :return list:  List of tags
399        """
400        # be flexible about the input types here
401        if tags is None:
402            tags = []
403        elif type(tags) is str:
404            tags = [tags]
405
406        user = self._normalise_user(user)
407
408        # user-specific settings are just a special type of tag (which takes
409        # precedence), same goes for user groups. so if a user was passed, get
410        # that user's tags (including the 'special' user: tag) and add them
411        # to the list
412        if user:
413            user_tags = CacheMiss
414            
415            if not memcache and self.memcache:
416                memcache = self.memcache
417                
418            if memcache:
419                memcache_id = f"_usertags-{user}"
420                user_tags = memcache.get(memcache_id, default=CacheMiss)
421
422            if user_tags is CacheMiss:
423                user_tags = self.db.fetchone("SELECT tags FROM users WHERE name = %s", (user,))
424                if user_tags and memcache:
425                    memcache.set(memcache_id, user_tags)
426
427            if user_tags:
428                try:
429                    tags.extend(user_tags["tags"])
430                except (TypeError, ValueError):
431                    # should be a JSON list, but isn't
432                    pass
433
434            tags.insert(0, f"user:{user}")
435
436        return tags

Get active tags for given user/tag list

Used internally to harmonize tag setting for various methods, but can also be called directly to verify tag activation.

Parameters
  • **user: User object or name. Adds a tag user**: [username] in front of the tag list.
  • tags: Tag or tags for the required setting. If a tag is provided, the method checks if a special value for the setting exists with the given tag, and returns that if one exists. First matching tag wins.
  • MemcacheClient memcache: Memcache client. If None and self.memcache exists, use that instead.
Returns

List of tags

def set( self, attribute_name, value, is_json=False, tag='', overwrite_existing=True, memcache=None):
438    def set(self, attribute_name, value, is_json=False, tag="", overwrite_existing=True, memcache=None):
439        """
440        Insert OR set value for a setting
441
442        If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the
443        setting exists the setting is not updated.
444
445        :param str attribute_name:  Attribute to set
446        :param value:  Value to set (will be serialised as JSON)
447        :param bool is_json:  True for a value that is already a serialised JSON string; False if value is object that needs to
448                          be serialised into a JSON string
449        :param bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists
450        :param str tag:  Tag to write setting for
451        :param MemcacheClient memcache:  Memcache client. If `None` and
452        `self.memcache` exists, use that instead.
453
454        :return int: number of updated rows
455        """
456        # Check value is valid JSON
457        if is_json:
458            try:
459                json.dumps(json.loads(value))
460            except json.JSONDecodeError:
461                return None
462        else:
463            try:
464                value = json.dumps(value)
465            except json.JSONDecodeError:
466                return None
467
468        if attribute_name in self.config_definition and self.config_definition.get(attribute_name).get("global"):
469            tag = ""
470
471        if overwrite_existing:
472            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT (name, tag) DO UPDATE SET value = EXCLUDED.value"
473        else:
474            query = "INSERT INTO settings (name, value, tag) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING"
475
476        self.db.execute(query, (attribute_name, value, tag))
477        updated_rows = self.db.cursor.rowcount
478        self.db.log.debug(f"Updated setting for {attribute_name}: {value} (tag: {tag})")
479
480        if not memcache and self.memcache:
481            memcache = self.memcache
482
483        if memcache:
484            # invalidate any cached value for this setting
485            memcache_id = self._get_memcache_id(attribute_name, tag)
486            memcache.delete(memcache_id)
487
488        return updated_rows

Insert OR set value for a setting

If overwrite_existing=True and the setting exists, the setting is updated; if overwrite_existing=False and the setting exists the setting is not updated.

Parameters
  • str attribute_name: Attribute to set
  • value: Value to set (will be serialised as JSON)
  • bool is_json: True for a value that is already a serialised JSON string; False if value is object that needs to be serialised into a JSON string
  • bool overwrite_existing: True will overwrite existing setting, False will do nothing if setting exists
  • str tag: Tag to write setting for
  • MemcacheClient memcache: Memcache client. If None and self.memcache exists, use that instead.
Returns

number of updated rows

def delete_for_tag(self, attribute_name, tag):
490    def delete_for_tag(self, attribute_name, tag):
491        """
492        Delete config override for a given tag
493
494        :param str attribute_name:
495        :param str tag:
496        :return int: number of deleted rows
497        """
498        self.db.delete("settings", where={"name": attribute_name, "tag": tag})
499        updated_rows = self.db.cursor.rowcount
500
501        if self.memcache:
502            self.memcache.delete(self._get_memcache_id(attribute_name, tag))
503
504        return updated_rows

Delete config override for a given tag

Parameters
  • str attribute_name:
  • str tag:
Returns

number of deleted rows

def clear_cache(self):
506    def clear_cache(self):
507        """
508        Clear cached configuration values
509
510        Called when the backend restarts - helps start with a blank slate.
511        """
512        if not self.memcache:
513            return
514
515        self.memcache.flush_all()

Clear cached configuration values

Called when the backend restarts - helps start with a blank slate.

def uncache_user_tags(self, users):
517    def uncache_user_tags(self, users):
518        """
519        Clear cached user tags
520
521        User tags are cached with memcache if possible to avoid unnecessary
522        database roundtrips. This method clears the cached user tags, in case
523        a tag is added/deleted from a user.
524
525        :param list users:  List of users, as usernames or User objects
526        """
527        if self.memcache:
528            for user in users:
529                user = self._normalise_user(user)
530                self.memcache.delete(f"_usertags-{user}")

Clear cached user tags

User tags are cached with memcache if possible to avoid unnecessary database roundtrips. This method clears the cached user tags, in case a tag is added/deleted from a user.

Parameters
  • list users: List of users, as usernames or User objects
class ConfigWrapper:
601class ConfigWrapper:
602    """
603    Wrapper for the config manager
604
605    Allows setting a default set of tags or user, so that all subsequent calls
606    to `get()` are done for those tags or that user. Can also adjust tags based
607    on the HTTP request, if used in a Flask context.
608    """
609    def __init__(self, config, user=None, tags=None, request=None):
610        """
611        Initialise config wrapper
612
613        :param ConfigManager config:  Initialised config manager
614        :param user:  User to get settings for
615        :param tags:  Tags to get settings for
616        :param request:  Request to get headers from. This can be used to set
617        a particular tag based on the HTTP headers of the request, e.g. to
618        serve 4CAT with a different configuration based on the proxy server
619        used.
620        """
621        if type(config) is ConfigWrapper:
622            # let's not do nested wrappers, but copy properties unless
623            # provided explicitly
624            self.user = user if user else config.user
625            self.tags = tags if tags else config.tags
626            self.request = request if request else config.request
627            self.config = config.config
628            self.memcache = config.memcache
629        else:
630            self.config = config
631            self.user = user
632            self.tags = tags
633            self.request = request
634
635            # this ensures we use our own memcache client, important in threaded
636            # contexts because pymemcache is not thread-safe. Unless we get a
637            # prepared connection...
638            self.memcache = self.config.load_memcache()
639
640        # this ensures the user object in turn reads from the wrapper
641        if self.user:
642            self.user.with_config(self, rewrap=False)
643
644
645    def set(self, *args, **kwargs):
646        """
647        Wrap `set()`
648
649        :param args:
650        :param kwargs:
651        :return:
652        """
653        if "tag" not in kwargs and self.tags:
654            kwargs["tag"] = self.tags
655
656        kwargs["memcache"] = self.memcache
657
658        return self.config.set(*args, **kwargs)
659
660    def get_all(self, *args, **kwargs):
661        """
662        Wrap `get_all()`
663
664        Takes the `user`, `tags` and `request` given when initialised into
665        account. If `tags` is set explicitly, the HTTP header-based override
666        is not applied.
667
668        :param args:
669        :param kwargs:
670        :return:
671        """
672        if "user" not in kwargs and self.user:
673            kwargs["user"] = self.user
674
675        if "tags" not in kwargs:
676            kwargs["tags"] = self.tags if self.tags else []
677            kwargs["tags"] = self.request_override(kwargs["tags"])
678
679        kwargs["memcache"] = self.memcache
680
681        return self.config.get_all(*args, **kwargs)
682
683    def get(self, *args, **kwargs):
684        """
685        Wrap `get()`
686
687        Takes the `user`, `tags` and `request` given when initialised into
688        account. If `tags` is set explicitly, the HTTP header-based override
689        is not applied.
690
691        :param args:
692        :param kwargs:
693        :return:
694        """
695        if "user" not in kwargs:
696            kwargs["user"] = self.user
697
698        if "tags" not in kwargs:
699            kwargs["tags"] = self.tags if self.tags else []
700            kwargs["tags"] = self.request_override(kwargs["tags"])
701
702        kwargs["memcache"] = self.memcache
703
704        return self.config.get(*args, **kwargs)
705
706    def get_active_tags(self, user=None, tags=None):
707        """
708        Wrap `get_active_tags()`
709
710        Takes the `user`, `tags` and `request` given when initialised into
711        account. If `tags` is set explicitly, the HTTP header-based override
712        is not applied.
713
714        :param user:
715        :param tags:
716        :return list:
717        """
718        active_tags = self.config.get_active_tags(user, tags, self.memcache)
719        if not tags:
720            active_tags = self.request_override(active_tags)
721
722        return active_tags
723
724    def request_override(self, tags):
725        """
726        Force tag via HTTP request headers
727
728        To facilitate loading different configurations based on the HTTP
729        request, the request object can be passed to the ConfigWrapper and
730        if a certain request header is set, the value of that header will be
731        added to the list of tags to consider when retrieving settings.
732
733        See the flask.proxy_secret config setting; this is used to prevent
734        users from changing configuration by forging the header.
735
736        :param list|str tags:  List of tags to extend based on request
737        :return list:  Amended list of tags
738        """
739        if type(tags) is str:
740            tags = [tags]
741
742        # use self.config.get here, not self.get, because else we get infinite
743        # recursion (since self.get can call this method)
744        if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \
745            self.config.get("flask.proxy_secret", memcache=self.memcache) and \
746            self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache):
747            # need to ensure not just anyone can add this header to their
748            # request!
749            # to this end, the second header must be set to the secret value;
750            # if it is not set, assume the headers are not being configured by
751            # the proxy server
752            if not tags:
753                tags = []
754
755            # can never set admin tag via headers (should always be user-based)
756            forbidden_overrides = ("admin",)
757            tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides]
758
759        return tags
760
761
762    def __getattr__(self, item):
763        """
764        Generic wrapper
765
766        Just pipe everything through to the config object
767
768        :param item:
769        :return:
770        """
771        if hasattr(self.config, item):
772            return getattr(self.config, item)
773        elif hasattr(self, item):
774            return getattr(self, item)
775        else:
776            raise AttributeError(f"'{self.__name__}' object has no attribute '{item}'")

Wrapper for the config manager

Allows setting a default set of tags or user, so that all subsequent calls to get() are done for those tags or that user. Can also adjust tags based on the HTTP request, if used in a Flask context.

ConfigWrapper(config, user=None, tags=None, request=None)
609    def __init__(self, config, user=None, tags=None, request=None):
610        """
611        Initialise config wrapper
612
613        :param ConfigManager config:  Initialised config manager
614        :param user:  User to get settings for
615        :param tags:  Tags to get settings for
616        :param request:  Request to get headers from. This can be used to set
617        a particular tag based on the HTTP headers of the request, e.g. to
618        serve 4CAT with a different configuration based on the proxy server
619        used.
620        """
621        if type(config) is ConfigWrapper:
622            # let's not do nested wrappers, but copy properties unless
623            # provided explicitly
624            self.user = user if user else config.user
625            self.tags = tags if tags else config.tags
626            self.request = request if request else config.request
627            self.config = config.config
628            self.memcache = config.memcache
629        else:
630            self.config = config
631            self.user = user
632            self.tags = tags
633            self.request = request
634
635            # this ensures we use our own memcache client, important in threaded
636            # contexts because pymemcache is not thread-safe. Unless we get a
637            # prepared connection...
638            self.memcache = self.config.load_memcache()
639
640        # this ensures the user object in turn reads from the wrapper
641        if self.user:
642            self.user.with_config(self, rewrap=False)

Initialise config wrapper

Parameters
  • ConfigManager config: Initialised config manager
  • user: User to get settings for
  • tags: Tags to get settings for
  • request: Request to get headers from. This can be used to set a particular tag based on the HTTP headers of the request, e.g. to serve 4CAT with a different configuration based on the proxy server used.
def set(self, *args, **kwargs):
645    def set(self, *args, **kwargs):
646        """
647        Wrap `set()`
648
649        :param args:
650        :param kwargs:
651        :return:
652        """
653        if "tag" not in kwargs and self.tags:
654            kwargs["tag"] = self.tags
655
656        kwargs["memcache"] = self.memcache
657
658        return self.config.set(*args, **kwargs)

Wrap set()

Parameters
  • args:
  • kwargs:
Returns
def get_all(self, *args, **kwargs):
660    def get_all(self, *args, **kwargs):
661        """
662        Wrap `get_all()`
663
664        Takes the `user`, `tags` and `request` given when initialised into
665        account. If `tags` is set explicitly, the HTTP header-based override
666        is not applied.
667
668        :param args:
669        :param kwargs:
670        :return:
671        """
672        if "user" not in kwargs and self.user:
673            kwargs["user"] = self.user
674
675        if "tags" not in kwargs:
676            kwargs["tags"] = self.tags if self.tags else []
677            kwargs["tags"] = self.request_override(kwargs["tags"])
678
679        kwargs["memcache"] = self.memcache
680
681        return self.config.get_all(*args, **kwargs)

Wrap get_all()

Takes the user, tags and request given when initialised into account. If tags is set explicitly, the HTTP header-based override is not applied.

Parameters
  • args:
  • kwargs:
Returns
def get(self, *args, **kwargs):
683    def get(self, *args, **kwargs):
684        """
685        Wrap `get()`
686
687        Takes the `user`, `tags` and `request` given when initialised into
688        account. If `tags` is set explicitly, the HTTP header-based override
689        is not applied.
690
691        :param args:
692        :param kwargs:
693        :return:
694        """
695        if "user" not in kwargs:
696            kwargs["user"] = self.user
697
698        if "tags" not in kwargs:
699            kwargs["tags"] = self.tags if self.tags else []
700            kwargs["tags"] = self.request_override(kwargs["tags"])
701
702        kwargs["memcache"] = self.memcache
703
704        return self.config.get(*args, **kwargs)

Wrap get()

Takes the user, tags and request given when initialised into account. If tags is set explicitly, the HTTP header-based override is not applied.

Parameters
  • args:
  • kwargs:
Returns
def get_active_tags(self, user=None, tags=None):
706    def get_active_tags(self, user=None, tags=None):
707        """
708        Wrap `get_active_tags()`
709
710        Takes the `user`, `tags` and `request` given when initialised into
711        account. If `tags` is set explicitly, the HTTP header-based override
712        is not applied.
713
714        :param user:
715        :param tags:
716        :return list:
717        """
718        active_tags = self.config.get_active_tags(user, tags, self.memcache)
719        if not tags:
720            active_tags = self.request_override(active_tags)
721
722        return active_tags

Wrap get_active_tags()

Takes the user, tags and request given when initialised into account. If tags is set explicitly, the HTTP header-based override is not applied.

Parameters
  • user:
  • tags:
Returns
def request_override(self, tags):
724    def request_override(self, tags):
725        """
726        Force tag via HTTP request headers
727
728        To facilitate loading different configurations based on the HTTP
729        request, the request object can be passed to the ConfigWrapper and
730        if a certain request header is set, the value of that header will be
731        added to the list of tags to consider when retrieving settings.
732
733        See the flask.proxy_secret config setting; this is used to prevent
734        users from changing configuration by forging the header.
735
736        :param list|str tags:  List of tags to extend based on request
737        :return list:  Amended list of tags
738        """
739        if type(tags) is str:
740            tags = [tags]
741
742        # use self.config.get here, not self.get, because else we get infinite
743        # recursion (since self.get can call this method)
744        if self.request and self.request.headers.get("X-4Cat-Config-Tag") and \
745            self.config.get("flask.proxy_secret", memcache=self.memcache) and \
746            self.request.headers.get("X-4Cat-Config-Via-Proxy") == self.config.get("flask.proxy_secret", memcache=self.memcache):
747            # need to ensure not just anyone can add this header to their
748            # request!
749            # to this end, the second header must be set to the secret value;
750            # if it is not set, assume the headers are not being configured by
751            # the proxy server
752            if not tags:
753                tags = []
754
755            # can never set admin tag via headers (should always be user-based)
756            forbidden_overrides = ("admin",)
757            tags += [tag for tag in self.request.headers.get("X-4Cat-Config-Tag").split(",") if tag not in forbidden_overrides]
758
759        return tags

Force tag via HTTP request headers

To facilitate loading different configurations based on the HTTP request, the request object can be passed to the ConfigWrapper and if a certain request header is set, the value of that header will be added to the list of tags to consider when retrieving settings.

See the flask.proxy_secret config setting; this is used to prevent users from changing configuration by forging the header.

Parameters
  • list|str tags: List of tags to extend based on request
Returns

Amended list of tags

class CoreConfigManager(ConfigManager):
779class CoreConfigManager(ConfigManager):
780    """
781    A configuration reader that can only read from core settings
782
783    Can be used in thread-unsafe context and when no database is present.
784    """
785    def with_db(self, db=None):
786        """
787        Raise a RuntimeError when trying to link a database connection
788
789        :param db:
790        """
791        raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")

A configuration reader that can only read from core settings

Can be used in thread-unsafe context and when no database is present.

def with_db(self, db=None):
785    def with_db(self, db=None):
786        """
787        Raise a RuntimeError when trying to link a database connection
788
789        :param db:
790        """
791        raise RuntimeError("Trying to read non-core configuration value from a CoreConfigManager")

Raise a RuntimeError when trying to link a database connection

Parameters
  • db: