Skip to content

Commit

Permalink
Merge pull request #276 from python-discord/jb3/environ/python-3.12
Browse files Browse the repository at this point in the history
3.12 + Updates
  • Loading branch information
jb3 authored Jul 8, 2024
2 parents b03d30f + d04e74c commit 642c079
Show file tree
Hide file tree
Showing 35 changed files with 1,209 additions and 1,044 deletions.
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Other developers need to be able to reproduce your bug reports to fix them,
so please fill the following form to the best of your abilities.
- type: textarea
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this feature request!
Developers need to be able to understand your request,
to properly discuss it an implement it. Please fill this
form to the best of your ability.
Expand Down
20 changes: 11 additions & 9 deletions .github/workflows/forms-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ jobs:
uses: actions/checkout@v2

- name: Install Python Dependencies
uses: HassanAbouelela/actions/setup-python@setup-python_v1.3.1
uses: HassanAbouelela/actions/setup-python@setup-python_v1.6.0
with:
dev: true
python_version: "3.9"
python_version: "3.12"
install_args: "--only dev"

# Use this formatting to show them as GH Actions annotations.
- name: Run flake8
run: |
flake8 --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'
- name: Run pre-commit hooks
run: SKIP=ruff-lint pre-commit run --all-files

# Run `ruff` using github formatting to enable automatic inline annotations.
- name: Run ruff
run: "ruff check --output-format=github ."

# Prepare the Pull Request Payload artifact. If this fails, we
# we fail silently using the `continue-on-error` option. It's
Expand Down Expand Up @@ -117,5 +119,5 @@ jobs:
namespace: forms
manifests: |
deployment.yaml
images: 'ghcr.io/python-discord/forms-backend:${{ steps.sha_tag.outputs.tag }}'
kubectl-version: 'latest'
images: "ghcr.io/python-discord/forms-backend:${{ steps.sha_tag.outputs.tag }}"
kubectl-version: "latest"
29 changes: 29 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]

- repo: local
hooks:
- id: ruff-lint
name: ruff linting
description: Run ruff linting
entry: poetry run ruff check --force-exclude
language: system
"types_or": [python, pyi]
require_serial: true
args: [--fix, --exit-non-zero-on-fix]

- id: ruff-format
name: ruff formatting
description: Run ruff formatting
entry: poetry run ruff format --force-exclude
language: system
"types_or": [python, pyi]
require_serial: true
9 changes: 2 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
FROM --platform=linux/amd64 ghcr.io/chrislovering/python-poetry-base:3.9-slim
FROM --platform=linux/amd64 ghcr.io/owl-corp/python-poetry-base:3.12-slim

# Allow service to handle stops gracefully
STOPSIGNAL SIGQUIT

# Install C compiler and make
RUN apt-get update && \
apt-get install -y gcc make && \
apt-get clean && rm -rf /var/lib/apt/lists/*

# Install dependencies
WORKDIR /app
COPY pyproject.toml poetry.lock ./
Expand All @@ -24,4 +19,4 @@ ENV GIT_SHA=$git_sha

# Start the server with uvicorn
ENTRYPOINT ["poetry", "run"]
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8000", "-k", "uvicorn.workers.UvicornWorker", "backend:app"]
CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "8000"]
6 changes: 3 additions & 3 deletions backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
dsn=constants.FORMS_BACKEND_DSN,
send_default_pii=True,
release=SENTRY_RELEASE,
environment=SENTRY_RELEASE
environment=SENTRY_RELEASE,
)

middleware = [
Expand All @@ -36,10 +36,10 @@
allow_origins=["https://forms.pythondiscord.com"],
allow_origin_regex=ALLOW_ORIGIN_REGEX,
allow_headers=[
"Content-Type"
"Content-Type",
],
allow_methods=["*"],
allow_credentials=True
allow_credentials=True,
),
Middleware(DatabaseMiddleware),
Middleware(AuthenticationMiddleware, backend=JWTAuthenticationBackend()),
Expand Down
41 changes: 21 additions & 20 deletions backend/authentication/backend.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import typing as t

import jwt
from starlette import authentication
from starlette.requests import Request

from backend import constants
from backend import discord
from backend import constants, discord

# We must import user such way here to avoid circular imports
from .user import User

Expand All @@ -19,20 +17,19 @@ def get_token_from_cookie(cookie: str) -> str:
try:
prefix, token = cookie.split()
except ValueError:
raise authentication.AuthenticationError(
"Unable to split prefix and token from authorization cookie."
)
msg = "Unable to split prefix and token from authorization cookie."
raise authentication.AuthenticationError(msg)

if prefix.upper() != "JWT":
raise authentication.AuthenticationError(
f"Invalid authorization cookie prefix '{prefix}'."
)
msg = f"Invalid authorization cookie prefix '{prefix}'."
raise authentication.AuthenticationError(msg)

return token

async def authenticate(
self, request: Request
) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]:
self,
request: Request,
) -> tuple[authentication.AuthCredentials, authentication.BaseUser] | None:
"""Handles JWT authentication process."""
cookie = request.cookies.get("token")
if not cookie:
Expand All @@ -48,21 +45,25 @@ async def authenticate(
scopes = ["authenticated"]

if not payload.get("token"):
raise authentication.AuthenticationError("Token is missing from JWT.")
msg = "Token is missing from JWT."
raise authentication.AuthenticationError(msg)
if not payload.get("refresh"):
raise authentication.AuthenticationError(
"Refresh token is missing from JWT."
)
msg = "Refresh token is missing from JWT."
raise authentication.AuthenticationError(msg)

try:
user_details = payload.get("user_details")
if not user_details or not user_details.get("id"):
raise authentication.AuthenticationError("Improper user details.")
except Exception:
raise authentication.AuthenticationError("Could not parse user details.")
msg = "Improper user details."
raise authentication.AuthenticationError(msg) # noqa: TRY301
except Exception: # noqa: BLE001
msg = "Could not parse user details."
raise authentication.AuthenticationError(msg)

user = User(
token, user_details, await discord.get_member(request.state.db, user_details["id"])
token,
user_details,
await discord.get_member(request.state.db, user_details["id"]),
)
if await user.fetch_admin_status(request.state.db):
scopes.append("admin")
Expand Down
14 changes: 7 additions & 7 deletions backend/authentication/user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import typing
import typing as t

import jwt
Expand All @@ -16,7 +15,7 @@ def __init__(
self,
token: str,
payload: dict[str, t.Any],
member: typing.Optional[models.DiscordMember],
member: models.DiscordMember | None,
) -> None:
self.token = token
self.payload = payload
Expand All @@ -31,11 +30,11 @@ def is_authenticated(self) -> bool:
@property
def display_name(self) -> str:
"""Return username and discriminator as display name."""
return f"{self.payload['username']}#{self.payload['discriminator']}"
return f"{self.payload["username"]}#{self.payload["discriminator"]}"

@property
def discord_mention(self) -> str:
return f"<@{self.payload['id']}>"
return f"<@{self.payload["id"]}>"

@property
def user_id(self) -> str:
Expand All @@ -61,9 +60,10 @@ async def get_user_roles(self, database: Database) -> list[str]:
return roles

async def fetch_admin_status(self, database: Database) -> bool:
self.admin = await database.admins.find_one(
{"_id": self.payload["id"]}
) is not None
query = {"_id": self.payload["id"]}
found_admin = await database.admins.find_one(query)

self.admin = found_admin is not None

return self.admin

Expand Down
5 changes: 3 additions & 2 deletions backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID")
OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET")
OAUTH2_REDIRECT_URI = os.getenv(
"OAUTH2_REDIRECT_URI", "https://forms.pythondiscord.com/callback"
"OAUTH2_REDIRECT_URI",
"https://forms.pythondiscord.com/callback",
)

GIT_SHA = os.getenv("GIT_SHA", "dev")
Expand All @@ -28,7 +29,7 @@

SECRET_KEY = os.getenv("SECRET_KEY", binascii.hexlify(os.urandom(30)).decode())
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
DISCORD_GUILD = os.getenv("DISCORD_GUILD", 267624335836053506)
DISCORD_GUILD = os.getenv("DISCORD_GUILD", "267624335836053506")

HCAPTCHA_API_SECRET = os.getenv("HCAPTCHA_API_SECRET")

Expand Down
60 changes: 38 additions & 22 deletions backend/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import datetime
import json
import typing

import httpx
import starlette.requests
Expand All @@ -17,7 +16,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict
data = {
"client_id": constants.OAUTH2_CLIENT_ID,
"client_secret": constants.OAUTH2_CLIENT_SECRET,
"redirect_uri": f"{redirect}/callback"
"redirect_uri": f"{redirect}/callback",
}

if refresh:
Expand All @@ -27,9 +26,13 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict
data["grant_type"] = "authorization_code"
data["code"] = code

r = await client.post(f"{constants.DISCORD_API_BASE_URL}/oauth2/token", headers={
"Content-Type": "application/x-www-form-urlencoded"
}, data=data)
r = await client.post(
f"{constants.DISCORD_API_BASE_URL}/oauth2/token",
headers={
"Content-Type": "application/x-www-form-urlencoded",
},
data=data,
)

r.raise_for_status()

Expand All @@ -38,9 +41,12 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict

async def fetch_user_details(bearer_token: str) -> dict:
async with httpx.AsyncClient() as client:
r = await client.get(f"{constants.DISCORD_API_BASE_URL}/users/@me", headers={
"Authorization": f"Bearer {bearer_token}"
})
r = await client.get(
f"{constants.DISCORD_API_BASE_URL}/users/@me",
headers={
"Authorization": f"Bearer {bearer_token}",
},
)

r.raise_for_status()

Expand All @@ -52,15 +58,17 @@ async def _get_role_info() -> list[models.DiscordRole]:
async with httpx.AsyncClient() as client:
r = await client.get(
f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}/roles",
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"},
)

r.raise_for_status()
return [models.DiscordRole(**role) for role in r.json()]


async def get_roles(
database: Database, *, force_refresh: bool = False
database: Database,
*,
force_refresh: bool = False,
) -> list[models.DiscordRole]:
"""
Get a list of all roles from the cache, or discord API if not available.
Expand All @@ -86,23 +94,26 @@ async def get_roles(
if len(roles) == 0:
# Fetch roles from the API and insert into the database
roles = await _get_role_info()
await collection.insert_many({
"name": role.name,
"id": role.id,
"data": role.json(),
"inserted_at": datetime.datetime.now(tz=datetime.timezone.utc),
} for role in roles)
await collection.insert_many(
{
"name": role.name,
"id": role.id,
"data": role.json(),
"inserted_at": datetime.datetime.now(tz=datetime.UTC),
}
for role in roles
)

return roles


async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMember]:
async def _fetch_member_api(member_id: str) -> models.DiscordMember | None:
"""Get a member by ID from the configured guild using the discord API."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}"
f"/members/{member_id}",
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"}
headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"},
)

if r.status_code == 404:
Expand All @@ -113,8 +124,11 @@ async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMem


async def get_member(
database: Database, user_id: str, *, force_refresh: bool = False
) -> typing.Optional[models.DiscordMember]:
database: Database,
user_id: str,
*,
force_refresh: bool = False,
) -> models.DiscordMember | None:
"""
Get a member from the cache, or from the discord API.
Expand Down Expand Up @@ -147,7 +161,7 @@ async def get_member(
await collection.insert_one({
"user": user_id,
"data": member.json(),
"inserted_at": datetime.datetime.now(tz=datetime.timezone.utc),
"inserted_at": datetime.datetime.now(tz=datetime.UTC),
})
return member

Expand All @@ -161,7 +175,9 @@ class UnauthorizedError(exceptions.HTTPException):


async def _verify_access_helper(
form_id: str, request: starlette.requests.Request, attribute: str
form_id: str,
request: starlette.requests.Request,
attribute: str,
) -> None:
"""A low level helper to validate access to a form resource based on the user's scopes."""
form = await request.state.db.forms.find_one({"_id": form_id})
Expand Down
Loading

0 comments on commit 642c079

Please sign in to comment.