college-bot-v1/web/server.py

283 lines
9.1 KiB
Python
Raw Normal View History

import ipaddress
2023-04-09 21:22:55 +01:00
import sys
2023-02-22 01:33:30 +00:00
import discord
import os
import httpx
from pathlib import Path
2023-02-22 15:17:53 +00:00
from datetime import datetime, timezone
2023-02-22 01:33:30 +00:00
from hashlib import sha512
2023-02-22 15:17:53 +00:00
2023-02-22 01:33:30 +00:00
from fastapi import FastAPI, HTTPException, Request
2023-02-23 23:05:50 +00:00
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse
2023-04-09 21:22:55 +01:00
from http import HTTPStatus
2023-02-22 15:17:53 +00:00
from utils import Student, get_or_none, VerifyCode, console, BannedStudentID
from config import guilds
2023-02-22 01:33:30 +00:00
SF_ROOT = Path(__file__).parent / "static"
if SF_ROOT.exists() and SF_ROOT.is_dir():
from fastapi.staticfiles import StaticFiles
else:
StaticFiles = None
2023-02-22 01:33:30 +00:00
try:
from config import OAUTH_ID, OAUTH_SECRET, OAUTH_REDIRECT_URI
except ImportError:
OAUTH_ID = OAUTH_SECRET = OAUTH_REDIRECT_URI = None
try:
from config import WEB_ROOT_PATH
except ImportError:
WEB_ROOT_PATH = ""
2023-02-23 11:19:56 +00:00
GENERAL = "https://ptb.discord.com/channels/994710566612500550/1018915342317277215/"
2023-02-22 01:33:30 +00:00
OAUTH_ENABLED = OAUTH_ID and OAUTH_SECRET and OAUTH_REDIRECT_URI
app = FastAPI(root_path=WEB_ROOT_PATH)
2023-02-22 01:33:30 +00:00
app.state.bot = None
2023-04-09 21:13:07 +01:00
app.state.states = {}
2023-02-22 01:33:30 +00:00
app.state.http = httpx.Client()
if StaticFiles:
app.mount("/static", StaticFiles(directory=SF_ROOT), name="static")
2023-02-23 10:29:30 +00:00
try:
from utils.client import bot
app.state.bot = bot
except ImportError:
bot = None
2023-02-22 01:33:30 +00:00
@app.middleware("http")
async def check_bot_instanced(request, call_next):
if not request.app.state.bot:
return JSONResponse(
status_code=503,
2023-02-23 23:05:50 +00:00
content={"message": "Not ready."},
headers={
"Retry-After": "10"
}
2023-02-22 01:33:30 +00:00
)
return await call_next(request)
@app.get("/ping")
def ping():
2023-02-23 10:29:30 +00:00
bot_started = datetime.now(tz=timezone.utc) - app.state.bot.started_at
2023-02-22 01:33:30 +00:00
return {
"ping": "pong",
"online": app.state.bot.is_ready(),
2023-02-23 10:29:30 +00:00
"latency": max(round(app.state.bot.latency, 2), 0.01),
"uptime": max(round(bot_started.total_seconds(), 2), 1)
2023-02-22 01:33:30 +00:00
}
2023-02-22 15:17:53 +00:00
2023-02-22 01:33:30 +00:00
@app.get("/auth")
async def authenticate(req: Request, code: str = None, state: str = None):
2023-03-16 22:45:04 +00:00
"""Begins Oauth flow (browser only)"""
2023-02-23 11:19:56 +00:00
if not OAUTH_ENABLED:
raise HTTPException(
2023-02-23 23:05:50 +00:00
501,
2023-02-23 11:19:56 +00:00
"OAuth is not enabled."
)
2023-02-22 01:33:30 +00:00
if not (code and state) or state not in app.state.states:
2023-04-09 21:13:07 +01:00
value = os.urandom(4).hex()
if value in app.state.states:
2023-04-09 21:22:55 +01:00
print("Generated a state that already exists. Cleaning up", file=sys.stderr)
2023-04-09 21:13:07 +01:00
# remove any states older than 5 minutes
2023-04-09 21:49:03 +01:00
removed = 0
2023-04-09 21:13:07 +01:00
for _value in list(app.state.states):
if (datetime.now() - app.state.states[_value]).total_seconds() > 300:
del app.state.states[_value]
2023-04-09 21:49:03 +01:00
removed += 1
2023-04-09 22:04:05 +01:00
value = os.urandom(4).hex()
2023-04-09 21:49:03 +01:00
print(f"Removed {removed} states.", file=sys.stderr)
2023-04-09 21:13:07 +01:00
if value in app.state.states:
2023-04-09 21:55:54 +01:00
print("Critical: Generated a state that already exists and could not free any slots.", file=sys.stderr)
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
"Could not generate a state token (state container full, potential (D)DOS attack?). "
"Please try again later.",
# Saying a suspected DDOS makes sense, there are 4,294,967,296 possible states, the likelyhood of a
# collision is 1 in 4,294,967,296.
headers={
"Retry-After": "300"
}
)
2023-04-09 21:13:07 +01:00
app.state.states[value] = datetime.now()
2023-02-22 01:33:30 +00:00
return RedirectResponse(
discord.utils.oauth_url(
OAUTH_ID,
redirect_uri=OAUTH_REDIRECT_URI,
2023-05-16 15:12:59 +01:00
scopes=('identify', "connections", "guilds", "email")
2023-02-23 11:19:56 +00:00
) + f"&state={value}&prompt=none",
2023-04-09 21:22:55 +01:00
status_code=HTTPStatus.TEMPORARY_REDIRECT,
2023-02-23 23:05:50 +00:00
headers={
"Cache-Control": "no-store, no-cache"
}
2023-02-22 01:33:30 +00:00
)
else:
2023-04-09 21:13:07 +01:00
app.state.states.pop(state)
2023-02-22 01:33:30 +00:00
# First, we need to do the auth code flow
response = app.state.http.post(
"https://discord.com/api/oauth2/token",
data={
"client_id": OAUTH_ID,
"client_secret": OAUTH_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": OAUTH_REDIRECT_URI,
}
)
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=response.text
)
data = response.json()
access_token = data["access_token"]
# Now we can generate a token
token = sha512(access_token.encode()).hexdigest()
# Now we can get the user's info
response = app.state.http.get(
"https://discord.com/api/users/@me",
headers={
"Authorization": "Bearer " + data["access_token"]
}
)
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=response.text
)
user = response.json()
# Now we need to fetch the student from the database
2023-05-16 15:12:59 +01:00
student = await get_or_none(AccessTokens, user_id=user["id"])
2023-02-22 01:33:30 +00:00
if not student:
2023-05-16 15:12:59 +01:00
student = await AccessTokens.objects.create(
user_id=user["id"],
access_token=access_token
2023-02-22 01:33:30 +00:00
)
2023-02-23 14:33:50 +00:00
# Now send a request to https://ip-api.com/json/{ip}?fields=status,city,zip,lat,lon,isp,query
_host = ipaddress.ip_address(req.client.host)
if not any((_host.is_loopback, _host.is_private, _host.is_reserved, _host.is_unspecified)):
2023-02-23 11:19:56 +00:00
response = app.state.http.get(
2023-02-23 14:42:03 +00:00
f"http://ip-api.com/json/{req.client.host}?fields=status,city,zip,lat,lon,isp,query,proxy,hosting"
2023-02-22 01:33:30 +00:00
)
2023-02-23 11:19:56 +00:00
if response.status_code != 200:
raise HTTPException(
status_code=response.status_code,
detail=response.text
)
data = response.json()
if data["status"] != "success":
raise HTTPException(
2023-04-09 21:22:55 +01:00
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
2023-02-23 11:19:56 +00:00
detail=f"Failed to get IP data for {req.client.host}: {data}."
)
else:
data = None
2023-02-22 01:33:30 +00:00
# Now we can update the student entry with this data
await student.update(ip_info=data, access_token_hash=token)
2023-02-23 23:05:50 +00:00
document = \
f"""
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<script>
window.location.href = "{GENERAL}";
</script>
<noscript>
<meta http-equiv="refresh" content="0; url={GENERAL}" />
</noscript>
<p>Redirecting you to the general channel...</p>
<i><a href='{GENERAL}' rel='noopener'>Click here if you are not redirected.</a></i>
</body>
</html>
"""
2023-02-22 01:33:30 +00:00
# And set it as a cookie
2023-02-23 23:05:50 +00:00
response = HTMLResponse(
document,
status_code=200,
2023-02-22 01:33:30 +00:00
headers={
2023-02-23 23:05:50 +00:00
"Location": GENERAL,
2023-02-22 15:17:53 +00:00
"Cache-Control": "max-age=604800"
2023-02-22 01:33:30 +00:00
}
)
2023-02-22 15:17:53 +00:00
# set the cookie for at most 604800 seconds - expire after that
2023-02-22 01:33:30 +00:00
response.set_cookie(
"token",
token,
2023-02-22 15:17:53 +00:00
max_age=604800,
samesite="strict",
2023-02-22 01:33:30 +00:00
httponly=True,
)
return response
2023-02-22 15:17:53 +00:00
@app.get("/verify/{code}")
async def verify(code: str):
guild = app.state.bot.get_guild(guilds[0])
if not guild:
raise HTTPException(
status_code=503,
detail="Not ready."
)
# First, we need to fetch the code from the database
verify_code = await get_or_none(VerifyCode, code=code)
if not verify_code:
raise HTTPException(
status_code=404,
detail="Code not found."
)
2023-02-23 11:08:57 +00:00
2023-02-22 15:17:53 +00:00
# Now we need to fetch the student from the database
student = await get_or_none(Student, user_id=verify_code.bind)
if student:
raise HTTPException(
status_code=400,
detail="Already verified."
)
ban = await get_or_none(BannedStudentID, student_id=verify_code.student_id)
if ban is not None:
return await guild.kick(
reason=f"Attempted to verify with banned student ID {ban.student_id}"
f" (originally associated with account {ban.associated_account})"
)
await Student.objects.create(
id=verify_code.student_id, user_id=verify_code.bind, name=verify_code.name
)
await verify_code.delete()
role = discord.utils.find(lambda r: r.name.lower() == "verified", guild.roles)
member = await guild.fetch_member(verify_code.bind)
if role and role < guild.me.top_role:
await member.add_roles(role, reason="Verified")
try:
await member.edit(nick=f"{verify_code.name}", reason="Verified")
except discord.HTTPException:
pass
# And delete the code
await verify_code.delete()
2023-02-23 11:08:57 +00:00
console.log(f"[green]{verify_code.bind} verified ({verify_code.bind}/{verify_code.student_id})")
2023-02-22 15:17:53 +00:00
2023-02-23 11:08:57 +00:00
return RedirectResponse(
2023-02-23 11:19:56 +00:00
GENERAL,
2023-02-23 11:08:57 +00:00
status_code=308
)