diff --git a/onboard_live_backend/main.py b/onboard_live_backend/main.py index 6d4247f..1aa2f52 100644 --- a/onboard_live_backend/main.py +++ b/onboard_live_backend/main.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import json import os from contextlib import asynccontextmanager @@ -5,21 +7,16 @@ from random import choice from secrets import token_hex from typing import Dict, List -import fastapi import httpx +import uvicorn from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from dotenv import load_dotenv -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi_oauth2.claims import Claims -from fastapi_oauth2.client import OAuth2Client -from fastapi_oauth2.config import OAuth2Config from prisma import Prisma from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler from slack_bolt.async_app import AsyncAck, AsyncApp -from social_core.backends.slack import SlackOAuth2 -import uvicorn load_dotenv(dotenv_path="./.env") @@ -28,19 +25,27 @@ active_streams: List[Dict[str, str | bool]] = [] scheduler = AsyncIOScheduler() -oauth2_config = OAuth2Config( - allow_http=False, - jwt_secret=os.environ["JWT_SECRET"], - jwt_expires=os.environ["JWT_EXPIRES"], - jwt_algorithm=os.environ["JWT_ALGORITHM"], - clients=[ - OAuth2Client( - backend=SlackOAuth2, - client_id=os.environ["SLACK_TOKEN"], - client_secret=os.environ["SLACK_SIGNING_SECRET"], + +def verify_gh_signature(payload_body, secret_token, signature_header): + """Verify that the payload was sent from GitHub by validating SHA256. + + Raise and return 403 if not authorized. + + Args: + payload_body: original request body to verify (request.body()) + secret_token: GitHub app webhook token (WEBHOOK_SECRET) + signature_header: header received from GitHub (x-hub-signature-256) + """ + if not signature_header: + raise HTTPException( + status_code=403, detail="x-hub-signature-256 header is missing!" ) - ], -) + hash_object = hmac.new( + secret_token.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256 + ) + expected_signature = "sha256=" + hash_object.hexdigest() + if not hmac.compare_digest(expected_signature, signature_header): + raise HTTPException(status_code=403, detail="Request signatures didn't match!") async def update_active(): @@ -75,17 +80,13 @@ async def update_active(): ) new_stream = choice(active_streams) print(f"found new stream to make active: {new_stream}") - try: - await db.connect() - except Exception as e: - print(e) print(f"trying to find user associated with stream {active_stream['name']}") old_active_stream_user = await db.user.find_first(where={"id": (await db.stream.find_first(where={"key": str(active_stream["name"])})).user_id}) # type: ignore await bolt.client.chat_postMessage(channel="C07ERCGG989", text=f"Hey <@{old_active_stream_user.slack_id}>, you're no longer in focus!") # type: ignore active_stream = new_stream active_stream_user = await db.user.find_first(where={"id": (await db.stream.find_first(where={"key": str(active_stream["name"])})).user_id}) # type: ignore await bolt.client.chat_postMessage(channel="C07ERCGG989", text=f"Hey <@{active_stream_user.slack_id}>, you're in focus! Make sure to tell us what you're working on!") # type: ignore - await db.disconnect() + return True async def check_for_new(): @@ -124,19 +125,16 @@ async def lifespan(app: FastAPI): scheduler.start() scheduler.add_job(update_active, IntervalTrigger(seconds=5 * 60)) scheduler.add_job(check_for_new, IntervalTrigger(seconds=3)) - try: - await db.connect() - except Exception: - pass + await db.connect() async with httpx.AsyncClient() as client: for stream in await db.stream.find_many(): await client.post( "http://127.0.0.1:9997/v3/config/paths/add/" + stream.key, json={"name": stream.key}, ) - await db.disconnect() yield scheduler.shutdown() + await db.disconnect() api = FastAPI(lifespan=lifespan) # type: ignore @@ -157,11 +155,48 @@ bolt = AsyncApp( bolt_handler = AsyncSlackRequestHandler(bolt) +@api.post("/api/v1/github/pr_event") +async def pr_event(request: Request): + verify_gh_signature( + await request.body(), + os.environ["GH_HOOK_SECRET"], + request.headers.get("x-hub-signature-256"), + ) + body = json.loads(await request.body()) + if body["action"] == "labeled": + if body["label"]["id"] == 7336079497: + print("Added label has same id as OBL label!") + async with httpx.AsyncClient() as client: + db_pr = await db.pullrequest.create({"github_id": body["number"]}) + db_pr_token = db_pr.token + await client.post( + f"https://api.github.com/repos/hackclub/OnBoard/issues/{body["issue"]["number"]}/comments", + headers={ + "Authorization": f"token {os.environ['GH_TOKEN']}", + "Accept": "application/vnd.github.v3+json", + }, + json={ + "body": f"Hey, I'm Micha, a.k.a `@mra` on Slack! It looks like this is an OnBoard Live submission. If that sounds right, then go to the #onboard-live channel on Slack and send the message `/onboard-live-submit {db_pr_token}`. Doing that helps us link this pull request to your Slack account lets you select your sessions for review.\n###### If you have no clue what OnBoard Live is, please disregard this automated message!" + }, + ) + elif "created" in body and "comment" in body: + if body["comment"]["user"]["id"] == body["issue"]["user"]["id"]: + db_pr = await db.pullrequest.find_first(where={"github_id": body["issue"]["number"]}) + if db_pr: + if db_pr.possible_users: + for user in db_pr.possible_users: + if hashlib.sha256(bytes(f"{db_pr.secondary_token}+{user.slack_id}", encoding="utf-8")).hexdigest() in body["comment"]["body"]: + # Yay, the user who ran the Slack submit command is the same user who submitted the PR! + db_pr.user = user + break + else: + print("possible users was none") + return + + @api.get("/api/v1/stream_key/{stream_key}") async def get_stream_by_key(stream_key: str): - await db.connect() stream = await db.stream.find_first(where={"key": stream_key}) - await db.disconnect() return ( stream if stream else Response(status_code=404, content="404: Stream not found") ) @@ -220,10 +255,6 @@ async def deny(ack, body): @bolt.action("approve") async def approve(ack, body): await ack() - try: - await db.connect() - except Exception: - pass message = body["message"] applicant_slack_id = message["blocks"][len(message) - 3]["text"]["text"].split( ": " @@ -262,7 +293,6 @@ async def approve(ack, body): channel=sumbitter_convo["channel"]["id"], text=f"Welcome to OnBoard Live! Your stream key is {new_stream.key}. To use your stream key the easy way, go to . You can also use it in OBS with the server URL of rtmp://live.onboard.hackclub.com:1935", ) - await db.disconnect() @bolt.view("apply") @@ -287,9 +317,6 @@ async def handle_application_submission(ack, body): channel=sumbitter_convo["channel"]["id"], text=f"Your application has been submitted! We will review it shortly. Please do not send another application - If you haven't heard back in over 48 hours, or you forgot something in your application, please message <@{os.environ['ADMIN_SLACK_ID']}>! Here's a copy of your responses for your reference:\nSome info on your project(s): {body['view']['state']['values']['project-info']['project-info-body']['value']}\n{f'Please fill out ! We can only approve your application once this is done.' if not user_verified else ''}", ) - admin_convo = await bolt.client.conversations_open( - users=os.environ["ADMIN_SLACK_ID"], return_im=True - ) will_behave = True # boxes = body["view"]["state"]["values"]["kAgeY"]["checkboxes"]["selected_options"] # if len(boxes) == 1 and boxes[0]["value"] == "value-1": @@ -376,6 +403,29 @@ async def handle_application_submission(ack, body): ) +@bolt.command("/onboard-live-submit") +async def submit(ack: AsyncAck, command): + await ack() + user_id = command["user_id"] + channel_id = command["channel_id"] + text = command["text"] + db_pr = await db.pullrequest.find_first(where={"token": text}) + db_user = await db.user.find_first_or_raise(where={"slack_id": user_id}) + if db_pr is None: + await bolt.client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text="There doesn't seem to be a PR open with that token! If this seems like a mistake, please message <@U05C64XMMHV> about it!", + ) + return + await db.pullrequest.update(where={"id": db_pr.id}, data={"possible_users": {"set": [{"id": db_user.id}]}}) + await bolt.client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text=f"Please go to and add a comment containing the secret code `{hashlib.sha256(bytes(f"{db_pr.secondary_token}+{user_id}", encoding="utf-8")).hexdigest()}`. This helps us make sure this is your PR!", + ) + + @bolt.command("/onboard-live-apply") async def apply(ack: AsyncAck, command): await ack() diff --git a/onboard_live_backend/migrations/20240816163601_add_pr_model/migration.sql b/onboard_live_backend/migrations/20240816163601_add_pr_model/migration.sql new file mode 100644 index 0000000..28b85a1 --- /dev/null +++ b/onboard_live_backend/migrations/20240816163601_add_pr_model/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "PullRequest" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + CONSTRAINT "PullRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PullRequest_token_key" ON "PullRequest"("token"); diff --git a/onboard_live_backend/migrations/20240816164220_make_pr_model_optional/migration.sql b/onboard_live_backend/migrations/20240816164220_make_pr_model_optional/migration.sql new file mode 100644 index 0000000..e1751ed --- /dev/null +++ b/onboard_live_backend/migrations/20240816164220_make_pr_model_optional/migration.sql @@ -0,0 +1,15 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_PullRequest" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT, + "token" TEXT NOT NULL, + CONSTRAINT "PullRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_PullRequest" ("id", "token", "userId") SELECT "id", "token", "userId" FROM "PullRequest"; +DROP TABLE "PullRequest"; +ALTER TABLE "new_PullRequest" RENAME TO "PullRequest"; +CREATE UNIQUE INDEX "PullRequest_token_key" ON "PullRequest"("token"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/onboard_live_backend/migrations/20240816164920_add_pr_number_field/migration.sql b/onboard_live_backend/migrations/20240816164920_add_pr_number_field/migration.sql new file mode 100644 index 0000000..de12636 --- /dev/null +++ b/onboard_live_backend/migrations/20240816164920_add_pr_number_field/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - Added the required column `github_id` to the `PullRequest` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_PullRequest" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "github_id" INTEGER NOT NULL, + "userId" TEXT, + "token" TEXT NOT NULL, + CONSTRAINT "PullRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_PullRequest" ("id", "token", "userId") SELECT "id", "token", "userId" FROM "PullRequest"; +DROP TABLE "PullRequest"; +ALTER TABLE "new_PullRequest" RENAME TO "PullRequest"; +CREATE UNIQUE INDEX "PullRequest_github_id_key" ON "PullRequest"("github_id"); +CREATE UNIQUE INDEX "PullRequest_token_key" ON "PullRequest"("token"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/onboard_live_backend/migrations/20240817173150_add_sec_token/migration.sql b/onboard_live_backend/migrations/20240817173150_add_sec_token/migration.sql new file mode 100644 index 0000000..f9cdd01 --- /dev/null +++ b/onboard_live_backend/migrations/20240817173150_add_sec_token/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - The required column `secondary_token` was added to the `PullRequest` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_PullRequest" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "github_id" INTEGER NOT NULL, + "userId" TEXT, + "token" TEXT NOT NULL, + "secondary_token" TEXT NOT NULL, + CONSTRAINT "PullRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_PullRequest" ("github_id", "id", "token", "userId") SELECT "github_id", "id", "token", "userId" FROM "PullRequest"; +DROP TABLE "PullRequest"; +ALTER TABLE "new_PullRequest" RENAME TO "PullRequest"; +CREATE UNIQUE INDEX "PullRequest_github_id_key" ON "PullRequest"("github_id"); +CREATE UNIQUE INDEX "PullRequest_token_key" ON "PullRequest"("token"); +CREATE UNIQUE INDEX "PullRequest_secondary_token_key" ON "PullRequest"("secondary_token"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/onboard_live_backend/migrations/20240817193412_refactor_pull_user_relation/migration.sql b/onboard_live_backend/migrations/20240817193412_refactor_pull_user_relation/migration.sql new file mode 100644 index 0000000..b58ceeb --- /dev/null +++ b/onboard_live_backend/migrations/20240817193412_refactor_pull_user_relation/migration.sql @@ -0,0 +1,39 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `PullRequest` table. All the data in the column will be lost. + +*/ +-- CreateTable +CREATE TABLE "_PullRequestToPossibleUser" ( + "A" INTEGER NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_PullRequestToPossibleUser_A_fkey" FOREIGN KEY ("A") REFERENCES "PullRequest" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_PullRequestToPossibleUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_PullRequest" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "github_id" INTEGER NOT NULL, + "known_user_id" TEXT, + "token" TEXT NOT NULL, + "secondary_token" TEXT NOT NULL, + CONSTRAINT "PullRequest_known_user_id_fkey" FOREIGN KEY ("known_user_id") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_PullRequest" ("github_id", "id", "secondary_token", "token") SELECT "github_id", "id", "secondary_token", "token" FROM "PullRequest"; +DROP TABLE "PullRequest"; +ALTER TABLE "new_PullRequest" RENAME TO "PullRequest"; +CREATE UNIQUE INDEX "PullRequest_github_id_key" ON "PullRequest"("github_id"); +CREATE UNIQUE INDEX "PullRequest_token_key" ON "PullRequest"("token"); +CREATE UNIQUE INDEX "PullRequest_secondary_token_key" ON "PullRequest"("secondary_token"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "_PullRequestToPossibleUser_AB_unique" ON "_PullRequestToPossibleUser"("A", "B"); + +-- CreateIndex +CREATE INDEX "_PullRequestToPossibleUser_B_index" ON "_PullRequestToPossibleUser"("B"); diff --git a/onboard_live_backend/schema.prisma b/onboard_live_backend/schema.prisma index 178b024..f10598b 100644 --- a/onboard_live_backend/schema.prisma +++ b/onboard_live_backend/schema.prisma @@ -10,11 +10,13 @@ datasource db { } model User { - id String @id @default(cuid()) - created_at DateTime @default(now()) - slack_id String @unique - name String - stream Stream? + id String @id @default(cuid()) + created_at DateTime @default(now()) + slack_id String @unique + name String + stream Stream? + possible_pulls PullRequest[] @relation(name: "PullRequestToPossibleUser") // pull requests that this user has tried to claim via the /onboard-live-submit command on Slack + known_pulls PullRequest[] @relation(name: "PullRequestToKnownUser") // pull requests that have been verified to belong to this user } model Stream { @@ -26,3 +28,13 @@ model Stream { user_id String @unique user User @relation(fields: [user_id], references: [id]) } + +model PullRequest { + id Int @id @default(autoincrement()) + github_id Int @unique + user User? @relation(name: "PullRequestToKnownUser", fields: [known_user_id], references: [id]) + known_user_id String? + token String @unique @default(uuid()) + secondary_token String @unique @default(uuid()) + possible_users User[] @relation(name: "PullRequestToPossibleUser") +}