Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First cut of the module #1

Merged
merged 6 commits into from
Feb 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ Then alter your homeserver configuration, adding to your `modules` configuration
modules:
- module: tchap_username_email.TchapUsernameEmail
config:
# TODO: Complete this section with an example for your module
# Whether to try to extract full name and organisation from the email address. The
# resulting display name will have the form "John Doe [Acme]".
# Optional, defaults to "true".
extract_from_email: true
```


Expand Down Expand Up @@ -74,13 +77,3 @@ Synapse developers (assuming a Unix-like shell):
```shell
git push origin tag v$version
```

7. If applicable:
Create a *release*, based on the tag you just pushed, on GitHub or GitLab.

8. If applicable:
Create a source distribution and upload it to PyPI:
```shell
python -m build
twine upload dist/tchap_username_email-$version*
```
125 changes: 125 additions & 0 deletions tchap_username_email/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Any, Dict, Optional

import attr
from synapse.module_api import ModuleApi

AUTH_TYPE_EMAIL = "m.login.email.identity"


@attr.s(auto_attribs=True, frozen=True)
class TchapUsernameEmailConfig:
extract_from_email: bool


class TchapUsernameEmail:
def __init__(self, config: TchapUsernameEmailConfig, api: ModuleApi):
# Keep a reference to the config.
self._config = config

api.register_password_auth_provider_callbacks(
get_displayname_for_registration=self.extract_displayname_from_email,
)

@staticmethod
def parse_config(config: Dict[str, Any]) -> TchapUsernameEmailConfig:
extract_from_email = config.get("extract_from_email", True)
return TchapUsernameEmailConfig(
extract_from_email=extract_from_email,
)

async def extract_displayname_from_email(
self,
uia_results: Dict[str, Any],
params: Dict[str, Any],
) -> Optional[str]:
"""Checks if an email address can be found in the UIA results, and if so derives
the display name from it.

Args:
uia_results: The UIA results.
params: The parameters of the registration request.

Returns:
The username if an email address could be found in the UIA results, None
otherwise. If an email is present, and `extract_from_email` is True, then
the email address is used as is.
"""
if AUTH_TYPE_EMAIL in uia_results:
address: str = uia_results[AUTH_TYPE_EMAIL]["address"]

if self._config.extract_from_email:
return _map_email_to_displayname(address)

return address

return None


def cap(name: str) -> str:
"""Capitalise parts of a name containing different words, including those
separated by hyphens.

For example, 'John-Doe'

Args:
The name to parse
"""
if not name:
return name

# Split the name by whitespace then hyphens, capitalizing each part then
# joining it back together.
capitalized_name = " ".join(
"-".join(part.capitalize() for part in space_part.split("-"))
for space_part in name.split()
)
return capitalized_name


def _map_email_to_displayname(address: str) -> str:
"""Custom mapping from an email address to a user displayname

Args:
address: The email address to process

Returns:
The new displayname
"""
# Split the part before and after the @ in the email.
# Replace all . with spaces in the first part
parts = address.replace(".", " ").split("@")

# Figure out which org this email address belongs to
org_parts = parts[1].split(" ")

# If this is a ...matrix.org email, mark them as an Admin
if org_parts[-2] == "matrix" and org_parts[-1] == "org":
org = "Tchap Admin"

# Is this is a ...gouv.fr address, set the org to whatever is before
# gouv.fr. If there isn't anything (a @gouv.fr email) simply mark their
# org as "gouv"
elif org_parts[-2] == "gouv" and org_parts[-1] == "fr":
org = org_parts[-3] if len(org_parts) > 2 else org_parts[-2]

# Otherwise, mark their org as the email's second-level domain name
else:
org = org_parts[-2]

desired_display_name = cap(parts[0]) + " [" + cap(org) + "]"

return desired_display_name
Empty file added tchap_username_email/py.typed
Empty file.
33 changes: 33 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Any, Dict, Optional
from unittest.mock import Mock

from synapse.module_api import ModuleApi

from tchap_username_email import TchapUsernameEmail


def create_module(raw_config: Optional[Dict[str, Any]] = None) -> TchapUsernameEmail:
# Create a mock based on the ModuleApi spec, but override some mocked functions
# because some capabilities are needed for running the tests.
module_api = Mock(spec=ModuleApi)

# Give parse_config some configuration to parse.
if raw_config is None:
raw_config = {}

config = TchapUsernameEmail.parse_config(raw_config)

return TchapUsernameEmail(config, module_api)
95 changes: 95 additions & 0 deletions tests/test_username.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional

import aiounittest

from tchap_username_email import AUTH_TYPE_EMAIL, TchapUsernameEmail
from tests import create_module


class UsernameTestCase(aiounittest.AsyncTestCase):
async def test_no_email(self) -> None:
"""Tests that the module returns None if there's no email provided."""
module = create_module()
res = await module.extract_displayname_from_email({}, {})
self.assertIsNone(res)

async def test_matrix_org(self) -> None:
"""Tests that the module returns the correct org if a matrix.org email is
provided.
"""
module = create_module()
res = await self._get_username(module, "foo.bar@matrix.org")
self.assertEqual(res, "Foo Bar [Tchap Admin]")

async def test_gouv_fr(self) -> None:
"""Tests that the module returns the correct org if a gouv.fr (without subdomain)
email is provided.
"""
module = create_module()
res = await self._get_username(module, "foo.bar@gouv.fr")
self.assertEqual(res, "Foo Bar [Gouv]")

async def test_gouv_fr_sub(self) -> None:
"""Tests that the module returns the correct org if a gouv.fr (with subdomain)
email is provided.
"""
module = create_module()
res = await self._get_username(module, "foo.bar@education.gouv.fr")
self.assertEqual(res, "Foo Bar [Education]")

async def test_other_domain(self) -> None:
"""Tests that the module returns the correct org if an email of a unknown domain
is provided.
"""
module = create_module()
res = await self._get_username(module, "foo.bar@nikan.com")
self.assertEqual(res, "Foo Bar [Nikan]")

async def test_hyphen(self) -> None:
"""Tests that the module capitalises name parts even if they're only separated by
a hyphen.
"""
module = create_module()
res = await self._get_username(module, "foo-bar.baz@nikan.com")
self.assertEqual(res, "Foo-Bar Baz [Nikan]")

async def test_no_last_name(self) -> None:
"""Tests that the module doesn't always expect a last name to be provided."""
module = create_module()
res = await self._get_username(module, "foo@nikan.com")
self.assertEqual(res, "Foo [Nikan]")

async def test_not_extract(self) -> None:
"""Tests that the module returns directly with the provided email address if told
to do so.
"""
module = create_module({"extract_from_email": False})
res = await self._get_username(module, "foo@nikan.com")
self.assertEqual(res, "foo@nikan.com")

async def _get_username(
self,
module: TchapUsernameEmail,
email: Optional[str],
) -> Optional[str]:
"""Calls the extract_displayname_from_email method on the given module using the
given email address.
"""
results = {}
if email is not None:
results[AUTH_TYPE_EMAIL] = {"address": email}

return await module.extract_displayname_from_email(results, {})