Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
cc35a2b
Refactor membership checking
MattyTheHacker Aug 31, 2025
0917ae5
Refactor and Reformat
MattyTheHacker Aug 31, 2025
2270e9c
Fix import error
MattyTheHacker Aug 31, 2025
fdda120
Bit of a mess
MattyTheHacker Aug 31, 2025
1ed9d9f
Formatting
MattyTheHacker Aug 31, 2025
797f94d
Fix
MattyTheHacker Aug 31, 2025
2ac933d
Reformat
MattyTheHacker Aug 31, 2025
74e57a6
Revert accidental change
MattyTheHacker Aug 31, 2025
fc6c1aa
Revert
MattyTheHacker Aug 31, 2025
f324842
Add logging
MattyTheHacker Aug 31, 2025
00a7121
Simplify logic
MattyTheHacker Sep 1, 2025
363e315
Refactor auth token checking
MattyTheHacker Sep 1, 2025
1c1722f
Remove unused variables
MattyTheHacker Sep 1, 2025
5670cba
Merge main into msl-auth
automatic-pr-updater[bot] Sep 2, 2025
26dcf70
Merge main into msl-auth
automatic-pr-updater[bot] Sep 2, 2025
ca9f7fb
Strip
MattyTheHacker Sep 3, 2025
6c976d3
remove old import
MattyTheHacker Sep 3, 2025
c466e69
Merge main into msl-auth
automatic-pr-updater[bot] Sep 4, 2025
ec9b323
Merge main into msl-auth
automatic-pr-updater[bot] Sep 4, 2025
3886f61
Merge main into msl-auth
automatic-pr-updater[bot] Sep 5, 2025
104da07
Merge main into msl-auth
automatic-pr-updater[bot] Sep 5, 2025
2aae19e
Merge branch 'main' into msl-auth
MattyTheHacker Oct 7, 2025
44d8215
Fix mypy
MattyTheHacker Oct 7, 2025
7a50739
Merge main into msl-auth
automatic-pr-updater[bot] Oct 8, 2025
508120b
Merge main into msl-auth
automatic-pr-updater[bot] Oct 13, 2025
be46790
Merge main into msl-auth
automatic-pr-updater[bot] Oct 14, 2025
cdfbf13
Merge main into msl-auth
automatic-pr-updater[bot] Oct 16, 2025
1f59415
Merge branch 'main' into msl-auth
MattyTheHacker Oct 28, 2025
92aee59
Fix spacing
MattyTheHacker Oct 28, 2025
5ae6b81
Merge main into msl-auth
automatic-pr-updater[bot] Nov 2, 2025
23f9d49
Merge main into msl-auth
automatic-pr-updater[bot] Nov 4, 2025
f5cc302
Merge main into msl-auth
automatic-pr-updater[bot] Nov 4, 2025
d03f3b9
Merge main into msl-auth
automatic-pr-updater[bot] Nov 5, 2025
d7a56de
Merge main into msl-auth
automatic-pr-updater[bot] Nov 5, 2025
4aef459
Merge main into msl-auth
automatic-pr-updater[bot] Nov 6, 2025
f5ad888
Merge main into msl-auth
automatic-pr-updater[bot] Nov 11, 2025
381bd04
Merge main into msl-auth
automatic-pr-updater[bot] Nov 11, 2025
c3a8968
Merge main into msl-auth
automatic-pr-updater[bot] Nov 11, 2025
d546f1a
Merge main into msl-auth
automatic-pr-updater[bot] Nov 13, 2025
6e2b490
Merge main into msl-auth
automatic-pr-updater[bot] Nov 15, 2025
b95929a
Merge main into msl-auth
automatic-pr-updater[bot] Nov 17, 2025
e2a25cf
Merge main into msl-auth
automatic-pr-updater[bot] Nov 18, 2025
53a0fd3
Merge main into msl-auth
automatic-pr-updater[bot] Nov 18, 2025
13cced0
Merge main into msl-auth
automatic-pr-updater[bot] Nov 18, 2025
1dfd9be
Merge main into msl-auth
automatic-pr-updater[bot] Nov 20, 2025
669155d
Merge main into msl-auth
automatic-pr-updater[bot] Nov 20, 2025
82d4eb2
Merge main into msl-auth
automatic-pr-updater[bot] Nov 21, 2025
11dc44a
Merge main into msl-auth
automatic-pr-updater[bot] Nov 24, 2025
87f9336
Merge main into msl-auth
automatic-pr-updater[bot] Nov 25, 2025
8fe9412
Merge main into msl-auth
automatic-pr-updater[bot] Nov 25, 2025
83c28b5
Merge main into msl-auth
automatic-pr-updater[bot] Nov 27, 2025
954b662
Merge main into msl-auth
automatic-pr-updater[bot] Nov 27, 2025
fbb6344
Merge main into msl-auth
automatic-pr-updater[bot] Nov 28, 2025
5d91282
Merge main into msl-auth
automatic-pr-updater[bot] Dec 1, 2025
6761788
Merge main into msl-auth
automatic-pr-updater[bot] Dec 1, 2025
c86723a
Merge main into msl-auth
automatic-pr-updater[bot] Dec 2, 2025
61c41c6
Merge main into msl-auth
automatic-pr-updater[bot] Dec 3, 2025
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
137 changes: 7 additions & 130 deletions cogs/check_su_platform_authorisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@
from enum import Enum
from typing import TYPE_CHECKING, override

import aiohttp
import bs4
import discord
from discord.ext import tasks

from config import settings
from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog
from utils import CommandChecks, TeXBotBaseCog
from utils.error_capture_decorators import (
capture_guild_does_not_exist_error,
)
from utils.msl import get_su_platform_access_cookie_status, get_su_platform_organisations

if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
from logging import Logger
from typing import Final
Expand All @@ -31,21 +30,6 @@

logger: "Final[Logger]" = logging.getLogger("TeX-Bot")

REQUEST_HEADERS: "Final[Mapping[str, str]]" = {
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Expires": "0",
}

REQUEST_COOKIES: "Final[Mapping[str, str]]" = {
".AspNet.SharedCookie": settings["SU_PLATFORM_ACCESS_COOKIE"]
}

SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile"
SU_PLATFORM_ORGANISATION_URL: "Final[str]" = (
"https://www.guildofstudents.com/organisation/admin"
)


class SUPlatformAccessCookieStatus(Enum):
"""Enum class defining the status of the SU Platform Access Cookie."""
Expand Down Expand Up @@ -73,114 +57,7 @@ class SUPlatformAccessCookieStatus(Enum):
)


class CheckSUPlatformAuthorisationBaseCog(TeXBotBaseCog):
"""Cog class that defines the base functionality for cookie authorisation checks."""

async def _fetch_url_content_with_session(self, url: str) -> str:
"""Fetch the HTTP content at the given URL, using a shared aiohttp session."""
async with (
aiohttp.ClientSession(
headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES
) as http_session,
http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response,
):
return await http_response.text()

async def get_su_platform_access_cookie_status(self) -> SUPlatformAccessCookieStatus:
"""Retrieve the current validity status of the SU platform access cookie."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)
page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")
if not page_title or "Login" in str(page_title):
logger.warning("Token is invalid or expired.")
return SUPlatformAccessCookieStatus.INVALID

organisation_admin_url: str = (
f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}"
)
response_html: str = await self._fetch_url_content_with_session(organisation_admin_url)

if "admin tools" in response_html.lower():
return SUPlatformAccessCookieStatus.AUTHORISED

if "You do not have any permissions for this organisation" in response_html.lower():
return SUPlatformAccessCookieStatus.VALID

logger.warning(
"Unexpected response when checking SU platform access cookie authorisation."
)
return SUPlatformAccessCookieStatus.INVALID

async def get_su_platform_organisations(self) -> "Iterable[str]":
"""Retrieve the MSL organisations the current SU platform cookie has access to."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)

page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")

if not page_title:
logger.warning(
"Profile page returned no content when checking "
"SU platform access cookie's authorisation."
)
return ()

if "Login" in str(page_title):
logger.warning(
"Authentication redirected to login page. "
"SU platform access cookie is invalid or expired."
)
return ()

profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"div", {"id": "profile_main"}
)

if profile_section_html is None:
logger.warning(
"Couldn't find the profile section of the user "
"when scraping the SU platform's website HTML."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1")

if not isinstance(user_name, bs4.Tag):
logger.warning(
"Found user profile on the SU platform but couldn't find their name."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"ul", {"id": "ulOrgs"}
)

if parsed_html is None or isinstance(parsed_html, bs4.NavigableString):
NO_ADMIN_TABLE_MESSAGE: Final[str] = (
f"Failed to retrieve the admin table for user: {user_name.string}. "
"Please check you have used the correct SU platform access token!"
)
logger.warning(NO_ADMIN_TABLE_MESSAGE)
return ()

organisations: Iterable[str] = [
list_item.get_text(strip=True) for list_item in parsed_html.find_all("li")
]

logger.debug(
"SU platform access cookie has admin authorisation to: %s as user %s",
organisations,
user_name.text,
)

return organisations


class CheckSUPlatformAuthorisationCommandCog(CheckSUPlatformAuthorisationBaseCog):
class CheckSUPlatformAuthorisationCommandCog(TeXBotBaseCog):
"""Cog class that defines the "/check-su-platform-authorisation" command."""

@discord.slash_command(
Expand All @@ -201,7 +78,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")

async with ctx.typing():
su_platform_access_cookie_organisations: AbstractSet[str] = set(
await self.get_su_platform_organisations()
await get_su_platform_organisations()
)

await ctx.followup.send(
Expand All @@ -224,7 +101,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
)


class CheckSUPlatformAuthorisationTaskCog(CheckSUPlatformAuthorisationBaseCog):
class CheckSUPlatformAuthorisationTaskCog(TeXBotBaseCog):
"""Cog class defining a repeated task for checking SU platform access cookie."""

@override
Expand Down Expand Up @@ -256,7 +133,7 @@ async def su_platform_access_cookie_check_task(self) -> None:
logger.debug("Running SU platform access cookie check task...")

su_platform_access_cookie_status: tuple[int, str] = (
await self.get_su_platform_access_cookie_status()
await get_su_platform_access_cookie_status()
).value

logger.log(
Expand Down
4 changes: 4 additions & 0 deletions utils/msl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import TYPE_CHECKING

from .authorisation import get_su_platform_access_cookie_status, get_su_platform_organisations
from .memberships import (
fetch_community_group_members_count,
fetch_community_group_members_list,
Expand All @@ -12,7 +13,10 @@
from collections.abc import Sequence

__all__: "Sequence[str]" = (
"GLOBAL_SSL_CONTEXT",
"fetch_community_group_members_count",
"fetch_community_group_members_list",
"get_su_platform_access_cookie_status",
"get_su_platform_organisations",
"is_id_a_community_group_member",
)
135 changes: 135 additions & 0 deletions utils/msl/authorisation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Module for authorisation checks."""

import logging
from typing import TYPE_CHECKING

import aiohttp
import bs4

from cogs.check_su_platform_authorisation import SUPlatformAccessCookieStatus
from config import settings
from utils import GLOBAL_SSL_CONTEXT

from .core import BASE_COOKIES, BASE_HEADERS

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from logging import Logger
from typing import Final


__all__: "Sequence[str]" = (
"get_su_platform_access_cookie_status",
"get_su_platform_organisations",
)


logger: "Final[Logger]" = logging.getLogger("TeX-Bot")


SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile"
SU_PLATFORM_ORGANISATION_URL: "Final[str]" = (
"https://www.guildofstudents.com/organisation/admin"
)


async def _fetch_url_content_with_session(url: str) -> str:
"""Fetch the HTTP content at the given URL, using a shared aiohttp session."""
async with (
aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session,
http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response,
):
return await http_response.text()


async def get_su_platform_access_cookie_status() -> SUPlatformAccessCookieStatus:
"""Retrieve the current validity status of the SU platform access cookie."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)
page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")
if not page_title or "Login" in str(page_title):
logger.debug("Token is invalid or expired.")
return SUPlatformAccessCookieStatus.INVALID

organisation_admin_url: str = (
f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}"
)
response_html: str = await _fetch_url_content_with_session(organisation_admin_url)

if "admin tools" in response_html.lower():
return SUPlatformAccessCookieStatus.AUTHORISED

if "You do not have any permissions for this organisation" in response_html.lower():
return SUPlatformAccessCookieStatus.VALID

logger.warning(
"Unexpected response when checking SU platform access cookie authorisation."
)
return SUPlatformAccessCookieStatus.INVALID


async def get_su_platform_organisations() -> "Iterable[str]":
"""Retrieve the MSL organisations the current SU platform cookie has access to."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)

page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")

if not page_title:
logger.warning(
"Profile page returned no content when checking "
"SU platform access cookie's authorisation."
)
return ()

if "Login" in str(page_title):
logger.warning(
"Authentication redirected to login page. "
"SU platform access cookie is invalid or expired."
)
return ()

profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"div", {"id": "profile_main"}
)

if profile_section_html is None:
logger.warning(
"Couldn't find the profile section of the user "
"when scraping the SU platform's website HTML."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1")

if not isinstance(user_name, bs4.Tag):
logger.warning("Found user profile on the SU platform but couldn't find their name.")
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"ul", {"id": "ulOrgs"}
)

if parsed_html is None or isinstance(parsed_html, bs4.NavigableString):
NO_ADMIN_TABLE_MESSAGE: Final[str] = (
f"Failed to retrieve the admin table for user: {user_name.string}. "
"Please check you have used the correct SU platform access token!"
)
logger.warning(NO_ADMIN_TABLE_MESSAGE)
return ()

organisations: Iterable[str] = [
list_item.get_text(strip=True) for list_item in parsed_html.find_all("li")
]

logger.debug(
"SU platform access cookie has admin authorisation to: %s as user %s",
organisations,
user_name.text,
)

return organisations
Loading