diff --git a/.gitignore b/.gitignore index 4356796..0199a21 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ venv domains.txt geckodriver.log targets.json +*.kdev4 \ No newline at end of file diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index 4316a9e..a4eba69 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -9,7 +9,10 @@ no-auth - + + + + diff --git a/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml b/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml index 36e4d02..69d87da 100644 --- a/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml +++ b/.idea/dataSources/28efee07-d306-4126-bf69-01008b4887e2.xml @@ -1316,305 +1316,339 @@
-
+
+
1
- -
-
- +
+
+
+ INTEGER|0s 1 1 - + CHAR(32)|0s 2 - + VARCHAR(2000)|0s 1 3 - + VARCHAR(4096)|0s 4 - + VARCHAR(4096)|0s 5 - + FLOAT|0s 1 6 - + FLOAT|0s 1 7 - + VARCHAR(7)|0s 1 8 - + JSON|0s 1 9 - + BOOLEAN|0s 1 10 - + BOOLEAN|0s 1 11 - + JSON|0s 1 12 - + created_by entry_id students - + entry_id 1 - + CHAR(32)|0s 1 1 - + VARCHAR(7)|0s 1 2 - + BIGINT|0s 1 3 - + FLOAT|0s 1 4 - + entry_id 1 1 - + student_id 1 1 - + entry_id 1 sqlite_autoindex_banned_1 - + student_id sqlite_autoindex_banned_2 - + INTEGER|0s 1 1 - + VARCHAR(64)|0s 1 2 - + BIGINT|0s 1 3 - + VARCHAR(7)|0s 1 4 - + VARCHAR(32)|0s 1 5 - + code 1 1 - + id 1 - + code sqlite_autoindex_codes_1 - - TEXT|0s - 1 - - - TEXT|0s - 2 - - - TEXT|0s - 3 - - - INT|0s - 4 - - - TEXT|0s - 5 - - + CHAR(32)|0s 1 1 - + BIGINT|0s 1 2 - - BIGINT|0s - 1 + + TEXT|0s 3 - - BIGINT|0s + + FLOAT|0s + 1 4 - + + FLOAT|0s + 5 + + entry_id 1 1 - - id - 1 - 1 - - + entry_id 1 - sqlite_autoindex_starboard_1 - - - id - sqlite_autoindex_starboard_2 + sqlite_autoindex_jimmy_bans_1 + + TEXT|0s + 1 + + + TEXT|0s + 2 + + + TEXT|0s + 3 + + + INT|0s + 4 + + + TEXT|0s + 5 + CHAR(32)|0s 1 1 - VARCHAR(7)|0s + BIGINT|0s 1 2 - + BIGINT|0s 1 3 - - VARCHAR(32)|0s - 1 + + BIGINT|0s 4 - + entry_id 1 1 - + id 1 1 - - user_id - 1 - 1 - - + entry_id 1 - sqlite_autoindex_students_1 + sqlite_autoindex_starboard_1 - + id - sqlite_autoindex_students_2 + sqlite_autoindex_starboard_2 - - user_id - sqlite_autoindex_students_3 - - + CHAR(32)|0s 1 1 - - VARCHAR(128)|0s + + VARCHAR(7)|0s 1 2 - - VARCHAR(128)|0s + + BIGINT|0s 1 3 - - BOOLEAN|0s + + VARCHAR(32)|0s 1 4 - - FLOAT|0s - 1 - 5 - - - INTEGER|0s - 6 - - - TEXT|0s - 7 - - - BOOLEAN|0s - 1 - 8 - - + entry_id 1 1 - + + id + 1 + 1 + + + user_id + 1 + 1 + + + entry_id + 1 + sqlite_autoindex_students_1 + + + id + sqlite_autoindex_students_2 + + + user_id + sqlite_autoindex_students_3 + + + CHAR(32)|0s + 1 + 1 + + + VARCHAR(128)|0s + 1 + 2 + + + VARCHAR(128)|0s + 1 + 3 + + + BOOLEAN|0s + 1 + 4 + + + FLOAT|0s + 1 + 5 + + + INTEGER|0s + 6 + + + TEXT|0s + 7 + + + BOOLEAN|0s + 1 + 8 + + + entry_id + 1 + 1 + + entry_id 1 sqlite_autoindex_uptime_1 diff --git a/config.example.py b/config.example.py index 4e1d5d3..cba7c31 100644 --- a/config.example.py +++ b/config.example.py @@ -13,6 +13,7 @@ import os import discord # The IDs of guilds the bot should be in; used to determine where to make slash commands +# If you put multiple IDs in here, the first one should be your "primary" server. guilds = [994710566612500550] # Email & email password for the email verification system @@ -46,7 +47,15 @@ HTTP_HOST = "127.0.0.1" HTTP_PORT = 3762 # You can also just fully turn the web server off -WEB_SERVER = False +WEB_SERVER = True # change this to False to disable it + +# Or change uvicorn settings (see: https://www.uvicorn.org/settings/) +# Note that passing `host` or `port` will raise an error, as those are configured above. +UVICORN_CONFIG = { + "log_level": "error", + "access_log": False, + "lifespan": "off" +} # Only change this if you want to test changes to the bot without sending too much traffic to discord. # Connect modes: diff --git a/main.py b/main.py index 2c24704..147f9b1 100644 --- a/main.py +++ b/main.py @@ -1,61 +1,11 @@ +import asyncio + import discord from discord.ext import commands -from asyncio import Lock import config from datetime import datetime, timezone, timedelta -from utils import registry, console, get_or_none, JimmyBans -from web.server import app -import uvicorn - - -intents = discord.Intents.default() -intents += discord.Intents.messages -intents += discord.Intents.message_content -intents += discord.Intents.members -intents += discord.Intents.presences - - -extensions = [ - "jishaku", - "cogs.verify", - "cogs.mod", - "cogs.events", - "cogs.assignments", - "cogs.timetable", - "cogs.other", - "cogs.starboard", - "cogs.uptime", -] - - -class Bot(commands.Bot): - def __init__(self): - super().__init__( - command_prefix=self.get_prefix, - debug_guilds=config.guilds, - allowed_mentions=discord.AllowedMentions.none(), - intents=intents, - ) - self.training_lock = Lock() - self.started_at = datetime.now(tz=timezone.utc) - self.bans = JimmyBans() - for ext in extensions: - try: - bot.load_extension(ext) - except discord.ExtensionFailed as e: - console.log(f"[red]Failed to load extension {ext}: {e}") - else: - console.log(f"Loaded extension [green]{ext}") - - app.state.bot = self - config = uvicorn.Config( - app, - port=3762 - ) - - -bot = Bot() -bot.loop.run_until_complete(registry.create_all()) +from utils import console, get_or_none, JimmyBans +from utils.client import bot @bot.listen() @@ -101,6 +51,9 @@ async def on_application_command(ctx: discord.ApplicationContext): @bot.event async def on_ready(): console.log("Logged in as", bot.user) + if getattr(config, "CONNECT_MODE", None) == 1: + console.log("Bot is now ready and exit target 1 is set, shutting down.") + await bot.close() @bot.slash_command() @@ -135,4 +88,36 @@ async def check_not_banned(ctx: discord.ApplicationContext | commands.Context): if __name__ == "__main__": console.log("Starting...") bot.started_at = discord.utils.utcnow() + + if getattr(config, "WEB_SERVER", True): + from web.server import app + import uvicorn + + http_config = uvicorn.Config( + app, + host=getattr(config, "HTTP_HOST", "127.0.0.1"), + port=getattr(config, "HTTP_PORT", 3762), + lifespan="off", + access_log=False, + **getattr(config, "UVICORN_CONFIG", {}) + ) + server = uvicorn.Server(http_config) + console.log("Starting web server...") + loop = bot.loop + http_server_task = loop.create_task(server.serve()) + bot.web = { + "server": server, + "config": http_config, + "task": http_server_task, + } + bot.run(config.token) + if hasattr(bot, "web"): + console.log("Cancelling web task...") + bot.web["task"].cancel() + console.log("Shutting down web server...") + try: + bot.web["task"].result() + except asyncio.CancelledError: + pass + console.log("Web server closed.") diff --git a/requirements.txt b/requirements.txt index 932f735..80bc4e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,3 @@ aiofiles==22.1.0 httpx==0.23.0 fastapi==0.92.0 uvicorn==0.20.0 -bcrypt==4.0.1 \ No newline at end of file diff --git a/utils/client.py b/utils/client.py new file mode 100644 index 0000000..533fe6a --- /dev/null +++ b/utils/client.py @@ -0,0 +1,64 @@ +import discord +import config +from asyncio import Lock +from discord.ext import commands +from datetime import datetime, timezone + + +__all__ = ("Bot", 'bot') + + +# noinspection PyAbstractClass +class Bot(commands.Bot): + def __init__(self, intents: discord.Intents, guilds: list[int], extensions: list[str]): + from .db import JimmyBans, registry + from .console import console + super().__init__( + command_prefix=self.get_prefix, + debug_guilds=guilds, + allowed_mentions=discord.AllowedMentions.none(), + intents=intents, + ) + self.loop.run_until_complete(registry.create_all()) + self.training_lock = Lock() + self.started_at = datetime.now(tz=timezone.utc) + self.bans = JimmyBans() + self.console = console + for ext in extensions: + try: + self.load_extension(ext) + except discord.ExtensionFailed as e: + console.log(f"[red]Failed to load extension {ext}: {e}") + if getattr(config, "dev", False): + console.print_exception() + else: + console.log(f"Loaded extension [green]{ext}") + + if getattr(config, "CONNECT_MODE", None) == 2: + async def connect(self, *, reconnect: bool = True) -> None: + self.console.log("Exit target 2 reached, shutting down (not connecting to discord).") + return + + +try: + from config import intents as _intents +except ImportError: + _intents = discord.Intents.all() + +try: + from config import extensions as _extensions +except ImportError: + _extensions = [ + "jishaku", + "cogs.verify", + "cogs.mod", + "cogs.events", + "cogs.assignments", + "cogs.timetable", + "cogs.other", + "cogs.starboard", + "cogs.uptime", + ] + + +bot = Bot(_intents, config.guilds, _extensions) diff --git a/utils/db.py b/utils/db.py index e34a7b9..52e1e1c 100644 --- a/utils/db.py +++ b/utils/db.py @@ -83,6 +83,9 @@ class Student(orm.Model): id: str user_id: int name: str + access_token: str | None + ip_info: dict | None + access_token_hash: str | None class BannedStudentID(orm.Model): diff --git a/web/server.py b/web/server.py index 7b56036..88cfbd1 100644 --- a/web/server.py +++ b/web/server.py @@ -1,11 +1,13 @@ import discord import os import httpx -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from hashlib import sha512 + from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse -from utils.db import Student, get_or_none +from utils import Student, get_or_none, VerifyCode, console, BannedStudentID +from config import guilds try: from config import OAUTH_ID, OAUTH_SECRET, OAUTH_REDIRECT_URI @@ -37,9 +39,10 @@ def ping(): "ping": "pong", "online": app.state.bot.is_ready(), "latency": app.state.bot.latency, - "uptime": bot_started + "uptime": bot_started.total_seconds() } + @app.get("/auth") async def authenticate(req: Request, code: str = None, state: str = None): if not (code and state) or state not in app.state.states: @@ -94,7 +97,7 @@ async def authenticate(req: Request, code: str = None, state: str = None): user = response.json() # Now we need to fetch the student from the database - student = await get_or_none(Student, discord_id=user["id"]) + student = await get_or_none(Student, user_id=user["id"]) if not student: raise HTTPException( status_code=404, @@ -125,15 +128,69 @@ async def authenticate(req: Request, code: str = None, state: str = None): "/", status_code=307, headers={ - "Cache-Control": "max-age=86400" + "Cache-Control": "max-age=604800" } ) - # set the cookie for at most 86400 seconds - expire after that + # set the cookie for at most 604800 seconds - expire after that response.set_cookie( "token", token, - max_age=86400, - same_site="strict", + max_age=604800, + samesite="strict", httponly=True, ) return response + + +@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." + ) + + # 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() + + console.log(f"[green]{verify_code.bind} verified ({verify_code.bing}/{verify_code.student_id})") + + return { + "message": "Successfully verified." + }