Skip to content

Commit

Permalink
feat: Convert to Flask server (BURG3R5#149)
Browse files Browse the repository at this point in the history
  • Loading branch information
BURG3R5 authored Jan 6, 2023
2 parents 0c43766 + 4b97ca0 commit decea76
Show file tree
Hide file tree
Showing 11 changed files with 61 additions and 74 deletions.
4 changes: 2 additions & 2 deletions bot/github/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import requests
import sentry_sdk
from bottle import redirect
from flask import redirect

from .base import GitHubBase

Expand Down Expand Up @@ -35,7 +35,7 @@ def redirect_to_oauth_flow(self, state: str):
f"https://redirect.mdgspace.org/{self.base_url}"
f"/github/auth/redirect",
}
redirect(endpoint + "?" + urllib.parse.urlencode(params))
return redirect(endpoint + "?" + urllib.parse.urlencode(params))

def set_up_webhooks(self, code: str, state: str) -> str:
repository = json.loads(state).get("repository")
Expand Down
6 changes: 3 additions & 3 deletions bot/github/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Type

import sentry_sdk
from bottle import LocalRequest
from flask.wrappers import Request

from ..models.github import Commit, EventType, Issue, PullRequest, Ref, Repository, User
from ..models.github.event import GitHubEvent
Expand Down Expand Up @@ -69,7 +69,7 @@ def parse(self, event_type, raw_json) -> GitHubEvent | None:

return None

def verify(self, request: LocalRequest) -> tuple[bool, str]:
def verify(self, request: Request) -> tuple[bool, str]:
"""
Verifies incoming GitHub event.
Expand All @@ -91,7 +91,7 @@ def verify(self, request: LocalRequest) -> tuple[bool, str]:
expected_digest = headers["X-Hub-Signature-256"].split('=', 1)[-1]
digest = hmac.new(
secret.encode(),
request.body.getvalue(),
request.get_data(),
hashlib.sha256,
).hexdigest()
is_valid = hmac.compare_digest(expected_digest, digest)
Expand Down
11 changes: 5 additions & 6 deletions bot/slack/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
import hmac
import time
import urllib.parse
from io import BytesIO
from json import dumps as json_dumps
from typing import Any

from bottle import MultiDict, WSGIHeaderDict
from sentry_sdk import capture_message
from slack.errors import SlackApiError
from werkzeug.datastructures import Headers, ImmutableMultiDict

from ..models.github import EventType, convert_keywords_to_events
from ..utils.json import JSON
Expand Down Expand Up @@ -44,8 +43,8 @@ def __init__(

def verify(
self,
body: BytesIO,
headers: WSGIHeaderDict,
body: bytes,
headers: Headers,
) -> tuple[bool, str]:
"""
Checks validity of incoming Slack request.
Expand All @@ -66,7 +65,7 @@ def verify(
return False, "Request is too old"

expected_digest = headers["X-Slack-Signature"].split('=', 1)[-1]
sig_basestring = ('v0:' + timestamp + ':').encode() + body.getvalue()
sig_basestring = ('v0:' + timestamp + ':').encode() + body
digest = hmac.new(self.secret, sig_basestring,
hashlib.sha256).hexdigest()
is_valid = hmac.compare_digest(expected_digest, digest)
Expand All @@ -76,7 +75,7 @@ def verify(

return True, "Request is secure and valid"

def run(self, raw_json: MultiDict) -> dict[str, Any] | None:
def run(self, raw_json: ImmutableMultiDict) -> dict[str, Any] | None:
"""
Runs Slack slash commands sent to the bot.
:param raw_json: Slash command data sent by Slack.
Expand Down
10 changes: 5 additions & 5 deletions bot/utils/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Any

from bottle import MultiDict
from werkzeug.datastructures import ImmutableMultiDict


class JSON:
Expand Down Expand Up @@ -40,10 +40,10 @@ def get(k):
return keys[0].upper()

@staticmethod
def from_multi_dict(multi_dict: MultiDict):
def from_multi_dict(multi_dict: ImmutableMultiDict):
"""
Converts `bottle.MultiDict` to `JSON`.
:param multi_dict: Incoming `MultiDict`.
:return: `JSON` object containing the data from the `MultiDict`.
Converts `werkzeug.datastructures.ImmutableMultiDict` to `JSON`.
:param multi_dict: Incoming `ImmutableMultiDict`.
:return: `JSON` object containing the data from the `ImmutableMultiDict`.
"""
return JSON({key: multi_dict[key] for key in multi_dict.keys()})
6 changes: 6 additions & 0 deletions bot/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def test_get():
"""
First test endpoint.
:return: Plaintext confirming server status.
"""
return "This server is running!"
67 changes: 23 additions & 44 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Execution entrypoint for the project.
Sets up a `bottle` server with three endpoints: "/", "/github/events" and "/slack/commands".
Sets up a `Flask` server with three endpoints: "/", "/github/events" and "/slack/commands".
"/" is used for testing and status checks.
Expand All @@ -17,45 +17,23 @@
from typing import Any, Optional, Union

import sentry_sdk
from bottle import get, post, request
from bottle import response as http_response
from bottle import run
from dotenv import load_dotenv
from sentry_sdk.integrations.bottle import BottleIntegration
from flask import Flask, make_response, request
from sentry_sdk.integrations.flask import FlaskIntegration

from bot import views
from bot.github import GitHubApp
from bot.models.github.event import GitHubEvent
from bot.slack import SlackBot
from bot.slack.templates import error_message
from bot.utils.log import Logger

app = Flask(__name__)

@get("/")
def test_get() -> str:
"""
First test endpoint.
:return: Plaintext confirming server status.
"""
return "This server is running!"
app.add_url_rule("/", view_func=views.test_get)


@post("/")
def test_post() -> str:
"""
Second test endpoint.
:return: Status confirmation plaintext containing name supplied in request body.
"""
try:
name: str = request.json["name"]
except KeyError:
name: str = "(empty JSON)"
except TypeError:
name: str = "(invalid JSON)"
return (f"This server is working, and to prove it to you, "
f"I'll guess your name!\nYour name is... {name}!")


@post("/github/events")
@app.route("/github/events", methods=['POST'])
def manage_github_events():
"""
Uses `GitHubApp` to verify, parse and cast the payload into a `GitHubEvent`.
Expand All @@ -64,8 +42,7 @@ def manage_github_events():

is_valid_request, message = github_app.verify(request)
if not is_valid_request:
http_response.status = "400 Bad Request"
return message
return make_response(message, 400)

event: Optional[GitHubEvent] = github_app.parse(
event_type=request.headers["X-GitHub-Event"],
Expand All @@ -74,9 +51,12 @@ def manage_github_events():

if event is not None:
slack_bot.inform(event)
return "Informed appropriate channels"

return "Unrecognized Event"


@post("/slack/commands")
@app.route("/slack/commands", methods=['POST'])
def manage_slack_commands() -> Union[dict, str, None]:
"""
Uses a `SlackBot` instance to run the slash command triggered by the user.
Expand All @@ -85,41 +65,40 @@ def manage_slack_commands() -> Union[dict, str, None]:
"""

is_valid_request, message = slack_bot.verify(
body=request.body,
body=request.get_data(),
headers=request.headers,
)
if not is_valid_request:
return error_message(f"⚠️ Couldn't fulfill your request: {message}")

# Unlike GitHub webhooks, Slack does not send the data in `requests.json`.
# Instead, the data is passed in `request.forms`.
response: dict[str, Any] | None = slack_bot.run(raw_json=request.forms)
# Instead, the data is passed in `request.form`.
response: dict[str, Any] | None = slack_bot.run(raw_json=request.form)
return response


@get("/github/auth")
@app.route("/github/auth")
def initiate_auth():
github_app.redirect_to_oauth_flow(request.params.get("state"))
return github_app.redirect_to_oauth_flow(request.args.get("state"))


@get("/github/auth/redirect")
@app.route("/github/auth/redirect")
def complete_auth():
return github_app.set_up_webhooks(
code=request.query.get("code"),
state=request.params.get("state"),
code=request.args.get("code"),
state=request.args.get("state"),
)


if __name__ == "__main__":
load_dotenv(Path(".") / ".env")

debug = os.environ["DEBUG"] == "1"
port = int(os.environ.get("CONTAINER_PORT", 5000))
debug = os.environ["FLASK_DEBUG"] == "True"

if (not debug) and ("SENTRY_DSN" in os.environ):
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
integrations=[BottleIntegration()],
integrations=[FlaskIntegration()],
)

slack_bot = SlackBot(
Expand All @@ -136,4 +115,4 @@ def complete_auth():
client_secret=os.environ["GITHUB_APP_CLIENT_SECRET"],
)

run(host="", port=port, debug=debug)
app.run(port=int(os.environ.get("CONTAINER_PORT", 5000)))
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
bottle==0.12.23
Flask==2.2.2
peewee==3.15.4
python-dotenv==0.21.0
requests~=2.28.1
sentry-sdk[bottle]==1.10.1
sentry-sdk[flask]==1.12.1
slackclient==2.9.4
Werkzeug~=2.2.2
3 changes: 2 additions & 1 deletion samples/.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
BASE_URL=subdomain.domain.tld/path1/path2
CONTAINER_PORT=5000
DEBUG=1
FLASK_APP=main
FLASK_DEBUG=True
GITHUB_APP_CLIENT_ID=0123456789abcdefghij
GITHUB_APP_CLIENT_SECRET=e2fbe2fbe2fbe2fbe2fbe2e2fbe2fbe2e2fbe2fb
GITHUB_WEBHOOK_SECRET=3dbd2c253813c65b296b7acf67470b7e7bc116e3
Expand Down
3 changes: 2 additions & 1 deletion samples/.env.dev
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
SLACK_APP_ID=A0101010101
BASE_URL=blah-111-22-333-444.ngrok.io
CONTAINER_PORT=5000
DEBUG=1
FLASK_APP=main
FLASK_DEBUG=True
GITHUB_APP_CLIENT_ID=0123456789abcdefghij
GITHUB_APP_CLIENT_SECRET=e2fbe2fbe2fbe2fbe2fbe2e2fbe2fbe2e2fbe2fb
GITHUB_WEBHOOK_SECRET=3dbd2c253813c65b296b7acf67470b7e7bc116e3
Expand Down
16 changes: 8 additions & 8 deletions tests/slack/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from unittest import skip
from unittest.mock import patch

from bottle import MultiDict
from werkzeug.datastructures import ImmutableMultiDict

from bot.models.github import convert_keywords_to_events
from bot.models.slack import Channel
Expand Down Expand Up @@ -35,7 +35,7 @@ def setUp(self):
self.runner.storage = MockSubscriptionStorage()

def test_run_calls_subscribe(self):
raw_json = MultiDict(self.data["run|calls_subscribe"][0])
raw_json = ImmutableMultiDict(self.data["run|calls_subscribe"][0])

with patch.object(self.logger, "log_command") as log_command:
with patch.object(
Expand All @@ -53,7 +53,7 @@ def test_run_calls_subscribe(self):

@skip('This test is being skipped for the current PR')
def test_run_calls_unsubscribe(self):
raw_json = MultiDict(self.data["run|calls_unsubscribe"][0])
raw_json = ImmutableMultiDict(self.data["run|calls_unsubscribe"][0])
with patch.object(self.logger, "log_command") as mock_logger:
with patch.object(self.runner,
"run_unsubscribe_command") as mock_function:
Expand All @@ -67,7 +67,7 @@ def test_run_calls_unsubscribe(self):

@skip('This test is being skipped for the current PR')
def test_run_calls_list(self):
raw_json = MultiDict(self.data["run|calls_list"][0])
raw_json = ImmutableMultiDict(self.data["run|calls_list"][0])
with patch.object(self.logger, "log_command") as mock_logger:
with patch.object(self.runner,
"run_list_command") as mock_function:
Expand All @@ -78,7 +78,7 @@ def test_run_calls_list(self):

@skip('This test is being skipped for the current PR')
def test_run_calls_help(self):
raw_json = MultiDict(self.data["run|calls_help"][0])
raw_json = ImmutableMultiDict(self.data["run|calls_help"][0])
with patch.object(self.logger, "log_command") as mock_logger:
with patch.object(self.runner,
"run_help_command") as mock_function:
Expand All @@ -90,13 +90,13 @@ def test_run_calls_help(self):
def test_run_doesnt_call(self):
with patch.object(self.logger, "log_command") as mock_logger:
# Wrong command
raw_json = MultiDict(self.data["run|doesnt_call"][0])
raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][0])
self.assertIsNone(self.runner.run(raw_json))

# No args for subscribe or unsubscribe
raw_json = MultiDict(self.data["run|doesnt_call"][1])
raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][1])
self.assertIsNone(self.runner.run(raw_json))
raw_json = MultiDict(self.data["run|doesnt_call"][2])
raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][2])
self.assertIsNone(self.runner.run(raw_json))
mock_logger.assert_not_called()

Expand Down
4 changes: 2 additions & 2 deletions tests/utils/test_json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest

from bottle import MultiDict
from werkzeug.datastructures import ImmutableMultiDict

from bot.utils.json import JSON

Expand Down Expand Up @@ -32,7 +32,7 @@ def test_getitem_multiple_not_found(self):
self.assertEqual("NAME", json["name", "login"])

def test_from_multi_dict(self):
multi_dict = MultiDict({
multi_dict = ImmutableMultiDict({
"name": "exampleuser",
"login": "example_user"
})
Expand Down

0 comments on commit decea76

Please sign in to comment.