aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/api/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site/apps/api/views.py')
-rw-r--r--pydis_site/apps/api/views.py218
1 files changed, 201 insertions, 17 deletions
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 816463f6..1fa3efc2 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -1,7 +1,17 @@
+import json
+import logging
+import urllib.request
+from collections.abc import Mapping
+from http import HTTPStatus
+
+from rest_framework import status
from rest_framework.exceptions import ParseError
+from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
+from . import github_utils
+
class HealthcheckView(APIView):
"""
@@ -34,12 +44,14 @@ class RulesView(APIView):
## Routes
### GET /rules
- Returns a JSON array containing the server's rules:
+ Returns a JSON array containing the server's rules
+ and keywords relating to each rule.
+ Example response:
>>> [
- ... "Eat candy.",
- ... "Wake up at 4 AM.",
- ... "Take your medicine."
+ ... ["Eat candy.", ["candy", "sweets"]],
+ ... ["Wake up at 4 AM.", ["wake_up", "early", "early_bird"]],
+ ... ["Take your medicine.", ["medicine", "health"]]
... ]
Since some of the the rules require links, this view
@@ -88,7 +100,7 @@ class RulesView(APIView):
"""
if target == 'html':
return f'<a href="{link}">{description}</a>'
- elif target == 'md':
+ elif target == 'md': # noqa: RET505
return f'[{description}]({link})'
else:
raise ValueError(
@@ -96,7 +108,13 @@ class RulesView(APIView):
)
# `format` here is the result format, we have a link format here instead.
- def get(self, request, format=None): # noqa: D102,ANN001,ANN201
+ def get(self, request, format=None): # noqa: ANN001, ANN201
+ """
+ Returns a list of our community rules coupled with their keywords.
+
+ Each item in the returned list is a tuple with the rule as first item
+ and a list of keywords that match that rules as second item.
+ """
link_format = request.query_params.get('link_format', 'md')
if link_format not in ('html', 'md'):
raise ParseError(
@@ -109,7 +127,7 @@ class RulesView(APIView):
link_format
)
discord_tos = self._format_link(
- 'Terms Of Service',
+ 'Terms of Service',
'https://discordapp.com/terms',
link_format
)
@@ -121,34 +139,200 @@ class RulesView(APIView):
return Response([
(
- f"Follow the {pydis_coc}."
+ f"Follow the {pydis_coc}.",
+ ["coc", "conduct", "code"]
),
(
- f"Follow the {discord_community_guidelines} and {discord_tos}."
+ f"Follow the {discord_community_guidelines} and {discord_tos}.",
+ ["discord", "guidelines", "discord_tos"]
),
(
- "Respect staff members and listen to their instructions."
+ "Respect staff members and listen to their instructions.",
+ ["respect", "staff", "instructions"]
),
(
"Use English to the best of your ability. "
- "Be polite if someone speaks English imperfectly."
+ "Be polite if someone speaks English imperfectly.",
+ ["english", "eng", "language"]
),
(
- "Do not provide or request help on projects that may break laws, "
- "breach terms of services, or are malicious or inappropriate."
+ "Do not provide or request help on projects that may violate terms of service, "
+ "or that may be deemed inappropriate, malicious, or illegal.",
+ ["infraction", "tos", "breach", "malicious", "inappropriate", "illegal"]
),
(
- "Do not post unapproved advertising."
+ "Do not post unapproved advertising.",
+ ["ad", "ads", "advert", "advertising"]
),
(
"Keep discussions relevant to the channel topic. "
- "Each channel's description tells you the topic."
+ "Each channel's description tells you the topic.",
+ ["off-topic", "topic", "relevance"]
),
(
"Do not help with ongoing exams. When helping with homework, "
- "help people learn how to do the assignment without doing it for them."
+ "help people learn how to do the assignment without doing it for them.",
+ ["exam", "exams", "assignment", "assignments", "homework", "hw"]
),
(
- "Do not offer or ask for paid work of any kind."
+ "Do not offer or ask for paid work of any kind.",
+ ["pay", "paid", "work", "money", "hire"]
+ ),
+ (
+ "Do not copy and paste answers from ChatGPT or similar AI tools.",
+ ["gpt", "chatgpt", "gpt3", "ai"]
),
])
+
+
+class GitHubArtifactsView(APIView):
+ """
+ Provides utilities for interacting with the GitHub API and obtaining action artifacts.
+
+ ## Routes
+ ### GET /github/artifacts
+ Returns a download URL for the artifact requested.
+
+ {
+ 'url': 'https://pipelines.actions.githubusercontent.com/...'
+ }
+
+ ### Exceptions
+ In case of an error, the following body will be returned:
+
+ {
+ "error_type": "<error class name>",
+ "error": "<error description>",
+ "requested_resource": "<owner>/<repo>/<sha>/<artifact_name>"
+ }
+
+ ## Authentication
+ Does not require any authentication nor permissions.
+ """
+
+ authentication_classes = ()
+ permission_classes = ()
+
+ def get(
+ self,
+ request: Request,
+ *,
+ owner: str,
+ repo: str,
+ sha: str,
+ action_name: str,
+ artifact_name: str
+ ) -> Response:
+ """Return a download URL for the requested artifact."""
+ try:
+ url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name)
+ return Response({"url": url})
+ except github_utils.ArtifactProcessingError as e:
+ return Response({
+ "error_type": e.__class__.__name__,
+ "error": str(e),
+ "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}"
+ }, status=e.status)
+
+
+class GitHubWebhookFilterView(APIView):
+ """
+ Filters uninteresting events from webhooks sent by GitHub to Discord.
+
+ ## Routes
+ ### POST /github/webhook-filter/:webhook_id/:webhook_token
+ Takes the GitHub webhook payload as the request body, documented on here:
+ https://docs.github.com/en/webhooks/webhook-events-and-payloads. The endpoint
+ will then determine whether the sent webhook event is of interest,
+ and if so, will forward it to Discord. The response from Discord is
+ then returned back to the client of this website, including the original
+ status code and headers (excluding `Content-Type`).
+
+ ## Authentication
+ Does not require any authentication nor permissions on its own, however,
+ Discord will validate that the webhook originates from GitHub and respond
+ with a 403 forbidden error if not.
+ """
+
+ authentication_classes = ()
+ permission_classes = ()
+ logger = logging.getLogger(__name__ + ".GitHubWebhookFilterView")
+
+ def post(self, request: Request, *, webhook_id: str, webhook_token: str) -> Response:
+ """Filter a webhook POST from GitHub before sending it to Discord."""
+ sender = request.data.get('sender', {})
+ sender_name = sender.get('login', '').lower()
+ event = request.headers.get('X-GitHub-Event', '').lower()
+ repository = request.data.get('repository', {})
+
+ is_coveralls = 'coveralls' in sender_name
+ is_github_bot = sender.get('type', '').lower() == 'bot'
+ is_sentry = 'sentry-io' in sender_name
+ is_dependabot_branch_deletion = (
+ 'dependabot' in request.data.get('ref', '').lower()
+ and event == 'delete'
+ )
+ is_bot_pr_approval = is_github_bot and event == 'pull_request_review'
+ is_empty_review = (
+ request.data.get('review', {}).get('state', '').lower() == 'commented'
+ and event == 'pull_request_review'
+ and request.data.get('review', {}).get('body') is None
+ )
+ is_black_non_main_push = (
+ request.data.get('ref') != 'refs/heads/main'
+ and repository.get('name', '').lower() == 'black'
+ and repository.get('owner', {}).get('login', '').lower() == 'psf'
+ and event == 'push'
+ )
+
+ is_bot_payload = (
+ is_coveralls
+ or (is_github_bot and not is_sentry)
+ or is_dependabot_branch_deletion
+ or is_bot_pr_approval
+ )
+ is_noisy_user_action = is_empty_review
+ should_ignore = is_bot_payload or is_noisy_user_action or is_black_non_main_push
+
+ if should_ignore:
+ return Response(
+ {'message': "Ignored by github-filter endpoint"},
+ status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION,
+ )
+
+ (response_status, headers, body) = self.send_webhook(
+ webhook_id, webhook_token, request.data, dict(request.headers),
+ )
+ headers.pop('Connection', None)
+ headers.pop('Content-Length', None)
+ return Response(data=body, headers=headers, status=response_status)
+
+ def send_webhook(
+ self,
+ webhook_id: str,
+ webhook_token: str,
+ data: dict,
+ headers: Mapping[str, str],
+ ) -> tuple[int, dict[str, str], bytes]:
+ """Execute a webhook on Discord's GitHub webhook endpoint."""
+ payload = json.dumps(data).encode()
+ headers.pop('Content-Length', None)
+ headers.pop('Content-Type', None)
+ headers.pop('Host', None)
+ request = urllib.request.Request( # noqa: S310
+ f'https://discord.com/api/webhooks/{webhook_id}/{webhook_token}/github?wait=1',
+ data=payload,
+ headers={'Content-Type': 'application/json', **headers},
+ )
+
+ try:
+ with urllib.request.urlopen(request) as response: # noqa: S310
+ return (response.status, dict(response.getheaders()), response.read())
+ except urllib.error.HTTPError as err: # pragma: no cover
+ if err.code == HTTPStatus.TOO_MANY_REQUESTS:
+ self.logger.warning(
+ "We are being rate limited by Discord! Scope: %s, reset-after: %s",
+ headers.get("X-RateLimit-Scope"),
+ headers.get("X-RateLimit-Reset-After"),
+ )
+ return (err.code, dict(err.headers), err.fp.read())