• Dec. 26, 2022, 10 p.m.

    Misago 0.x comes with simple plugin system that lets people familiar with Python and Django to add custom Django apps and URLs to their Misago sites.

    Creating a new plugin

    A plugin is non-standard Django App that Misago loads in addition to standard apps that are part of Misago or 3rd party apps Misago requires to work.

    This guide will not explain how to create custom Django Apps, this is already done better on Django site.

    Before continuing, clone Misago from main repository and setup it locally (using docker-compose setup included).

    Now, create a directory named myplugin in repository's root directory (same directory that contains manage.py file). Inside myplugin directory create empty __init__.py file.

    Now we need to expose this plugin to Misago. To do this create a file named plugins.txt in root directory (directory that contains manage.py file). Inside this file enter following:

    # Enable my plugin for development
    myplugin
    

    Save your file. Restart Misago's containers by running those commands in terminal:

    docker-compose stop
    docker-compose up misago celery
    

    Your plugin will now be loaded by Misago, but it will not do anything (yet!).

    Plugin initialization

    Plugin initialization is done through Django application configs. Read Django guide for Django version used by your Misago site: Misago 0.27, Misago 0.28 and later

    If you plan to use Misago's built-in extension points, your plugin's AppConfig should define custom ready method:

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            ...  # Register new urls, validators, admin panel subpages, user profile cards, etc. ect.
    

    Enabling plugin in Misago

    When Misago starts, it looks for a file named plugins.txt and if its present, it reads it for list of plugins to load. This file should be located in same directory that contains manage.py file. If you are using misago-docker, thats misago directory. If you are only starting development and cloned main repo from github, create this file in top directory.

    Misago expects that every plugin in plugins.txt will be specified on separate line. Duplicates and lines starting with "#" are skipped:

    # Example plugins.txt with 2 plugins
    myplugin
    someotherplugin
    

    By default plugins are looked for in same directory as plugins.txt. If plugin is located in elsewhere, you can specify a path to it using @ symbol:

    # Example plugins.txt that will load `myplugin` python package located in `/app/my-plugin/` path
    myplugin@/app/my-plugin/
    

    Plugin requirements

    If plugin has custom requirements, define them in requirements-plugins.txt file that should exist in same directory that contains requirements.txt file specifying standard requirements.

    Remember to rebuild your docker containers after updating that file:

    # On localhost:
    docker-compose stop
    docker-compose rebuild misago celery
    
    # Using misago-docker
    ./appctl rebuild
    
  • bookmark_border

    Thread has been pinned locally.

  • edit

    Thread title has been changed from Plugin system guide.

  • Dec. 26, 2022, 11 p.m.

    Misago defines number of custom extension points that plugins can use.

    Hooks

    misago.hooks module defines bunch of hooks that plugins can extend. All hooks are python lists. To add new item to a hook, append or extend it to it on your plugin's ready method:

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from django.urls import include, path
    from misago.hooks import urlpatterns
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            # Add extra links to Misago
            urlpatterns.append(path("", include("myplugin.urls")))
    

    apipatterns - custom API urls

    This hook is a list of extra urlpatterns that should be prepended to standard API urls that are included under the api/ path and api: namespace.

    Because those extra patterns are prepended to standard patterns, its possible for plugin to override built-in API endpoint by defining custom one with same url pattern.

    urlpatterns - custom urls

    This hook is a list of extra urlpatterns that should be prepended to standard urlpatterns that are defined in the urls.py.

    Because those extra patterns are prepended to standard patterns, its possible for plugin to override built-in url by defining custom one with same url pattern.

    context_processors - custom context processors

    List of custom context-processor functions that should be called in addition to default ones.

    new_registrations_validators - custom validator functions to run for newly registered accounts

    List of validators to run on data provided to new user registration API. Validator functions are called with three arguments:

    • request
    • cleaned_data
    • add_error

    If value in cleaned_data is invalid, add_error should be called with first argument as field name (or None for whole form) and validation error. Contents of cleaned_data will differ between registration method (Single Sign-On, Social Auth, registration form).

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from django.core.exceptions import ValidationError
    from misago.hooks import new_registrations_validators
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            new_registrations_validators.extend([
                validate_user_ip, validate_user_email,
            ])
    
    
    def validate_user_ip(request, cleaned_data, add_error):
        if request.user_ip == "172.0.0.1":
            add_error(None, ValidationError("Registration is not currently available."))
    
    
    def validate_user_email(request, cleaned_data, add_error):
        if not cleaned_data.get("email"):
            return
    
        if cleaned_data["email"].lower().endswith(".spam"):
            add_error("email", ValidationError("This e-mail is not available."))
    

    Note: this hook is not fired for users created through OAuth2 client. To add custom logic for validating those those see the oauth2_validators hook.

    oauth2_user_data_filters - custom filters functions to run on user data from OAuth2 server

    OAuth2 user data filters are functions called with an user data retrieved from the OAuth2 provider. Those functions purpose is to convert user data from format its stored in provider to a format that is valid for Misago. For example, default user data filter from Misago removes characters outside of latin alphabet from user names and replaces spaces with underscores. It also adds random sequence of characters at the end of username if such username would conflict with other user.

    Those functions are called with three arguments:

    • request
    • user
    • user_data

    Function should return updated user_data or None, in which unmodified user_data is passed to next filter. If user data belongs to an user that already has an account on site, this account will be passed as user argument. Otherwise user will be None. user_data is a typed dict:

    class OAuth2UserData:
        id: str
        name: str
        email: str
        avatar: str | None
    

    id, name and email are guaranteed to be strings. name and email can be empty strings. email can contain any string, not just valid e-mail.

    # myplugin/apps.py file contents
    import random
    
    from django.apps import AppConfig
    from django.core.exceptions import ValidationError
    from misago.hooks import oauth2_user_data_filters
    from misago.oauth2.validation import filter_name
    
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            oauth2_user_data_filters.extend([
                remove_dots_from_gmail_email, filter_user_name,
            ])
    
    
    def remove_dots_from_gmail_email(request, user, user_data):
        email = user_data["email"]
        if not "@" in email:
            return  # No guarantees that email value is e-mail address
    
        if not email.lower().endswith("@gmail.com"):
            return
    
        identifier, _ = email.split("@", 1)
        identifier = identifier.replace(".", ")
    
        user_data["email"] = f"{identifier}@gmail.com"
        return user_data
    
    
    def filter_user_name(request, user, user_data):
        # Misago's default name filter is not used when custom OAuth2 filters are set!
        user_data["name"] = filter_name(user, user_data["name"])
    
        # Completely invalid usernames are replaced with User_RANDOM_STRING by the filter 
        if user_data["name"].startswith("User_"):
            # Replace placeholder name with something like "Solid_Snake"
            user_data["name"] = "%s_%s" % (
                random.choice(["Swift", "Sneaky", "Silent", "Solid", "Alert"]),
                random.choice(["Ocelot", "Snake", "Bobcat", "Bear", "Kitten"]),
            )
    
        return user_data
    

    oauth2_validators - custom validators for OAuth2 users

    OAuth2 validators are functions called with an user data retrieved from the OAuth2 provider. Those functions purpose is to validate both user data and original user payload from the server and either allow or disallow them from signing on the site through OAuth server.

    Those functions are called with four arguments:

    • request
    • user: None or pre-existing user model instance from Misago's database if this is another authentication for this user.
    • user_data: Filtered user data, same as in oauth2_user_data_filters.
    • original_json: Original JSON data with coplete payload returned by user retrieval API.

    Function should return nothing or raise the django.core.exceptions.ValidationError to prevent user from signing in.

    Example plugin that prevents new users from signing on the site if they don't have "forum" group:

    # myplugin/apps.py file contents
    import random
    
    from django.apps import AppConfig
    from django.core.exceptions import ValidationError
    from misago.hooks import oauth2_validators
    from misago.oauth2.validation import filter_name
    
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            oauth2_validators.extend([
                validate_oauth2_new_user_group
            ])
    
    
    def validate_oauth2_new_user_group(request, user, user_data, original_json):
        if user:
            return  # Pre-existing users can always sign in
    
        user_groups = original_json.get("groups") or []
        if "forum" not in user_groups:
            raise ValidationError("You need permission to access the forum!")
    
    
    ## `post_search_filters` - custom filters to run on search queries
    
    Search filters are functions called with single argument: string with search query. They are supposed to return updated search query string. If nothing or `None` is returned, original search query is passed to either next search filter, or search engine.
    
    ```python
    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from misago.hooks import post_search_filters
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            post_search_filters.append(filter_search)
    
    
    def filter_search(search):
        clean_search = []
        for word in search.split(" "):
            if "auto" in word.lower():
                clean_search.append("car")
            else:
                clean_search.append(word)
    
        return " ".join(clean_search)
    

    post_validators - custom validators to run when new message is posted

    Post validators are functions called with two arguments:

    • context: dict with context in which post was created/updated, it varies
    • data: dict with data sent to API by client

    Validator should raise ValidationError to interrupt posting process or return data otherwise. Data can be mutated by validator. If nothing or None is returned, original data is reused to call next validator or save changes.

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from rest_framework.serializers import ValidationError
    from misago.hooks import post_validators
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            post_validators.append(validate_post)
    
    
    def validate_post(context, data):
        if context["user"].is_staff:
            return data  # skip admins
    
        if "test" in data["post"].lower():
            raise ValidationError({"post": "Don't post testing messages!"})
    
        return data  # Return valid data
    

    markdown_extensions - custom functions that extend markup parser

    Markdown extensions are functions called with single argument, instance of Markdown class used by Misago to parse messages:

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from misago.hooks import markdown_extensions
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            markdown_extensions.append(extend_markdown)
    
    
    def extend_markdown(md):
        ...  # Do something with md instance, eg. register new preprocessor
    

    parsing_result_processors - custom filters for parsing result

    Parsing result is a dictionary containing parsed text of a message posted by user, and additional metadata:

    {
      "original_text": text,
      "parsed_text": "",
      "markdown": md,
      "mentions": [],
      "images": [],
      "internal_links": [],
      "outgoing_links": [],
    }
    

    parsing_result_processors is a list of functions that are called with two arguments (parsing_result and RootNode instance created from parsing result["parsed_text"]):

    # myplugin/apps.py file contents
    
    from django.apps import AppConfig
    from misago.hooks import parsing_result_processors
    
    class MyPluginConfig(AppConfig):
        name = 'myplugin'
        verbose_name = "My plugin"
    
        def ready(self):
            parsing_result_processors.append(proccess_parsing_result)
    
    
    def proccess_parsing_result(result, html_root_node):
        return result  # Do something with parsing result, eg. replace or remove spam urls