college-bot-v1/web/server.py

403 lines
15 KiB
Python
Raw Normal View History

2023-06-29 12:59:15 +01:00
import asyncio
import ipaddress
2023-12-05 16:43:42 +00:00
import logging
2023-11-04 17:18:39 +00:00
import os
2023-07-12 18:32:40 +01:00
import textwrap
import secrets
2023-12-05 21:12:33 +00:00
from asyncio import Lock
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-04-09 21:22:55 +01:00
from http import HTTPStatus
2023-11-04 17:18:39 +00:00
from pathlib import Path
2024-02-19 10:26:47 +00:00
from typing import Optional
2023-06-29 12:54:47 +01:00
2023-11-04 17:18:39 +00:00
import discord
import httpx
2024-02-19 10:26:47 +00:00
from fastapi import FastAPI, Header, HTTPException, Request, status
2023-12-05 21:12:33 +00:00
from fastapi import WebSocketException as _WSException
2023-11-04 17:18:39 +00:00
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
2023-06-29 12:54:47 +01:00
from starlette.websockets import WebSocket, WebSocketDisconnect
2023-12-05 21:12:33 +00:00
from websockets.exceptions import WebSocketException
2023-06-29 12:54:47 +01:00
2023-12-05 21:12:33 +00:00
from config import guilds
from utils import BannedStudentID, Student, VerifyCode, console, get_or_none, BridgeBind
from utils.db import AccessTokens
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:
2023-11-04 17:18:39 +00:00
from config import OAUTH_ID, OAUTH_REDIRECT_URI, OAUTH_SECRET
2023-02-22 01:33:30 +00:00
except ImportError:
OAUTH_ID = OAUTH_SECRET = OAUTH_REDIRECT_URI = None
try:
from config import WEB_ROOT_PATH
except ImportError:
WEB_ROOT_PATH = ""
2023-12-05 16:43:42 +00:00
log = logging.getLogger("jimmy.api")
GENERAL = "https://discord.com/channels/994710566612500550/"
2023-02-23 11:19:56 +00:00
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 = {}
app.state.binds = {}
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
2023-11-04 17:18:39 +00:00
2023-02-23 10:29:30 +00:00
app.state.bot = bot
except ImportError:
bot = None
app.state.last_sender = None
2023-07-24 13:57:48 +01:00
app.state.last_sender_ts = datetime.utcnow()
2023-11-07 13:43:39 +00:00
app.state.ws_connected = Lock()
2023-02-23 10:29:30 +00:00
2023-02-22 01:33:30 +00:00
2024-02-19 10:26:47 +00:00
async def get_access_token(code: str):
response = app.state.http.post(
"https://discord.com/api/oauth2/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": OAUTH_REDIRECT_URI,
},
2024-02-19 11:10:54 +00:00
headers={"Content-Type": "application/x-www-form-urlencoded"},
auth=(OAUTH_ID, OAUTH_SECRET)
2024-02-19 10:26:47 +00:00
)
response.raise_for_status()
return response.json()
async def get_authorised_user(access_token: str):
response = app.state.http.get(
"https://discord.com/api/users/@me",
headers={"Authorization": "Bearer " + access_token}
)
response.raise_for_status()
return response.json()
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:
2023-11-04 17:18:39 +00:00
return JSONResponse(status_code=503, 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 {
2023-11-04 17:18:39 +00:00
"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),
2023-11-04 17:18:39 +00:00
"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:
2023-11-04 17:18:39 +00:00
raise HTTPException(501, "OAuth is not enabled.")
2023-02-23 11:19:56 +00:00
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-12-05 16:43:42 +00:00
log.warning("Generated a state that already exists. Cleaning up")
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-12-05 16:43:42 +00:00
log.warning(f"Removed {removed} old states.")
2023-04-09 21:13:07 +01:00
if value in app.state.states:
2023-12-05 16:43:42 +00:00
log.critical("Generated a state that already exists and could not free any slots.")
2023-04-09 21:55:54 +01:00
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.
2023-12-05 16:43:42 +00:00
headers={"Retry-After": "60"},
2023-04-09 21:55:54 +01:00
)
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(
2023-11-04 17:18:39 +00:00
OAUTH_ID, redirect_uri=OAUTH_REDIRECT_URI, scopes=("identify", "connections", "guilds", "email")
2023-12-05 21:12:33 +00:00
)
+ f"&state={value}&prompt=none",
2023-04-09 21:22:55 +01:00
status_code=HTTPStatus.TEMPORARY_REDIRECT,
2023-11-04 17:18:39 +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
2024-02-19 10:26:47 +00:00
data = await get_access_token(code)
2023-02-22 01:33:30 +00:00
access_token = data["access_token"]
2023-11-04 17:18:39 +00:00
2023-02-22 01:33:30 +00:00
# Now we can generate a token
token = sha512(access_token.encode()).hexdigest()
# Now we can get the user's info
2024-02-19 10:26:47 +00:00
user = await get_authorised_user(access_token)
2023-02-22 01:33:30 +00:00
# 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-11-04 17:18:39 +00:00
student = await AccessTokens.objects.create(user_id=user["id"], access_token=access_token)
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:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=response.status_code, detail=response.text)
2023-02-23 11:19:56 +00:00
data = response.json()
if data["status"] != "success":
raise HTTPException(
2023-04-09 21:22:55 +01:00
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
2023-11-04 17:18:39 +00:00
detail=f"Failed to get IP data for {req.client.host}: {data}.",
2023-02-23 11:19:56 +00:00
)
else:
data = None
2023-11-04 17:18:39 +00:00
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-11-04 17:18:39 +00:00
document = f"""
2023-02-23 23:05:50 +00:00
<!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(
2023-11-04 17:18:39 +00:00
document, status_code=200, headers={"Location": GENERAL, "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:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=503, detail="Not ready.")
2023-02-22 15:17:53 +00:00
# First, we need to fetch the code from the database
verify_code = await get_or_none(VerifyCode, code=code)
if not verify_code:
2023-11-04 17:18:39 +00:00
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:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=400, detail="Already verified.")
2023-02-22 15:17:53 +00:00
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}"
2023-11-04 17:18:39 +00:00
f" (originally associated with account {ban.associated_account})"
2023-02-22 15:17:53 +00:00
)
2023-11-04 17:18:39 +00:00
await Student.objects.create(id=verify_code.student_id, user_id=verify_code.bind, name=verify_code.name)
2023-02-22 15:17:53 +00:00
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-12-05 16:43:42 +00:00
log.info(f"[green]{verify_code.bind} verified ({verify_code.bind}/{verify_code.student_id})")
2023-02-22 15:17:53 +00:00
2023-11-04 17:18:39 +00:00
return RedirectResponse(GENERAL, status_code=308)
2023-06-28 22:19:40 +01:00
2023-07-12 18:32:40 +01:00
@app.post("/bridge", include_in_schema=False, status_code=201)
2023-06-28 22:19:40 +01:00
async def bridge(req: Request):
2023-07-24 13:57:48 +01:00
now = datetime.utcnow()
ts_diff = (now - app.state.last_sender_ts).total_seconds()
2023-07-12 18:32:40 +01:00
from discord.ext.commands import Paginator
2023-11-04 17:18:39 +00:00
2023-06-28 22:19:40 +01:00
body = await req.json()
if body["secret"] != app.state.bot.http.token:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=401, detail="Invalid secret.")
2023-06-28 22:19:40 +01:00
2023-07-12 18:32:40 +01:00
channel = app.state.bot.get_channel(1032974266527907901) # type: discord.TextChannel | None
2023-06-28 22:19:40 +01:00
if not channel:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=404, detail="Channel does not exist.")
2023-06-28 22:19:40 +01:00
if len(body["message"]) > 4000:
2023-11-04 17:18:39 +00:00
raise HTTPException(status_code=400, detail="Message too long.")
2023-07-12 18:32:40 +01:00
paginator = Paginator(prefix="", suffix="", max_size=1990)
for line in body["message"].splitlines():
try:
paginator.add_line(line)
except ValueError:
paginator.add_line(textwrap.shorten(line, width=1900, placeholder="<...>"))
if len(paginator.pages) > 1:
msg = None
2023-07-24 13:57:48 +01:00
if app.state.last_sender != body["sender"] or ts_diff >= 600:
2023-11-04 17:18:39 +00:00
msg = await channel.send(f"**{body['sender']}**:")
2023-07-12 18:32:40 +01:00
m = len(paginator.pages)
for n, page in enumerate(paginator.pages, 1):
await channel.send(
f"[{n}/{m}]\n>>> {page}",
allowed_mentions=discord.AllowedMentions.none(),
reference=msg,
silent=True,
2023-11-04 17:18:39 +00:00
suppress=n != m,
2023-07-12 18:32:40 +01:00
)
app.state.last_sender = body["sender"]
2023-07-12 18:32:40 +01:00
else:
content = f"**{body['sender']}**:\n>>> {body['message']}"
2023-07-24 13:57:48 +01:00
if app.state.last_sender == body["sender"] and ts_diff < 600:
content = f">>> {body['message']}"
2023-11-04 17:18:39 +00:00
await channel.send(content, allowed_mentions=discord.AllowedMentions.none(), silent=True, suppress=False)
app.state.last_sender = body["sender"]
2023-07-24 13:57:48 +01:00
app.state.last_sender_ts = now
2023-07-12 18:32:40 +01:00
return {"status": "ok", "pages": len(paginator.pages)}
2023-06-29 12:54:47 +01:00
2023-11-04 17:18:39 +00:00
@app.websocket("/bridge/recv")
2023-06-29 12:56:42 +01:00
async def bridge_recv(ws: WebSocket, secret: str = Header(None)):
2023-11-07 13:43:39 +00:00
await ws.accept()
2023-12-05 16:43:42 +00:00
log.info("Websocket %s:%s accepted.", ws.client.host, ws.client.port)
2023-06-29 12:54:47 +01:00
if secret != app.state.bot.http.token:
2023-12-05 16:43:42 +00:00
log.warning("Closing websocket %r, invalid secret.", ws.client.host)
2023-11-07 13:43:39 +00:00
raise _WSException(code=1008, reason="Invalid Secret")
if app.state.ws_connected.locked():
2023-12-05 16:43:42 +00:00
log.warning("Closing websocket %r, already connected." % ws)
2023-11-07 13:43:39 +00:00
raise _WSException(code=1008, reason="Already connected.")
2023-06-29 13:50:55 +01:00
queue: asyncio.Queue = app.state.bot.bridge_queue
2023-06-29 12:54:47 +01:00
2023-11-07 13:43:39 +00:00
async with app.state.ws_connected:
while True:
try:
2023-11-07 14:00:47 +00:00
await ws.send_json({"status": "ping"})
except (WebSocketDisconnect, WebSocketException):
2023-12-05 16:43:42 +00:00
log.info("Websocket %r disconnected.", ws)
2023-11-07 14:00:47 +00:00
break
try:
data = await asyncio.wait_for(queue.get(), timeout=5)
except asyncio.TimeoutError:
2023-11-07 13:43:39 +00:00
continue
try:
await ws.send_json(data)
2023-12-05 16:43:42 +00:00
log.debug("Sent data %r to websocket %r.", data, ws)
2023-11-07 13:43:39 +00:00
except (WebSocketDisconnect, WebSocketException):
2023-12-05 16:43:42 +00:00
log.info("Websocket %r disconnected." % ws)
2023-11-07 13:43:39 +00:00
break
finally:
queue.task_done()
@app.get("/bridge/bind/new")
2024-02-19 10:26:47 +00:00
async def bridge_bind_new(mx_id: str):
"""Begins a new bind session."""
existing: Optional[BridgeBind] = await get_or_none(BridgeBind, matrix_id=mx_id)
if existing:
raise HTTPException(409, "Account already bound")
if not OAUTH_ENABLED:
2024-02-19 12:04:09 +00:00
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE)
token = secrets.token_urlsafe()
app.state.binds[token] = mx_id
url = discord.utils.oauth_url(
2024-02-19 12:04:09 +00:00
OAUTH_ID, redirect_uri=OAUTH_REDIRECT_URI, scopes=("identify",)
2024-02-19 11:55:54 +00:00
) + f"&state={token}&prompt=none"
2024-02-19 10:26:47 +00:00
return {
"status": "pending",
"url": url,
}
@app.get("/bridge/bind/callback")
async def bridge_bind_callback(code: str, state: str):
"""Finishes the bind."""
# Getting an entire access token seems like a waste, but oh well. Only need to do this once.
mx_id = app.state.binds.pop(state, None)
if not mx_id:
2024-02-19 11:12:14 +00:00
raise HTTPException(status_code=400, detail="Invalid state")
2024-02-19 10:26:47 +00:00
data = await get_access_token(code)
access_token = data["access_token"]
user = await get_authorised_user(access_token)
user_id = int(user["id"])
await BridgeBind.objects.create(matrix_id=mx_id, user_id=user_id)
return JSONResponse({"matrix": mx_id, "discord": user_id}, 201)
@app.delete("/bridge/bind/{mx_id}")
async def bridge_bind_delete(mx_id: str, code: str = None, state: str = None):
"""Unbinds a matrix account."""
existing: Optional[BridgeBind] = await get_or_none(BridgeBind, matrix_id=mx_id)
if not existing:
raise HTTPException(404, "Not found")
if not (code and state) or state not in app.state.binds:
token = secrets.token_urlsafe()
app.state.binds[token] = mx_id
url = discord.utils.oauth_url(
2024-02-19 12:04:09 +00:00
OAUTH_ID, redirect_uri=OAUTH_REDIRECT_URI, scopes=("identify",)
) + f"&state={token}&prompt=none"
2024-02-19 10:26:47 +00:00
return JSONResponse({"status": "pending", "url": url})
else:
real_mx_id = app.state.binds.pop(state, None)
if real_mx_id != mx_id:
raise HTTPException(400, "Invalid state")
await existing.delete()
return JSONResponse({"status": "ok"}, 200)
2024-02-19 10:33:12 +00:00
@app.get("/bridge/bind/{mx_id}")
async def bridge_bind_fetch(mx_id: str):
"""Fetch the discord account associated with a matrix account."""
existing: Optional[BridgeBind] = await get_or_none(BridgeBind, matrix_id=mx_id)
if not existing:
raise HTTPException(404, "Not found")
return JSONResponse({"discord": existing.user_id}, 200)