commit 3a1b0379b04a754966f8051b675a39bafa129102 Author: Nils Büchner Date: Sat Sep 14 19:12:42 2024 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66dc294 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__ +_trial_temp +*.egg-info +.eggs +venv +dist + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e94a78d --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Example config for homeserver.yaml: + +- module: synapse_invite_checker.InviteChecker + config: + blacklist_whitelist_url: "https://example.com/invites.json" + use_whitelist: true + use_blacklist: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0e2d9ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "synapse-invite-checker" +description = 'Synapse module to handle TIM contact management and invite permissions' +readme = "README.md" +requires-python = ">=3.11" +license = "AGPL-3.0-only" +keywords = [] +authors = [ + { name = "Nicolas Werner", email = "n.werner@famedly.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "twisted", + "cachetools", + "asyncache", +] +version = "0.2.0" + +[project.urls] +Documentation = "https://github.com/famedly/synapse-invite-checker#synapse-invite-checker" +Issues = "https://github.com/famedly/synapse-invite-checker/-/issues" +Source = "https://github.com/famedly/synapse-invite-checker/" + +[tool.hatch.envs.default] +dependencies = [ + "black", + "pytest", + "pytest-cov", + "mock", + "matrix-synapse" # we don't depend on synapse directly to prevent pip from pulling the wrong synapse, when we just want to install the module +] +[tool.hatch.envs.default.scripts] +cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=synapse_invite_checker --cov=tests" +format = "black ." +# For CI use +head-cov = "pytest --cov-report=lcov:../head.lcov --cov-config=pyproject.toml --cov=synapse_invite_checker --cov=tests" +base-cov = "pytest --cov-report=lcov:../base.lcov --cov-config=pyproject.toml --cov=synapse_invite_checker --cov=tests" + +[tool.hatch.envs.ci.scripts] +format = "black --check ." + +[tool.coverage.run] +branch = true +parallel = true +omit = ["tests/*"] + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +ignore = [ + "FBT001", + "FBT002", + "TRY002", + "TRY003", + "PLW0603", + "N802" +] + +[tool.ruff.per-file-ignores] +"tests/*" = ["RUF012", "S101", "PLR2004", "N803", "SLF001", "S105"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/synapse_invite_checker/__init__.py b/synapse_invite_checker/__init__.py new file mode 100644 index 0000000..25248cf --- /dev/null +++ b/synapse_invite_checker/__init__.py @@ -0,0 +1 @@ +from synapse_invite_checker.invite_checker import InviteChecker # noqa: F401 diff --git a/synapse_invite_checker/invite_checker.py b/synapse_invite_checker/invite_checker.py new file mode 100644 index 0000000..e30736a --- /dev/null +++ b/synapse_invite_checker/invite_checker.py @@ -0,0 +1,129 @@ +import treq +from twisted.internet.defer import inlineCallbacks, returnValue +from cachetools import TTLCache +from synapse.module_api import ModuleApi, errors +from synapse.types import UserID +import logging + +logger = logging.getLogger(__name__) + +class InviteCheckerConfig: + def __init__(self, config): + # Initialize whitelist and blacklist toggle settings + self.use_whitelist = config.get("use_whitelist", True) + self.use_blacklist = config.get("use_blacklist", True) + self.blacklist_whitelist_url = config.get("blacklist_whitelist_url", None) + + @staticmethod + def parse_config(config): + return InviteCheckerConfig(config) + +class InviteChecker: + def __init__(self, config, api: ModuleApi): + self.api = api + self.config = InviteCheckerConfig.parse_config(config) + + # Initialize toggles for enabling or disabling whitelist and blacklist + self.use_whitelist = self.config.use_whitelist + self.use_blacklist = self.config.use_blacklist + + # Cache for whitelist/blacklist (TTL = 10 minutes) + self.cache = TTLCache(maxsize=1, ttl=600) + + self.blacklist = set() + self.whitelist = set() + self.allow_all_invites_on_error = False # Flag to allow all invites if the JSON fetch fails + + # Register spam checker callback + self.api.register_spam_checker_callbacks(user_may_invite=self.user_may_invite) + logger.info("InviteChecker initialized") + + @inlineCallbacks + def fetch_json(self, url): + """Fetch JSON data from the URL asynchronously using Twisted treq.""" + logger.debug(f"Fetching JSON data from: {url}") + try: + response = yield treq.get(url) + if response.code == 200: + data = yield response.json() + logger.debug(f"Received data: {data}") + returnValue(data) + else: + logger.error(f"Failed to fetch JSON data. Status code: {response.code}") + returnValue(None) + except Exception as e: + logger.error(f"Error while fetching JSON: {str(e)}") + returnValue(None) + + @inlineCallbacks + def update_blacklist_whitelist(self): + """Fetch and update the blacklist and whitelist from the URLs.""" + logger.info("Updating blacklist and whitelist") + + json_data = yield self.fetch_json(self.config.blacklist_whitelist_url) + + if json_data: + self.allow_all_invites_on_error = False # Reset the error flag if we successfully fetch the JSON + self.use_whitelist = json_data.get('use_whitelist', True) + self.use_blacklist = json_data.get('use_blacklist', True) + self.blacklist = set(json_data.get('blacklist', [])) + self.whitelist = set(json_data.get('whitelist', [])) + + # Cache the data + self.cache['blacklist_whitelist'] = (self.blacklist, self.whitelist) + logger.info(f"Updated whitelist: {self.whitelist}") + logger.info(f"Updated blacklist: {self.blacklist}") + else: + # Set the error flag to allow all invites if fetching the JSON fails + logger.error("Failed to update whitelist/blacklist due to missing JSON data. Allowing all invites.") + self.allow_all_invites_on_error = True + + @inlineCallbacks + def get_blacklist_whitelist(self): + """Return cached blacklist/whitelist or fetch new data if cache is expired.""" + if self.allow_all_invites_on_error: + logger.info("Skipping whitelist/blacklist checks because of previous JSON fetch failure.") + returnValue((set(), set())) # Return empty sets to indicate no checks should be applied + + try: + returnValue(self.cache['blacklist_whitelist']) + except KeyError: + yield self.update_blacklist_whitelist() + returnValue(self.cache['blacklist_whitelist']) + + @inlineCallbacks + def user_may_invite(self, inviter: str, invitee: str, room_id: str): + """Check if a user may invite another based on whitelist/blacklist rules.""" + logger.debug(f"Checking invite from {inviter} to {invitee}") + + # If JSON fetching failed, allow all invites + if self.allow_all_invites_on_error: + logger.info(f"Allowing invite from {inviter} to {invitee} due to previous JSON fetch failure.") + returnValue("NOT_SPAM") # Allow all invites + + # Proceed with whitelist/blacklist checks if JSON fetching was successful + blacklist, whitelist = yield self.get_blacklist_whitelist() + inviter_domain = UserID.from_string(inviter).domain + + # Whitelist check (if enabled) + if self.use_whitelist: + logger.debug(f"Whitelist enabled. Checking domain: {inviter_domain}") + if inviter_domain in whitelist: + logger.info(f"Invite allowed: {inviter_domain} is in the whitelist") + returnValue("NOT_SPAM") # Allow if the domain is in the whitelist + + # Blacklist check (if enabled) + if self.use_blacklist: + logger.debug(f"Blacklist enabled. Checking domain: {inviter_domain}") + if inviter_domain in blacklist: + logger.info(f"Invite blocked: {inviter_domain} is in the blacklist") + returnValue(errors.Codes.FORBIDDEN) # Deny if the domain is in the blacklist + + # If whitelist is enabled and domain is not in the whitelist, deny the invite + if self.use_whitelist and inviter_domain not in whitelist: + logger.info(f"Invite blocked: {inviter_domain} is not in the whitelist") + returnValue(errors.Codes.FORBIDDEN) + + # Allow by default + logger.info(f"Invite allowed by default: {inviter_domain}") + returnValue("NOT_SPAM") diff --git a/synapse_invite_checker/setup.py b/synapse_invite_checker/setup.py new file mode 100644 index 0000000..36787de --- /dev/null +++ b/synapse_invite_checker/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages + +setup( + name="synapse-invite-checker", + version="0.1", + description="Synapse module for checking invites against whitelist and blacklist", + packages=find_packages(), + install_requires=[ + "matrix-synapse", # Ensure Synapse is installed + "aiohttp", # For async HTTP requests + "cachetools", # For caching the results + ], + entry_points={ + "synapse.python_module": [ + "InviteChecker = synapse_invite_checker.module:InviteChecker" + ] + }, +)