From 43aea09ba6dec2993fcde25ecb77d83fa82c6273 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 14:07:36 -0400 Subject: [PATCH 1/6] The big rewrite --- .gitignore | 1 + app/alembic.ini | 112 +++++ {forms => app/forms}/2.json | 0 {forms => app/forms}/edit.json | 0 app/index.py | 356 +++++++++++++++ app/migrations/README | 1 + app/migrations/__init__.py | 0 app/migrations/env.py | 93 ++++ app/migrations/script.py.mako | 27 ++ .../versions/3337a749d431_initial_commit.py | 65 +++ {models => app/models}/info.py | 2 +- app/models/user.py | 171 +++++++ app/routes/admin.py | 319 +++++++++++++ app/routes/api.py | 183 ++++++++ {routes => app/routes}/plinko.py | 130 +++--- {routes => app/routes}/wallet.py | 12 +- {static => app/static}/admin.js | 0 {static => app/static}/admin_logo.svg | 0 {static => app/static}/apple_wallet.svg | 0 {static => app/static}/apple_wallet/icon.png | Bin .../static}/apple_wallet/icon@2x.png | Bin .../static}/apple_wallet/logo_reg.png | Bin .../static}/apple_wallet/logo_reg@2x.png | Bin {static => app/static}/checkin/index.css | 0 {static => app/static}/checkin/index.js | 0 {static => app/static}/dash/index.css | 0 {static => app/static}/dash/index.js | 0 {static => app/static}/form.js | 0 {static => app/static}/hackucf.css | 0 {static => app/static}/index.html | 0 .../static}/lib/qr-scanner-worker.min.js | 0 {static => app/static}/lib/qr-scanner.min.js | 0 .../static}/lib/qr-scanner.umd.min.js | 0 {static => app/static}/qr_hpcc_light.svg | 0 .../templates}/admin_searcher.html | 0 {templates => app/templates}/approved.html | 0 {templates => app/templates}/checkin_qr.html | 0 {templates => app/templates}/dash.html | 0 {templates => app/templates}/denied.html | 0 {templates => app/templates}/error.html | 0 {templates => app/templates}/form.html | 0 {templates => app/templates}/index.html | 0 {templates => app/templates}/profile.html | 0 {templates => app/templates}/scoreboard.html | 0 .../templates}/scoreboard_editor.html | 0 {templates => app/templates}/signup.html | 0 {templates => app/templates}/waitlist.html | 0 {util => app/util}/authentication.py | 31 +- app/util/database.py | 57 +++ {util => app/util}/discord.py | 38 +- app/util/email.py | 38 ++ {util => app/util}/errors.py | 0 app/util/forms.py | 72 +++ {util => app/util}/kennelish.py | 42 +- {util => app/util}/options.py | 0 {util => app/util}/plinko.py | 55 ++- app/util/settings.py | 329 ++++++++++++++ {util => app/util}/websockets.py | 0 database/database.db | Bin 0 -> 24576 bytes index.py | 424 ------------------ models/user.py | 100 ----- requirements.txt | 1 - routes/admin.py | 224 --------- routes/api.py | 188 -------- 64 files changed, 1982 insertions(+), 1089 deletions(-) create mode 100644 app/alembic.ini rename {forms => app/forms}/2.json (100%) rename {forms => app/forms}/edit.json (100%) create mode 100644 app/index.py create mode 100644 app/migrations/README create mode 100644 app/migrations/__init__.py create mode 100644 app/migrations/env.py create mode 100644 app/migrations/script.py.mako create mode 100644 app/migrations/versions/3337a749d431_initial_commit.py rename {models => app/models}/info.py (84%) create mode 100644 app/models/user.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/api.py rename {routes => app/routes}/plinko.py (76%) rename {routes => app/routes}/wallet.py (96%) rename {static => app/static}/admin.js (100%) rename {static => app/static}/admin_logo.svg (100%) rename {static => app/static}/apple_wallet.svg (100%) rename {static => app/static}/apple_wallet/icon.png (100%) rename {static => app/static}/apple_wallet/icon@2x.png (100%) rename {static => app/static}/apple_wallet/logo_reg.png (100%) rename {static => app/static}/apple_wallet/logo_reg@2x.png (100%) rename {static => app/static}/checkin/index.css (100%) rename {static => app/static}/checkin/index.js (100%) rename {static => app/static}/dash/index.css (100%) rename {static => app/static}/dash/index.js (100%) rename {static => app/static}/form.js (100%) rename {static => app/static}/hackucf.css (100%) rename {static => app/static}/index.html (100%) rename {static => app/static}/lib/qr-scanner-worker.min.js (100%) rename {static => app/static}/lib/qr-scanner.min.js (100%) rename {static => app/static}/lib/qr-scanner.umd.min.js (100%) rename {static => app/static}/qr_hpcc_light.svg (100%) rename {templates => app/templates}/admin_searcher.html (100%) rename {templates => app/templates}/approved.html (100%) rename {templates => app/templates}/checkin_qr.html (100%) rename {templates => app/templates}/dash.html (100%) rename {templates => app/templates}/denied.html (100%) rename {templates => app/templates}/error.html (100%) rename {templates => app/templates}/form.html (100%) rename {templates => app/templates}/index.html (100%) rename {templates => app/templates}/profile.html (100%) rename {templates => app/templates}/scoreboard.html (100%) rename {templates => app/templates}/scoreboard_editor.html (100%) rename {templates => app/templates}/signup.html (100%) rename {templates => app/templates}/waitlist.html (100%) rename {util => app/util}/authentication.py (81%) create mode 100644 app/util/database.py rename {util => app/util}/discord.py (53%) create mode 100644 app/util/email.py rename {util => app/util}/errors.py (100%) create mode 100644 app/util/forms.py rename {util => app/util}/kennelish.py (90%) rename {util => app/util}/options.py (100%) rename {util => app/util}/plinko.py (59%) create mode 100644 app/util/settings.py rename {util => app/util}/websockets.py (100%) create mode 100644 database/database.db delete mode 100644 index.py delete mode 100644 models/user.py delete mode 100644 routes/admin.py delete mode 100644 routes/api.py diff --git a/.gitignore b/.gitignore index 89b38c0..49bc7e1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ terraform.* clouds.yaml infra_options.json _deploy.sh +config/ diff --git a/app/alembic.ini b/app/alembic.ini new file mode 100644 index 0000000..7900535 --- /dev/null +++ b/app/alembic.ini @@ -0,0 +1,112 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = ./migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = ../ + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/forms/2.json b/app/forms/2.json similarity index 100% rename from forms/2.json rename to app/forms/2.json diff --git a/forms/edit.json b/app/forms/edit.json similarity index 100% rename from forms/edit.json rename to app/forms/edit.json diff --git a/app/index.py b/app/index.py new file mode 100644 index 0000000..83547de --- /dev/null +++ b/app/index.py @@ -0,0 +1,356 @@ +import json, re, uuid +import os +import requests +import logging + +from datetime import datetime, timedelta +import time +from typing import Optional, Union + +# FastAPI +from fastapi import Depends, FastAPI, HTTPException, status, Request, Response, Cookie +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select + +from jose import JWTError, jwt +from urllib.parse import urlparse +from requests_oauthlib import OAuth2Session + + +# Import the page rendering library +from app.util.kennelish import Kennelish + +# Import middleware +from app.util.authentication import Authentication +from app.util.database import get_session, init_db +from app.util.forms import Forms + +# Import error handling +from app.util.errors import Errors + +# Import options +from app.util.settings import Settings +from app.util.plinko import Plinko +from app.util.discord import Discord + +# Import data types +from app.models.user import UserModel, DiscordModel, user_to_dict + +# import db functions +from app.util.database import get_session, init_db, get_user, get_user_discord + +# Import routes +from app.routes import api, admin, wallet, plinko + +# Init Logger +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + + +# Initiate FastAPI. +app = FastAPI() +templates = Jinja2Templates(directory="app/templates") +app.mount("/static", StaticFiles(directory="./app/static"), name="static") + +# Import endpoints from ./routes +app.include_router(api.router) +app.include_router(admin.router) +app.include_router(wallet.router) +app.include_router(plinko.router) + +@app.get("/") +async def index(request: Request, token: Optional[str] = Cookie(None)): + """ + Home page. + """ + try: + if token is None: + raise JWTError("Token is None") + payload = jwt.decode( + token, + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, + ) + + if payload.get("waitlist") and payload.get("waitlist") > 0: + return RedirectResponse("/profile/", status_code=status.HTTP_302_FOUND) + elif payload.get("sudo") == True: + return RedirectResponse("/profile/", status_code=status.HTTP_302_FOUND) + else: + return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) + except JWTError: + return RedirectResponse( + "/discord/new/?redir=/", status_code=status.HTTP_302_FOUND + ) + +""" +Redirects to Discord for OAuth. +This is what is linked to by Onboard. +""" + +@app.get("/discord/new/") +async def oauth_transformer(redir: str = "/join/2"): + # Open redirect check + hostname = urlparse(redir).netloc + logger.debug(f"Hostname: {hostname}") + if hostname != "" and hostname != Settings().http.domain: + redir = "/join/2" + + oauth = OAuth2Session( + Settings().discord.client_id, + redirect_uri=Settings().discord.redirect_base + "_redir", + scope=Settings().discord.scope, + ) + authorization_url, state = oauth.authorization_url( + "https://discord.com/api/oauth2/authorize" + ) + + rr = RedirectResponse(authorization_url, status_code=302) + + rr.set_cookie(key="redir_endpoint", value=redir) + + return rr + +""" +Logs the user into Onboard via Discord OAuth and updates their Discord metadata. +This is what Discord will redirect to. +""" + +@app.get("/api/oauth/") +async def oauth_transformer_new( + request: Request, + response: Response, + code: str, + redir: str = "/join/2", + redir_endpoint: Optional[str] = Cookie(None), + session: Session = Depends(get_session), +): + + # Open redirect check + if redir == "_redir": + redir = redir_endpoint or "/join/2" + + hostname = urlparse(redir).netloc + + if hostname != "" and hostname != Settings().http.domain: + redir = "/join/2" + + if code is None: + return Errors.generate( + request, + 401, + "You declined Discord log-in", + essay="We need your Discord account to sign up for the Horse Plinko Cyber Challenge.", + ) + + # Get data from Discord + oauth = OAuth2Session( + Settings().discord.client_id, + redirect_uri=Settings().discord.redirect_base + "_redir", + scope=Settings().discord.scope, + ) + + token = oauth.fetch_token( + "https://discord.com/api/oauth2/token", + client_id=Settings().discord.client_id, + client_secret=Settings().discord.secret.get_secret_value(), + code=code, + ) + + r = oauth.get("https://discord.com/api/users/@me") + discordData = r.json() + + # Generate a new user ID or reuse an existing one. + user = get_user_discord(session, discordData["id"], use_selectinload=True) + + # TODO implament Onboard Federation + + is_new = False + + + if user: + member_id = user.id + do_sudo = user.sudo + else: + if not discordData.get("verified"): + tr = Errors.generate( + request, + 403, + "Discord email not verfied please try again", + ) + return tr + infra_email = "" + discord_id = discordData["id"] + Discord().join_plinko_server(discord_id, token) + user = UserModel(discord_id=discord_id) + discord_data = { + "email": discordData.get("email"), + "mfa": discordData.get("mfa_enabled"), + "avatar": f"https://cdn.discordapp.com/avatars/{discordData['id']}/{discordData['avatar']}.png?size=512", + "banner": f"https://cdn.discordapp.com/banners/{discordData['id']}/{discordData['banner']}.png?size=1536", + "color": discordData.get("accent_color"), + "nitro": discordData.get("premium_type"), + "locale": discordData.get("locale"), + "username": discordData.get("username"), + "user_id": user.id, + } + discord_model = DiscordModel(**discord_data) + user.discord = discord_model + session.add(user) + session.commit() + session.refresh(user) + + # Create JWT. This should be the only way to issue JWTs. + jwtData = { + "discord": token, + "name": discordData["username"], + "pfp": discordData.get("avatar"), + "id": str(member_id), + "sudo": do_sudo, + "issued": time.time(), + } + bearer = jwt.encode( + jwtData, + Settings().jwt.secret.get_secret_value(), + algorithm=Settings().jwt.algorithm, + ) + rr = RedirectResponse(redir, status_code=status.HTTP_302_FOUND) + if user.sudo: + max_age = Settings().jwt.lifetime_sudo + else: + max_age = Settings().jwt.lifetime_user + if Settings().env == "dev": + rr.set_cookie( + key="token", + value=bearer, + httponly=True, + samesite="lax", + secure=False, + max_age=max_age, + ) + else: + rr.set_cookie( + key="token", + value=bearer, + httponly=True, + samesite="lax", + secure=True, + max_age=max_age, + ) + + # Clear redirect cookie. + rr.delete_cookie("redir_endpoint") + + return rr + +""" +Renders the landing page for the sign-up flow. +""" + +@app.get("/join/") +async def join(request: Request, token: Optional[str] = Cookie(None), session: Session = Depends(get_session)): + signups, wl_status, group = Plinko.get_waitlist_status(session) + if token is None: + return templates.TemplateResponse( + "signup.html", {"request": request, "waitlist_status": wl_status} + ) + else: + try: + payload = jwt.decode( + token, + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, + ) + + user_data = get_user(session, uuid.UUID(payload.get("id"))) + + if user_data.get("waitlist") and user_data.get("waitlist") > 0: + return RedirectResponse("/profile", status_code=status.HTTP_302_FOUND) + + except Exception as e: + pass + + return RedirectResponse("/join/2/", status_code=status.HTTP_302_FOUND) + +""" +Renders a basic "my membership" page +""" + +@app.get("/profile/") +@Authentication.member +async def profile( + request: Request, + token: Optional[str] = Cookie(None), + payload: Optional[object] = {}, + session: Session = Depends(get_session), +): + # Get data from DynamoDB + user_data = get_user(session, uuid.UUID(payload.get("id"))) + + team_data = Plinko.get_team(session, payload.get("id")) + + return templates.TemplateResponse( + "profile.html", + {"request": request, "user_data": user_to_dict(user_data), "team_data": team_data}, + ) + +""" +Renders a Kennelish form page, complete with stylings and UI controls. +""" + +@app.get("/join/{num}/") +@Authentication.member +async def forms( + request: Request, + token: Optional[str] = Cookie(None), + payload: Optional[object] = {}, + num: str = "1", + session: Session = Depends(get_session), +): + # AWS dependencies + + if num == "1": + return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) + + data = Forms.get_form_body(num) + + # Get data from DynamoDB + user_data = get_user(session, uuid.UUID(payload.get("id"))) + + # Have Kennelish parse the data. + body = Kennelish.parse(data, user_to_dict(user_data)) + + return templates.TemplateResponse( + "form.html", + { + "request": request, + "icon": payload["pfp"], + "name": payload["name"], + "id": payload["id"], + "body": body, + }, + ) + +@app.get("/final") +async def final(request: Request): + return templates.TemplateResponse("done.html", {"request": request}) + +@app.get("/logout") +async def logout(request: Request): + rr = RedirectResponse("/", status_code=status.HTTP_302_FOUND) + rr.delete_cookie(key="token") + return rr + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/app/migrations/README b/app/migrations/README new file mode 100644 index 0000000..a23d4fb --- /dev/null +++ b/app/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/migrations/env.py b/app/migrations/env.py new file mode 100644 index 0000000..89b2659 --- /dev/null +++ b/app/migrations/env.py @@ -0,0 +1,93 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine import Connection +from sqlmodel import SQLModel # noqa: F401 + +from app.models.user import DiscordModel, UserModel # noqa: F401 +from app.util.settings import Settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", Settings().database.url) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + do_run_migrations(connection) + + connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/migrations/script.py.mako b/app/migrations/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/app/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/migrations/versions/3337a749d431_initial_commit.py b/app/migrations/versions/3337a749d431_initial_commit.py new file mode 100644 index 0000000..d0868ac --- /dev/null +++ b/app/migrations/versions/3337a749d431_initial_commit.py @@ -0,0 +1,65 @@ +"""Initial Commit + +Revision ID: 3337a749d431 +Revises: +Create Date: 2024-08-23 13:09:07.970763 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '3337a749d431' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('usermodel', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('did_get_shirt', sa.Boolean(), nullable=True), + sa.Column('did_agree_to_do_kh', sa.Boolean(), nullable=True), + sa.Column('team_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('availability', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('sudo', sa.Boolean(), nullable=True), + sa.Column('discord_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('hackucf_id', sa.Uuid(), nullable=True), + sa.Column('experience', sa.Integer(), nullable=True), + sa.Column('waitlist', sa.Integer(), nullable=True), + sa.Column('team_number', sa.Integer(), nullable=True), + sa.Column('assigned_run', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('checked_in', sa.Boolean(), nullable=True), + sa.Column('did_sign_photo_release', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('discordmodel', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('mfa', sa.Boolean(), nullable=True), + sa.Column('avatar', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('banner', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('color', sa.Integer(), nullable=True), + sa.Column('nitro', sa.Integer(), nullable=True), + sa.Column('locale', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['usermodel.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('discordmodel') + op.drop_table('usermodel') + # ### end Alembic commands ### diff --git a/models/info.py b/app/models/info.py similarity index 84% rename from models/info.py rename to app/models/info.py index c97334e..746632e 100644 --- a/models/info.py +++ b/app/models/info.py @@ -2,7 +2,7 @@ from typing import Optional, List # Import data types -from models.user import PublicContact +from app.models.user import PublicContact class InfoModel(BaseModel): diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..d70465b --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,171 @@ +import re +import uuid +from typing import Any, Optional + +from pydantic import BaseModel, validator +from sqlmodel import Field, Relationship, SQLModel + + +class DiscordModel(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + email: Optional[str] = None + mfa: Optional[bool] = None + avatar: Optional[str] = None + banner: Optional[str] = None + color: Optional[int] = None + nitro: Optional[int] = None + locale: Optional[str] = None + username: str + user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="usermodel.id") + user: "UserModel" = Relationship(back_populates="discord") + + +class UserModel(SQLModel, table=True): + # Identifiers + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + # PII + first_name: Optional[str] = "" + last_name: Optional[str] = "" + email: Optional[str] = "" + did_get_shirt: Optional[bool] = False + + # The MLH required stuff + did_agree_to_do_kh: Optional[bool] = False # must be True to compete + + # HPCC data (user-mutable) + team_name: Optional[str] = "" + availability: Optional[str] = "" + + # Permissions and Member Status + sudo: Optional[bool] = False + + # Collected from Discord + discord_id: str + discord: DiscordModel = Relationship(back_populates="user") + + # Collected from HackUCF Onboard + hackucf_id: Optional[uuid.UUID] = None + experience: Optional[int] = None + + # HPCC data (internal) + waitlist: Optional[int] = None + team_number: Optional[int] = None + assigned_run: Optional[str] = "" + + checked_in: Optional[bool] = False + + did_sign_photo_release: Optional[bool] = False + + did_get_shirt: Optional[bool] = False + + +# What admins can edit. +class UserModelMutable(BaseModel): + # Identifiers + id: str + + # PII + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + shirt_size: Optional[str] + + # The MLH required stuff + did_agree_to_do_kh: Optional[bool] # must be True to compete + + # HPCC data (user-mutable) + team_name: Optional[str] + availability: Optional[str] + + # Permissions and Member Status + sudo: Optional[bool] + + # Collected from Discord + discord_id: Optional[str] + discord: Optional[DiscordModel] + + # Collected from HackUCF Onboard + hackucf_id: Optional[str] + experience: Optional[int] + + # HPCC data (internal) + waitlist: Optional[int] + team_number: Optional[int] + assigned_run: Optional[str] + + checked_in: Optional[bool] + + did_sign_photo_release: Optional[bool] + + did_get_shirt: Optional[bool] + + +class PublicContact(BaseModel): + first_name: str + surname: str + ops_email: str + + + +def user_to_dict(model): + if model is None: + return None + if isinstance(model, list): + return [user_to_dict(item) for item in model] + if isinstance(model, (SQLModel, BaseModel)): + data = model.model_dump() + for key, value in model.__dict__.items(): + if isinstance(value, (SQLModel, BaseModel)): + data[key] = user_to_dict(value) + elif ( + isinstance(value, list) + and value + and isinstance(value[0], (SQLModel, BaseModel)) + ): + data[key] = user_to_dict(value) + return data + + +def user_update_instance(instance: SQLModel, data: dict[str, Any]) -> None: + for key, value in data.items(): + if isinstance(value, dict): + nested_instance = getattr(instance, key, None) + if nested_instance is not None: + user_update_instance(nested_instance, value) + else: + nested_model_class = instance.__class__.__annotations__.get(key) + if nested_model_class: + new_nested_instance = nested_model_class() + user_update_instance(new_nested_instance, value) + else: + if value is not None: + setattr(instance, key, value) + + +# Removed unneeded functionality + +# class CyberLabModel(SQLModel, table=True): +# id: Optional[int] = Field(default=None, primary_key=True) +# resource: Optional[bool] = False +# clean: Optional[bool] = False +# no_profane: Optional[bool] = False +# access_control: Optional[bool] = False +# report_damage: Optional[bool] = False +# be_nice: Optional[bool] = False +# can_revoke: Optional[bool] = False +# signtime: Optional[int] = 0 +# +# user_id: Optional[int] = Field(default=None, foreign_key="usermodel.id") +# user: "UserModel" = Relationship(back_populates="cyberlab_monitor") +# +# class MenteeModel(SQLModel, table=True): +# id: Optional[int] = Field(default=None, primary_key=True) +# schedule: Optional[str] = None +# time_in_cyber: Optional[str] = None +# personal_proj: Optional[str] = None +# hope_to_gain: Optional[str] = None +# domain_interest: Optional[str] = None +# +# user_id: Optional[int] = Field(default=None, foreign_key="usermodel.id") +# user: "UserModel" = Relationship(back_populates="mentee") diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..1f63c67 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,319 @@ +import logging +import uuid +from typing import Optional + +from fastapi import APIRouter, Body, Cookie, Depends, Request, Response +from fastapi.templating import Jinja2Templates +from jose import jwt +from sqlalchemy.orm import selectinload +from sqlalchemy.types import UUID +from sqlmodel import Session, select + +from app.models.user import ( + UserModel, + UserModelMutable, + user_to_dict, + user_update_instance, +) + +from app.util.authentication import Authentication +from app.util.database import get_session +from app.util.discord import Discord +from app.util.email import Email +from app.util.errors import Errors +from app.util.settings import Settings + +logger = logging.getLogger(__name__) + +templates = Jinja2Templates(directory="app/templates") + +router = APIRouter(prefix="/admin", tags=["Admin"], responses=Errors.basic_http()) + + +@router.get("/") +@Authentication.admin +async def admin(request: Request, token: Optional[str] = Cookie(None)): + """ + Renders the Admin home page. + """ + payload = jwt.decode( + token, + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, + ) + return templates.TemplateResponse( + "admin_searcher.html", + { + "request": request, + "icon": payload["pfp"], + "name": payload["name"], + "id": payload["id"], + }, + ) + + +@router.get("/infra/") +@Authentication.admin +async def get_infra( + request: Request, + user_jwt: Optional[str] = Cookie(None), + member_id: Optional[str] = "FAIL", + session: Session = Depends(get_session), +): + """ + API endpoint to FORCE-provision Infra credentials (even without membership!!!) + """ + if member_id == "FAIL": + return {"username": "", "password": "", "error": "Missing ?member_id"} + + creds = Approve.provision_infra(member_id) + if creds is None: + creds = {} + + if not creds: + return Errors.generate(request, 404, "User Not Found") + + # Get user data + user_data = session.exec( + select(UserModel).where(UserModel.id == uuid.UUID(member_id)) + ).one_or_none() + + # Send DM... + new_creds_msg = f"""Hello {user_data.first_name}, + +We are happy to grant you Hack@UCF Private Cloud access! + +These credentials can be used to the Hack@UCF Private Cloud. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi. + +``` +Username: {creds.get('username', 'Not Set')} +Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")} +``` + +By using the Hack@UCF Infrastructure, you agree to the following Acceptable Use Policy located at https://help.hackucf.org/misc/aup + +The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens). + +Happy Hacking, + - Hack@UCF Bot + """ + + # Send Discord message + # Discord.send_message(user_data.get("discord_id"), new_creds_msg) + Email.send_email( + "Hack@UCF Private Cloud Credentials", new_creds_msg, user_data.email + ) + return {"username": creds.get("username"), "password": creds.get("password")} + + +@router.get("/refresh/") +@Authentication.admin +async def get_refresh( + request: Request, + token: Optional[str] = Cookie(None), + member_id: Optional[str] = "FAIL", + session: Session = Depends(get_session), +): + """ + API endpoint that re-runs the member verification workflow + """ + if member_id == "FAIL": + return {"data": {}, "error": "Missing ?member_id"} + + Approve.approve_member(uuid.UUID(member_id)) + + user_data = session.exec( + select(UserModel).where(UserModel.id == uuid.UUID(member_id)) + ).one_or_none() + + if not user_data: + return Errors.generate(request, 404, "User Not Found") + + return {"data": user_data} + + +@router.get("/get/") +@Authentication.admin +async def admin_get_single( + request: Request, + user_jwt: Optional[str] = Cookie(None), + member_id: Optional[str] = "FAIL", + session: Session = Depends(get_session), +): + """ + API endpoint that gets a specific user's data as JSON + """ + if member_id == "FAIL": + return {"data": {}, "error": "Missing ?member_id"} + + statement = ( + select(UserModel) + .where(UserModel.id == uuid.UUID(user_jwt["id"])) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + user_data = user_to_dict(session.exec(statement).one_or_none()) + + if not user_data: + return Errors.generate(request, 404, "User Not Found") + + return {"data": user_data} + + +@router.get("/get_by_snowflake/") +@Authentication.admin +async def admin_get_snowflake( + request: Request, + token: Optional[str] = Cookie(None), + discord_id: Optional[str] = "FAIL", + session: Session = Depends(get_session), +): + """ + API endpoint that gets a specific user's data as JSON, given a Discord snowflake. + Designed for trusted federated systems to exchange data. + """ + if discord_id == "FAIL": + return {"data": {}, "error": "Missing ?discord_id"} + + statement = ( + select(UserModel) + .where(UserModel.discord_id == discord_id) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + data = user_to_dict(session.exec(statement).one_or_none()) + # if not data: + # # Try a legacy-user-ID search (deprecated, but still neccesary) + # data = table.scan(FilterExpression=Attr("discord_id").eq(int(discord_id))).get( + # "Items" + # ) + # + # if not data: + # return Errors.generate(request, 404, "User Not Found") + + # data = data[0] + + return {"data": data} + + +@router.post("/message/") +@Authentication.admin +async def admin_post_discord_message( + request: Request, + token: Optional[str] = Cookie(None), + member_id: Optional[str] = "FAIL", + user_jwt: dict = Body(None), + session: Session = Depends(get_session), +): + """ + API endpoint that gets a specific user's data as JSON + """ + if member_id == "FAIL": + return {"data": {}, "error": "Missing ?member_id"} + + data = session.exec( + select(UserModel).where(UserModel.id == uuid.UUID(member_id)) + ).one_or_none() + + if not data: + return Errors.generate(request, 404, "User Not Found") + + message_text = user_jwt.get("msg") + + res = Discord.send_message(data.discord_id, message_text) + + if res: + return {"msg": "Message sent."} + else: + return {"msg": "An error occured!"} + + +@router.post("/get/") +@Authentication.admin +async def admin_edit( + request: Request, + token: Optional[str] = Cookie(None), + input_data: Optional[UserModelMutable] = {}, + session: Session = Depends(get_session), +): + """ + API endpoint that modifies a given user's data + """ + member_id = input_data.id + + statement = ( + select(UserModel) + .where(UserModel.id == member_id) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + member_data = session.exec(statement).one_or_none() + + if not member_data: + return Errors.generate(request, 404, "User Not Found") + input_data = user_to_dict(input_data) + user_update_instance(member_data, input_data) + + session.add(member_data) + session.commit() + return {"data": user_to_dict(member_data), "msg": "Updated successfully!"} + + +@router.get("/list") +@Authentication.admin +async def admin_list( + request: Request, + token: Optional[str] = Cookie(None), + session: Session = Depends(get_session), +): + """ + API endpoint that dumps all users as JSON. + """ + statement = select(UserModel).options( + selectinload(UserModel.discord), selectinload(UserModel.ethics_form) + ) + users = session.exec(statement) + data = [] + for user in users: + user = user_to_dict(user) + data.append(user) + + return {"data": data} + + +@router.get("/csv") +@Authentication.admin +async def admin_list_csv( + request: Request, + token: Optional[str] = Cookie(None), + session: Session = Depends(get_session), +): + """ + API endpoint that dumps all users as CSV. + """ + statement = select(UserModel).options( + selectinload(UserModel.discord), selectinload(UserModel.ethics_form) + ) + data = user_to_dict(session.exec(statement)) + + output = "Membership ID, First Name, Last Name, NID, Is Returning, Gender, Major, Class Standing, Shirt Size, Discord Username, Experience, Cyber Interests, Event Interest, Is C3 Interest, Comments, Ethics Form Timestamp, Minecraft, Infra Email\n" + for user in data: + output += f'"{user.get("id")}", ' + output += f'"{user.get("first_name")}", ' + output += f'"{user.get("surname")}", ' + output += f'"{user.get("nid")}", ' + output += f'"{user.get("is_returning")}", ' + output += f'"{user.get("gender")}", ' + output += f'"{user.get("major")}", ' + output += f'"{user.get("class_standing")}", ' + output += f'"{user.get("shirt_size")}", ' + output += f'"{user.get("discord", {}).get("username")}", ' + output += f'"{user.get("experience")}", ' + output += f'"{user.get("curiosity")}", ' + output += f'"{user.get("attending")}", ' + output += f'"{user.get("c3_interest")}", ' + + output += f'"{user.get("comments")}", ' + + output += f'"{user.get("ethics_form", {}).get("signtime")}", ' + output += f'"{user.get("minecraft")}", ' + output += f'"{user.get("infra_email")}"\n' + + return Response(content=output, headers={"Content-Type": "text/csv"}) diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..194a769 --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,183 @@ +import json +import logging +import uuid +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select + +from app.models.info import InfoModel +from app.models.user import PublicContact, UserModel, user_update_instance +from app.util.authentication import Authentication +from app.util.database import get_session +from app.util.errors import Errors +from app.util.forms import Forms, apply_fuzzy_parsing, transform_dict +from app.util.kennelish import Transformer + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http()) + + +@router.get("/") +async def get_root(): + """ + Get API information. + """ + return InfoModel( + name="OnboardLite", + description="Hack@UCF's in-house membership management suite.", + credits=[ + PublicContact( + first_name="Jonathan", + surname="Styles", + ops_email="jstyles@hackucf.org", + ) + ], + ) + + +@router.get("/form/{num}") +async def get_form(num: str): + """ + Gets the JSON markup for a Kennelish file. For client-side rendering (if that ever becomes a thing). + Note that Kennelish form files are NOT considered sensitive. + """ + try: + return Forms.get_form_body(num) + except FileNotFoundError: + return HTTPException(status_code=404, detail="Form not found") + + +""" +Renders a Kennelish form file as HTML (with user data). Intended for AJAX applications. +""" +# TODO Fix or remove this route, Do we even need it? +# +# @router.get("/form/{num}/html", response_class=HTMLResponse) +# @Authentication.member +# async def get_form_html( +# request: Request, +# token: Optional[str] = Cookie(None), +# user_jwt: Optional[object] = {}, +# num: str = 1, +# ): +# # AWS dependencies +# # dynamodb = boto3.resource("dynamodb") +# # table = dynamodb.Table(Settings().aws.table) +# +# # Get form object +# try: +# data = Forms.get_form_body(num) +# except FileNotFoundError: +# return HTTPException(status_code=404, detail="Form not found") +# # Get data from DynamoDB +# user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) +# +# # Have Kennelish parse the data. +# body = Kennelish.parse(data, user_data) +# +# return body + + +""" +Allows updating the user's database using a schema assumed by the Kennelish file. +""" + + +# @router.post("/form/ethics_form_midway") +# @Authentication.member +# async def post_ethics_form( +# request: Request, +# token: Optional[str] = Cookie(None), +# user_jwt: Optional[object] = {}, +# session: Session = Depends(get_session), +# ): +# try: +# ethics_form_data = EthicsFormUpdate.model_validate(await request.json()) +# except json.JSONDecodeError: +# return {"description": "Malformed JSON input."} +# user_id = user_jwt.get("id") +# # Retrieve existing user model from the database +# statement = select(UserModel).where(UserModel.id == user_id) +# result = session.exec(statement) +# user = result.one_or_none() +# +# if not user: +# raise HTTPException(status_code=404, detail="User not found") +# +# # Update the ethics form with new values +# validated_data = apply_fuzzy_parsing( +# ethics_form_data.model_dump(exclude_unset=True), EthicsFormModel +# ) +# print(validated_data.dict()) +# for key, value in validated_data: +# if value is not None: +# setattr(user.ethics_form, key, value) +# +# # Save the updated model back to the database +# session.add(user) +# session.commit() +# session.refresh(user) +# +# return user.ethics_form.dict() +# +# +@router.post("/form/{num}") +@Authentication.member +async def post_form( + request: Request, + token: Optional[str] = Cookie(None), + payload: Optional[object] = {}, + num: str = '1', + session: Session = Depends(get_session), +): + # Get Kennelish data + try: + kennelish_data = Forms.get_form_body(num) + except FileNotFoundError: + return HTTPException(status_code=404, detail="Form not found") + + model = Transformer.kennelish_to_pydantic(kennelish_data) + + # Parse and Validate inputs + try: + inp = await request.json() + except json.JSONDecodeError: + return {"description": "Malformed JSON input."} + + model_validated = model(**inp).model_dump() + + validated_data = apply_fuzzy_parsing(model_validated) + + # Transform the dictionary + validated_data = transform_dict(validated_data) + + statement = ( + select(UserModel) + .where(UserModel.id == uuid.UUID(payload["id"])) + .options(selectinload(UserModel.discord)) + ) + result = session.exec(statement) + user = result.one_or_none() + + if not user: + raise HTTPException(status_code=422, detail="User not found") + + user_update_instance(user, validated_data) + + # Save the updated model back to the database + session.add(user) + try: + session.commit() + except IntegrityError as e: + logger.error(e) + session.rollback() + raise HTTPException( + status_code=422, detail=("Integrity Error. " + str(e).split("\n")[0]) + ) + session.refresh(user) + + return user.model_dump() diff --git a/routes/plinko.py b/app/routes/plinko.py similarity index 76% rename from routes/plinko.py rename to app/routes/plinko.py index 9880a8b..b53c091 100644 --- a/routes/plinko.py +++ b/app/routes/plinko.py @@ -1,9 +1,8 @@ -import boto3, json, requests -from boto3.dynamodb.conditions import Key, Attr - from jose import JWTError, jwt +import logging +import uuid -from fastapi import APIRouter, Cookie, Request, Response, status, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Cookie, Request, Response, status, WebSocket, WebSocketDisconnect, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.encoders import jsonable_encoder @@ -11,23 +10,29 @@ from pydantic import validator, error_wrappers from typing import Optional -from models.user import UserModelMutable -from models.info import InfoModel -from models.user import PublicContact - -from util.authentication import Authentication -from util.errors import Errors -from util.options import Options -from util.discord import Discord -from util.plinko import Plinko -from util.websockets import ConnectionManager -from util.kennelish import Kennelish, Transformer + +from sqlalchemy.sql.sqltypes import UUID +from sqlalchemy.orm import selectinload +from app.models.user import UserModelMutable +from app.models.info import InfoModel +from app.models.user import PublicContact, UserModel, DiscordModel +from app.util.database import get_user, Session, get_session + +from app.util.authentication import Authentication +from app.util.errors import Errors +from app.util.options import Options +from app.util.discord import Discord +from app.util.plinko import Plinko +from app.util.websockets import ConnectionManager +from app.util.kennelish import Kennelish, Transformer +from app.util.settings import Settings import asyncio -options = Options.fetch() -templates = Jinja2Templates(directory="templates") +logger = logging.getLogger(__name__) + +templates = Jinja2Templates(directory="app/templates") router = APIRouter(prefix="/plinko", tags=["HPCC"], responses=Errors.basic_http()) @@ -65,8 +70,10 @@ async def plinko_ws(websocket: WebSocket, token: str): @router.get("/waitlist") -async def get_waitlist(): - signups, waitlist_status, group = Plinko.get_waitlist_status() +async def get_waitlist( + session: Session = Depends(get_session), +): + signups, waitlist_status, group = Plinko.get_waitlist_status(session) return {"signups": signups, "status": waitlist_status, "group": group} @@ -77,21 +84,16 @@ async def get_waitlist( request: Request, token: Optional[str] = Cookie(None), payload: Optional[object] = {}, + session: Session = Depends(get_session), ): """ Quit HPCC. """ - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - # user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) - - table.update_item( - Key={"id": payload.get("id")}, - UpdateExpression="SET waitlist = :waitlist", - ExpressionAttributeValues={":waitlist": 0}, - ) + logger.info(f"User {payload.get('id')} is dropping out of HPCC.") + user_data = get_user(session, uuid.UUID(payload.get("id"))) + user_data.waitlist = 0 + session.add(user_data) + session.commit() return RedirectResponse("/profile/", status_code=status.HTTP_302_FOUND) @@ -102,12 +104,13 @@ async def get_team_info( request: Request, token: Optional[str] = Cookie(None), payload: Optional[object] = {}, + session: Session = Depends(get_session), ): """ Gets team information of a given user. """ - team = Plinko.get_team(payload.get("id")) + team = Plinko.get_team(session, payload.get("id")) if team: return team else: @@ -117,7 +120,7 @@ async def get_team_info( @router.get("/bot") @Authentication.admin async def get_team_info( - request: Request, token: Optional[str] = Cookie(None), run: Optional[str] = "FAIL" + request: Request, token: Optional[str] = Cookie(None), run: Optional[str] = "FAIL", session: Session = Depends(get_session) ): """ Expose teams for a given run in a format understood by PlinkoBot. @@ -127,11 +130,14 @@ async def get_team_info( return Errors.generate(request, 404, "Missing ?run") # Get all participants - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.scan().get("Items", None) - - output = [] + statement = select(UserModel).options( + selectinload(UserModel.discord), selectinload(UserModel.ethics_form) + ) + users = session.exec(statement) + data = [] + for user in users: + user = user_to_dict(user) + data.append(user) # Find hightest index. team_count = -1 @@ -189,6 +195,7 @@ async def checkin( token: Optional[str] = Cookie(None), member_id: Optional[str] = "FAIL", run: Optional[str] = "FAIL", + session: Session = Depends(get_session), ): """ Check-in a user for a given run. @@ -197,10 +204,8 @@ async def checkin( if member_id == "FAIL" or run == "FAIL": return Errors.generate(request, 404, "User Not Found (or run not defined)") - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - user_data = table.get_item(Key={"id": member_id}).get("Item", None) + user_data = get_user(session, uuid.UUID(member_id)) if not user_data: return { @@ -216,13 +221,10 @@ async def checkin( "user": user_data, } - table.update_item( - Key={"id": member_id}, - UpdateExpression="SET checked_in = :checked_in", - ExpressionAttributeValues={":checked_in": True}, - ) - user_data["checked_in"] = True + user_data.checked_in = True + session.add(user_data) + session.commit() team_number = -1 if user_data.get("team_number"): @@ -237,17 +239,15 @@ async def join_waitlist( request: Request, token: Optional[str] = Cookie(None), payload: Optional[object] = {}, + session: Session = Depends(get_session), ): - signups, waitlist_status, group = Plinko.get_waitlist_status(plus_one=True) + signups, waitlist_status, group = Plinko.get_waitlist_status(session, plus_one=True) print(waitlist_status) - # start adding the person to the list! - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) + # start adding the person to the list + user_data = get_user(session, uuid.UUID(payload.get("id"))) - if user_data.get("sudo") == True: + if user_data.sudo == True: return templates.TemplateResponse( "denied.html", { @@ -257,13 +257,13 @@ async def join_waitlist( ) if waitlist_status == "Closed": - print(user_data.get("waitlist", -1)) - if user_data.get("waitlist", -1) == 1: + logger.info(user_data.waitlist, -1) + if user_data.waitlist == 1: return templates.TemplateResponse("approved.html", {"request": request}) - elif user_data.get("waitlist", -1) > 1: + elif user_data.waitlist > 1: return templates.TemplateResponse( "waitlist.html", - {"request": request, "group": user_data.get("waitlist")}, + {"request": request, "group": user_data.waitlist}, ) else: return templates.TemplateResponse( @@ -276,7 +276,7 @@ async def join_waitlist( # Check if user is an Organizer (i.e., they are on the banned guild) check_organizer = Discord.check_presence( - user_data.get("discord_id"), options.get("discord", {}).get("banned_guild_id") + user_data.discord_id, Settings().discord.organizer_guild_id ) if check_organizer: return templates.TemplateResponse( @@ -294,8 +294,8 @@ async def join_waitlist( # If spots open... if elgible: # If user already has a spot, show it. - if user_data.get("waitlist") != None: - old_group_if_rechecking = user_data.get("waitlist", 0) + if user_data.waitlist != None: + old_group_if_rechecking = user_data.waitlist if old_group_if_rechecking == 1: return templates.TemplateResponse("approved.html", {"request": request}) @@ -307,12 +307,10 @@ async def join_waitlist( ) # Add to waitlist (or roster) - print(f"We can update -> {group}") - table.update_item( - Key={"id": payload.get("id")}, - UpdateExpression="SET waitlist = :waitlist", - ExpressionAttributeValues={":waitlist": group}, - ) + logger.info(f"We can update -> {group}") + user_data.waitlist = group + session.add(user_data) + session.commit() if waitlist_status == "Waitlisted": return templates.TemplateResponse( diff --git a/routes/wallet.py b/app/routes/wallet.py similarity index 96% rename from routes/wallet.py rename to app/routes/wallet.py index 7e853c7..e03ee19 100644 --- a/routes/wallet.py +++ b/app/routes/wallet.py @@ -12,16 +12,16 @@ from pydantic import validator, error_wrappers from typing import Optional -from models.user import PublicContact -from models.info import InfoModel +from app.models.user import PublicContact +from app.models.info import InfoModel -from util.authentication import Authentication -from util.errors import Errors -from util.options import Options +from app.util.authentication import Authentication +from app.util.errors import Errors +from app.util.options import Options from airpress import PKPass -options = Options.fetch() + router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() diff --git a/static/admin.js b/app/static/admin.js similarity index 100% rename from static/admin.js rename to app/static/admin.js diff --git a/static/admin_logo.svg b/app/static/admin_logo.svg similarity index 100% rename from static/admin_logo.svg rename to app/static/admin_logo.svg diff --git a/static/apple_wallet.svg b/app/static/apple_wallet.svg similarity index 100% rename from static/apple_wallet.svg rename to app/static/apple_wallet.svg diff --git a/static/apple_wallet/icon.png b/app/static/apple_wallet/icon.png similarity index 100% rename from static/apple_wallet/icon.png rename to app/static/apple_wallet/icon.png diff --git a/static/apple_wallet/icon@2x.png b/app/static/apple_wallet/icon@2x.png similarity index 100% rename from static/apple_wallet/icon@2x.png rename to app/static/apple_wallet/icon@2x.png diff --git a/static/apple_wallet/logo_reg.png b/app/static/apple_wallet/logo_reg.png similarity index 100% rename from static/apple_wallet/logo_reg.png rename to app/static/apple_wallet/logo_reg.png diff --git a/static/apple_wallet/logo_reg@2x.png b/app/static/apple_wallet/logo_reg@2x.png similarity index 100% rename from static/apple_wallet/logo_reg@2x.png rename to app/static/apple_wallet/logo_reg@2x.png diff --git a/static/checkin/index.css b/app/static/checkin/index.css similarity index 100% rename from static/checkin/index.css rename to app/static/checkin/index.css diff --git a/static/checkin/index.js b/app/static/checkin/index.js similarity index 100% rename from static/checkin/index.js rename to app/static/checkin/index.js diff --git a/static/dash/index.css b/app/static/dash/index.css similarity index 100% rename from static/dash/index.css rename to app/static/dash/index.css diff --git a/static/dash/index.js b/app/static/dash/index.js similarity index 100% rename from static/dash/index.js rename to app/static/dash/index.js diff --git a/static/form.js b/app/static/form.js similarity index 100% rename from static/form.js rename to app/static/form.js diff --git a/static/hackucf.css b/app/static/hackucf.css similarity index 100% rename from static/hackucf.css rename to app/static/hackucf.css diff --git a/static/index.html b/app/static/index.html similarity index 100% rename from static/index.html rename to app/static/index.html diff --git a/static/lib/qr-scanner-worker.min.js b/app/static/lib/qr-scanner-worker.min.js similarity index 100% rename from static/lib/qr-scanner-worker.min.js rename to app/static/lib/qr-scanner-worker.min.js diff --git a/static/lib/qr-scanner.min.js b/app/static/lib/qr-scanner.min.js similarity index 100% rename from static/lib/qr-scanner.min.js rename to app/static/lib/qr-scanner.min.js diff --git a/static/lib/qr-scanner.umd.min.js b/app/static/lib/qr-scanner.umd.min.js similarity index 100% rename from static/lib/qr-scanner.umd.min.js rename to app/static/lib/qr-scanner.umd.min.js diff --git a/static/qr_hpcc_light.svg b/app/static/qr_hpcc_light.svg similarity index 100% rename from static/qr_hpcc_light.svg rename to app/static/qr_hpcc_light.svg diff --git a/templates/admin_searcher.html b/app/templates/admin_searcher.html similarity index 100% rename from templates/admin_searcher.html rename to app/templates/admin_searcher.html diff --git a/templates/approved.html b/app/templates/approved.html similarity index 100% rename from templates/approved.html rename to app/templates/approved.html diff --git a/templates/checkin_qr.html b/app/templates/checkin_qr.html similarity index 100% rename from templates/checkin_qr.html rename to app/templates/checkin_qr.html diff --git a/templates/dash.html b/app/templates/dash.html similarity index 100% rename from templates/dash.html rename to app/templates/dash.html diff --git a/templates/denied.html b/app/templates/denied.html similarity index 100% rename from templates/denied.html rename to app/templates/denied.html diff --git a/templates/error.html b/app/templates/error.html similarity index 100% rename from templates/error.html rename to app/templates/error.html diff --git a/templates/form.html b/app/templates/form.html similarity index 100% rename from templates/form.html rename to app/templates/form.html diff --git a/templates/index.html b/app/templates/index.html similarity index 100% rename from templates/index.html rename to app/templates/index.html diff --git a/templates/profile.html b/app/templates/profile.html similarity index 100% rename from templates/profile.html rename to app/templates/profile.html diff --git a/templates/scoreboard.html b/app/templates/scoreboard.html similarity index 100% rename from templates/scoreboard.html rename to app/templates/scoreboard.html diff --git a/templates/scoreboard_editor.html b/app/templates/scoreboard_editor.html similarity index 100% rename from templates/scoreboard_editor.html rename to app/templates/scoreboard_editor.html diff --git a/templates/signup.html b/app/templates/signup.html similarity index 100% rename from templates/signup.html rename to app/templates/signup.html diff --git a/templates/waitlist.html b/app/templates/waitlist.html similarity index 100% rename from templates/waitlist.html rename to app/templates/waitlist.html diff --git a/util/authentication.py b/app/util/authentication.py similarity index 81% rename from util/authentication.py rename to app/util/authentication.py index c0ef131..dc130ce 100644 --- a/util/authentication.py +++ b/app/util/authentication.py @@ -8,10 +8,10 @@ from fastapi.responses import RedirectResponse # Import options and errors -from util.errors import Errors -from util.options import Options +from app.util.errors import Errors +from app.util.options import Options +from app.util.settings import Settings -options = Options.fetch() class Authentication: @@ -25,8 +25,8 @@ def admin_validate(token): try: payload = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) is_admin: bool = payload.get("sudo", False) creation_date: float = payload.get("issued", -1) @@ -36,9 +36,7 @@ def admin_validate(token): if not is_admin: return False - if time.time() > creation_date + options.get("jwt").get("lifetime").get( - "sudo" - ): + if time.time() > creation_date + Settings().jwt.lifetime_sudo: return False return True @@ -56,9 +54,8 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): try: payload = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), - ) + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm,) is_admin: bool = payload.get("sudo", False) creation_date: float = payload.get("issued", -1) except Exception: @@ -78,9 +75,7 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): essay="If you think this is an error, please try logging in again.", ) - if time.time() > creation_date + options.get("jwt").get("lifetime").get( - "sudo" - ): + if time.time() > creation_date + Settings().jwt.lifetime_sudo: return Errors.generate( request, 403, @@ -112,8 +107,8 @@ async def wrapper_member( try: payload = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) creation_date: float = payload.get("issued", -1) except Exception: @@ -125,9 +120,7 @@ async def wrapper_member( tr.delete_cookie(key="token") return tr - if time.time() > creation_date + options.get("jwt").get("lifetime").get( - "user" - ): + if time.time() > creation_date + Settings().jwt.lifetime_user: return Errors.generate( request, 403, diff --git a/app/util/database.py b/app/util/database.py new file mode 100644 index 0000000..1af08a3 --- /dev/null +++ b/app/util/database.py @@ -0,0 +1,57 @@ +import logging +from uuid import UUID + +# Create the database +from alembic import script +from alembic.runtime import migration +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool +from sqlalchemy.orm import selectinload +from app.models.user import UserModel, DiscordModel + +from app.util.settings import Settings + +DATABASE_URL = Settings().database.url +logger = logging.getLogger(__name__) + +engine = create_engine( + DATABASE_URL, + # echo=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + +if "sqlite:///:memory:" in DATABASE_URL: + SQLModel.metadata.create_all(engine) + logger.info("Tables created in SQLite in-memory database.") + + +def init_db(): + SQLModel.metadata.create_all(engine) + return + + +def get_session(): + with Session(engine) as session: + yield session + + +def check_current_head(alembic_cfg, connectable): + # type: (config.Config, engine.Engine) -> bool + # cfg = config.Config("../alembic.ini") + directory = script.ScriptDirectory.from_config(alembic_cfg) + with connectable.begin() as connection: + context = migration.MigrationContext.configure(connection) + return set(context.get_current_heads()) == set(directory.get_heads()) + +def get_user(session, user_id: UUID, use_selectinload: bool = False): + query = session.query(UserModel).filter(UserModel.id == user_id) + if selectinload: + query = query.options(selectinload(UserModel.discord)) + return query.one_or_none() + +def get_user_discord(session, discord_id, use_selectinload: bool = False): + query = session.query(UserModel).filter(UserModel.discord_id == discord_id) + if selectinload: + query = query.options(selectinload(UserModel.discord)) + return query.one_or_none() diff --git a/util/discord.py b/app/util/discord.py similarity index 53% rename from util/discord.py rename to app/util/discord.py index c0f81ae..a655e7b 100644 --- a/util/discord.py +++ b/app/util/discord.py @@ -1,15 +1,19 @@ import json import requests +import logging -from util.options import Options +from app.util.settings import Settings -options = Options.fetch() -headers = { - "Authorization": f"Bot {options.get('discord', {}).get('bot_token')}", - "Content-Type": "application/json", - "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", -} + +logger = logging.getLogger(__name__) + +if Settings().discord.enable: + headers = { + "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value() }", + "Content-Type": "application/json", + "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", + } class Discord: @@ -20,6 +24,7 @@ class Discord: def __init__(self): pass + @staticmethod def check_presence(discord_id, guild_id): """ Checks if member is in a guild. @@ -38,7 +43,7 @@ def assign_role(discord_id, role_id): discord_id = str(discord_id) req = requests.put( - f"https://discord.com/api/guilds/{options.get('discord', {}).get('guild_id')}/members/{discord_id}/roles/{role_id}", + f"https://discord.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}/roles/{Settings.discord.member_role}", headers=headers, ) @@ -71,3 +76,20 @@ def send_message(discord_id, message): print(req.text) return req.status_code < 400 + + def join_plinko_server(self, discord_id, token): + if not Settings().discord.enable: + return + if self.check_presence(discord_id, Settings().discord.guild_id) != "joined": + logger.info(f"Joining {discord_id} to Plinko Discord") + headers = { + "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", + "Content-Type": "application/json", + "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", + } + put_join_guild = {"access_token": token["access_token"]} + requests.put( + f"https://discordapp.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}", + headers=headers, + data=json.dumps(put_join_guild), + ) diff --git a/app/util/email.py b/app/util/email.py new file mode 100644 index 0000000..1c6738b --- /dev/null +++ b/app/util/email.py @@ -0,0 +1,38 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import commonmark + +from app.util.settings import Settings + +if Settings().email.enable: + email = Settings().email.email + password = Settings().email.password.get_secret_value() + smtp_host = Settings().email.smtp_server + + +class Email: + """ + This function handles sending emails. + """ + + def send_email(subject, body, recipient): + if not Settings().email.enable: + return + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = email + msg["To"] = recipient + text = body + parser = commonmark.Parser() + ast = parser.parse(body) + renderer = commonmark.HtmlRenderer() + html = renderer.render(ast) + part1 = MIMEText(text, "plain") + part2 = MIMEText(html, "html") + msg.attach(part1) + msg.attach(part2) + with smtplib.SMTP_SSL(smtp_host, 465) as smtp_server: + smtp_server.login(email, password) + smtp_server.sendmail(email, recipient, msg.as_string()) diff --git a/util/errors.py b/app/util/errors.py similarity index 100% rename from util/errors.py rename to app/util/errors.py diff --git a/app/util/forms.py b/app/util/forms.py new file mode 100644 index 0000000..281b3d4 --- /dev/null +++ b/app/util/forms.py @@ -0,0 +1,72 @@ +import json +import logging +import os +from pathlib import Path +from typing import DefaultDict + +logger = logging.getLogger(__name__) + + +def is_path_allowed(user_path: str, allowed_dir: str) -> bool: + # Convert to absolute paths + user_path = Path(user_path).resolve() + allowed_dir = Path(allowed_dir).resolve() + + try: + # Check if the user path is within the allowed directory + user_path.relative_to(allowed_dir) + return True + except ValueError: + return False + + +class Forms: + def get_form_body(file="1"): + form_file = os.path.join(os.getcwd(), "app/forms", f"{file}.json") + allowed_paths = "app/forms" + if not is_path_allowed(form_file, allowed_paths): + logger.error("attempted to access unauthorized paths") + raise PermissionError("Access to the specified file is not allowed") + try: + return json.load(open(form_file, "r")) + except FileNotFoundError: + raise FileNotFoundError + + +def fuzzy_parse_value(value): + # Convert common boolean-like values + if isinstance(value, str): + value_test = value.lower() + if value_test in {"yes", "true", "1", "Yes", "i agree."}: + return True + if value_test in {"no", "false", "0", "No"}: + return False + if "i promise not" in value_test: + return True + if "i have read the terms and agree to them" in value_test: + return True + + # Convert other types as needed + + return value + + +def apply_fuzzy_parsing(data: dict): + """ + Converts form data from fuzzy boolean values like, yes, no, 'i promise not' into booleans + """ + parsed_data = {k: fuzzy_parse_value(v) for k, v in data.items()} + return parsed_data + + +def transform_dict(d): + """ + Turns the nested Models in the format nested_model.key1: "1" into nested_model: {key1: "1", key2: "2" } + """ + if not any("." in key for key in d): + return d + nested_dict = DefaultDict(dict) + for key, value in d.items(): + parent, child = key.split(".") + nested_dict[parent][child] = value + return nested_dict diff --git a/util/kennelish.py b/app/util/kennelish.py similarity index 90% rename from util/kennelish.py rename to app/util/kennelish.py index 8545c98..d14d7c0 100644 --- a/util/kennelish.py +++ b/app/util/kennelish.py @@ -1,5 +1,9 @@ -from typing import Literal, Set -from pydantic import BaseModel, create_model, constr +import logging +from typing import Literal + +from pydantic import constr, create_model + +logger = logging.getLogger(__name__) # Known bug: You cannot pre-fill data stored in second-level DynamoDB levels. @@ -31,8 +35,6 @@ def parse(obj, user_data=None): output += Kennelish.text(entry, user_data, "email") elif entry["input"] == "nid": output += Kennelish.text(entry, user_data, "nid") - elif entry["input"] == "tel": - output += Kennelish.text(entry, user_data, "tel") elif entry["input"] == "text": output += Kennelish.text(entry, user_data) elif entry["input"] == "radio": @@ -50,7 +52,7 @@ def parse(obj, user_data=None): else: output += Kennelish.invalid(entry) except Exception as e: - print(e) + logger.exception(e) output += Kennelish.invalid({"input": "Malformed object"}) continue @@ -67,7 +69,7 @@ def header(entry, user_data=None, tag="h1"): return output def signature(entry, user_data=None): - output = f"
By submitting this form, you, {user_data.get('first_name', 'HackUCF Member #' + user_data.get('id'))} {user_data.get('surname', '')}, agree to the above terms. This form will be time-stamped.
" + output = f"
By submitting this form, you, {user_data.get('first_name', 'HackUCF Member #' + str(user_data.get('id')))} {user_data.get('surname', '')}, agree to the above terms. This form will be time-stamped.
" return output def text(entry, user_data=None, inp_type="text"): @@ -82,7 +84,7 @@ def text(entry, user_data=None, inp_type="text"): else: prefill = user_data.get(key, "") - if prefill == None: + if prefill is None: prefill = "" else: prefill = "" @@ -96,8 +98,6 @@ def text(entry, user_data=None, inp_type="text"): ) elif inp_type == "nid": regex_pattern = ' pattern="^([a-z]{2}[0-9]{6})$"' - elif inp_type == "tel": - regex_pattern = ' pattern="^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$"' output = f"" return Kennelish.label(entry, output) @@ -199,9 +199,7 @@ def __init__(self): super(Transformer, self).__init__() def kwargs_to_str(kwargs): - print(dir(kwargs)) for k, v in kwargs.items(): - print(k, v) kwargs[k] = str(v) return kwargs @@ -209,7 +207,7 @@ def kwargs_to_str(kwargs): def kennelish_to_form(json): obj = {} - if json == None: + if json is None: return {} for el in json: @@ -227,34 +225,32 @@ def kennelish_to_form(json): # For emails (specified domain) elif element_type == "email" and el.get("domain", False): - regex_constr = constr( - regex="([A-Za-z0-9.-_+]+)@" + el.get("domain").lower() - ) + domain_regex = rf'^[A-Za-z0-9._%+-]+@{el.get("domain").lower()}$' + regex_constr = constr(pattern=domain_regex) obj[el.get("key")] = (regex_constr, None) # For emails (any domain) elif element_type == "email": regex_constr = constr( - regex="([A-Za-z0-9.-_+]+)@[A-Za-z0-9-]+(.[A-Za-z-]{2,})" + pattern=r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$" ) obj[el.get("key")] = (regex_constr, None) # For NIDs elif element_type == "nid": - regex_constr = constr(regex="(^([a-z]{2}[0-9]{6})$)") - obj[el.get("key")] = (regex_constr, None) - - # For phone numbers - elif element_type == "tel": - regex_constr = constr(regex="(^(\\+)?([0-9]{9,})$)") + regex_constr = constr(pattern="(^([a-z]{2}[0-9]{6})$)") obj[el.get("key")] = (regex_constr, None) # For numbers elif element_type == "slider": obj[el.get("key")] = (int, None) + # Timestamps + elif element_type == "signature": + obj[el.get("key")] = (int, None) + # For arbitrary strings. - elif el.get("key") != None: + elif el.get("key") is not None: obj[el.get("key")] = (str, None) return obj diff --git a/util/options.py b/app/util/options.py similarity index 100% rename from util/options.py rename to app/util/options.py diff --git a/util/plinko.py b/app/util/plinko.py similarity index 59% rename from util/plinko.py rename to app/util/plinko.py index bf3da2c..053799b 100644 --- a/util/plinko.py +++ b/app/util/plinko.py @@ -1,17 +1,17 @@ import json import requests -import boto3 -from boto3.dynamodb.conditions import Key, Attr +import logging +import uuid +from app.util.settings import Settings +from app.util.database import get_user, Session +from app.models.user import UserModel, DiscordModel + +from app.util.settings import Settings + + -from util.options import Options -options = Options.fetch() -headers = { - "Authorization": f"Bot {options.get('discord', {}).get('bot_token')}", - "Content-Type": "application/json", - "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", -} class Plinko: @@ -22,11 +22,12 @@ class Plinko: def __init__(self): pass + @staticmethod def check_elgible(user_data): data = { - "has_first_name": user_data.get("first_name") != None, - "has_last_name": user_data.get("last_name") != None, - "kh_checked": user_data.get("did_agree_to_do_kh") == True, + "has_first_name": user_data.first_name != None, + "has_last_name": user_data.last_name != None, + "kh_checked": user_data.did_agree_to_do_kh == True, } for value in data.values(): @@ -34,28 +35,26 @@ def check_elgible(user_data): return False, data return True, data - - def get_team(user_id): + @staticmethod + def get_team(session: Session, user_id): """ Get team information for a given user, including team-mates """ # Database connection to get user... - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - user_data = table.get_item(Key={"id": user_id}).get("Item", None) - user_team_number = user_data.get("team_number") - user_run = user_data.get("assigned_run") + user_data = get_user(session, uuid.UUID(user_id)) + + user_team_number = user_data.team_number + user_run = user_data.assigned_run if not user_team_number or not user_run: return None teammates = [] - all_users_with_team_number = table.scan( - FilterExpression=Attr("team_number").eq(user_team_number) - ).get("Items", None) + all_users_with_team_number = session.query(UserModel).filter(UserModel.team_number == user_team_number).all() + for user in all_users_with_team_number: if user.get("assigned_run") == user_run and user.get("waitlist") == 1: @@ -68,7 +67,8 @@ def get_team(user_id): return {"number": user_team_number, "run": user_run, "members": teammates} - def get_waitlist_status(plus_one=False): + @staticmethod + def get_waitlist_status(session: Session, plus_one=False): """ Return waitlist metadata as (current_count, status, group #) """ @@ -76,15 +76,12 @@ def get_waitlist_status(plus_one=False): waitlist_groups = 15 # 150, 180, 210, etc. hard_cap = 200 - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.scan(FilterExpression=Attr("waitlist").gt(0)).get( - "Items", None - ) # on a list + + data = session.query(UserModel).filter(UserModel.waitlist > 0).all() current_count = len(data) currently_registered = 0 for user in data: - if user.get("waitlist", 0) == 1: + if user.waitlist == 1: currently_registered += 1 if plus_one: diff --git a/app/util/settings.py b/app/util/settings.py new file mode 100644 index 0000000..3d2972e --- /dev/null +++ b/app/util/settings.py @@ -0,0 +1,329 @@ +import json +import logging +import os +import pathlib +import re +import subprocess +from typing import Optional + +import yaml +from pydantic import BaseModel, Field, SecretStr, constr, model_validator +from pydantic_settings import BaseSettings + +logger = logging.getLogger(__name__) + +config_file = pathlib.Path(os.getenv("ONBOARD_CONFIG_FILE", "config.yml")).resolve() +onboard_env = os.getenv("ONBOARD_ENV", "prod") + +if onboard_env == "dev": + import hashlib + import socket + + +def BitwardenConfig(settings: dict): + """ + Takes a dict of settings loaded from yaml and adds the secrets from bitwarden to the settings dict. + The bitwarden secrets are mapped to the settings dict using the bitwarden_mapping dict. + The secrets are sourced based on a project id in the settings dict. + """ + logger.debug("Loading secrets from Bitwarden") + try: + project_id = settings["bws"]["project_id"] + if bool(re.search("[^a-z0-9-]", project_id)): + raise ValueError("Invalid project id") + command = ["bws", "secret", "list", project_id, "--output", "json"] + env_vars = os.environ.copy() + bitwarden_raw = subprocess.run( + command, text=True, env=env_vars, capture_output=True + ).stdout + except Exception as e: + logger.exception(e) + raise e + bitwarden_settings = parse_json_to_dict(bitwarden_raw) + + bitwarden_mapping = { + "discord_bot_token": ("discord", "bot_token"), + "discord_client_id": ("discord", "client_id"), + "discord_secret": ("discord", "secret"), + "stripe_api_key": ("stripe", "api_key"), + "stripe_webhook_secret": ("stripe", "webhook_secret"), + "stripe_price_id": ("stripe", "price_id"), + "email_password": ("email", "password"), + "jwt_secret": ("jwt", "secret"), + "infra_wifi": ("infra", "wifi"), + "infra_application_credential_id": ("infra", "application_credential_id"), + "infra_configuration_credential_secret": ( + "infra", + "application_credential_secret", + ), + } + + bitwarden_mapped = {} + for bw_key, nested_keys in bitwarden_mapping.items(): + if bw_key in bitwarden_settings: + top_key, nested_key = nested_keys + if top_key not in bitwarden_mapped: + bitwarden_mapped[top_key] = {} + bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] + + for top_key, nested_dict in bitwarden_mapped.items(): + if top_key in settings: + for nested_key, value in nested_dict.items(): + settings[top_key][nested_key] = value + return settings + + +settings = dict() + +# Reads config from ../config/options.yml +if os.path.exists(config_file): + with open(config_file) as f: + settings.update(yaml.load(f, Loader=yaml.FullLoader)) +else: + logger.error("No config file found at: " + str(config_file)) + + +def parse_json_to_dict(json_string): + data = json.loads(json_string) + return {item["key"]: item["value"] for item in data} + + +# If bitwarden is enabled, add secrets to settings +if settings.get("bws", {}).get("enable"): + settings = BitwardenConfig(settings) + +logger.debug("Final settings: %s", settings) + + +class DiscordConfig(BaseModel): + """ + Represents the configuration settings for Discord integration. + + Attributes: + bot_token (SecretStr): The secret token for the Discord bot. + client_id (int): The client ID for the Discord application. + guild_id (int): The ID of the HackUCF discord server. + member_role (int): The ID of the role assigned to members. + redirect_base (str): The base URL for redirecting after authentication. + scope (str): The scope of permissions required for the Discord integration. + secret (SecretStr): The secret key for the Discord oauth. + enable (Optional[bool]): A flag indicating whether Discord integration is enabled. + """ + + bot_token: Optional[SecretStr] = Field(None) + client_id: Optional[int] = Field(None) + guild_id: Optional[int] = Field(None) + member_role: Optional[int] = Field(None) + redirect_base: Optional[str] = Field(None) + scope: Optional[str] = Field(None) + secret: Optional[SecretStr] = Field(None) + enable: Optional[bool] = Field(True) + organizer_guild_id: Optional[int] = Field(None) + + @model_validator(mode="after") + def check_required_fields(cls, values): + enable = values.enable + if enable: + required_fields = [ + "bot_token", + "client_id", + "guild_id", + "member_role", + "redirect_base", + "scope", + "secret", + "organizer_guild_id" + ] + for field in required_fields: + if getattr(values, field) is None: + raise ValueError(f"Discord {field} is required when enable is True") + return values + + +if settings.get("discord"): + discord_config = DiscordConfig(**settings["discord"]) +elif onboard_env == "dev": + discord_config = DiscordConfig(enable=False, redirect_base="localhost") +else: + logger.warn("Missing discord config") + + + + +class GoogleWalletConfig(BaseModel): + """ + #TODO fix docs + """ + + auth_json: Optional[SecretStr] = Field(None) + class_suffix: Optional[str] = Field(None) + issuer_id: Optional[str] = Field(None) + enable: Optional[bool] = Field(True) + + @model_validator(mode="after") + def check_required_fields(cls, values): + enable = values.enable + if enable: + required_fields = [ + "auth_json", + "issuer_id", + "class_suffix", + ] + for field in required_fields: + if getattr(values, field) is None: + raise ValueError( + f"Google Wallet {field} is required when pause_payments is True" + ) + return values + + +if settings.get("google_wallet"): + google_wallet_config = GoogleWalletConfig(**settings["google_wallet"]) +elif onboard_env == "dev": + google_wallet_config = GoogleWalletConfig(enable=False) +else: + logger.warn("Missing GWallet config") + + +class EmailConfig(BaseModel): + """ + Represents the configuration for an email. + + Attributes: + smtp_server (str): The SMTP server address. + email (str): The email address to send from also used as the login username. + password (SecretStr): The password for the email account. + enable (Optional[bool]): A flag indicating whether email integration is enabled. + """ + + smtp_server: Optional[str] = Field(None) + email: Optional[str] = Field(None) + password: Optional[SecretStr] = Field(None) + enable: Optional[bool] = Field(True) + + @model_validator(mode="after") + def check_required_fields(cls, values): + enable = values.enable + if enable: + required_fields = ["smtp_server", "email", "password"] + for field in required_fields: + if getattr(values, field) is None: + raise ValueError(f"Email {field} is required when enable is True") + return values + + +if settings.get("email"): + email_config = EmailConfig(**settings["email"]) +elif onboard_env == "dev": + email_config = EmailConfig(enable=False) +else: + logger.warn("Missing email config") + + +class JwtConfig(BaseModel): + """ + Configuration class for JWT (JSON Web Token) settings. + + Attributes: + secret (SecretStr): The secret key used for signing and verifying JWTs. + algorithm (str): The algorithm used for JWT encryption. + lifetime_user (int): The lifetime (in seconds) of a user JWT. + lifetime_sudo (int): The lifetime (in seconds) of a sudo JWT. + """ + + secret: SecretStr = constr(min_length=32) + algorithm: Optional[str] = Field("HS256") + lifetime_user: Optional[int] = Field(9072000) + lifetime_sudo: Optional[int] = Field(86400) + + +if settings.get("jwt"): + jwt_config = JwtConfig(**settings["jwt"]) +elif onboard_env == "dev": + # Provides a stable secret per dev instance, horribly insecure for prod + hostname = socket.gethostname() + secret = hashlib.sha256(hostname.encode("utf-8")).hexdigest() + jwt_config = JwtConfig(secret=secret) + + + +class KeycloakConfig(BaseModel): + username: Optional[str] = Field(None) + password: Optional[SecretStr] = Field(None) + url: Optional[str] = Field(None) + realm: Optional[str] = Field(None) + enable: Optional[bool] = Field(True) + + @model_validator(mode="after") + def check_required_fields(cls, values): + enable = values.enable + if enable: + required_fields = ["username", "password", "url", "realm"] + for field in required_fields: + if getattr(values, field) is None: + raise ValueError( + f"Keycloak {field} is required when enable is True" + ) + return values + + +if settings.get("keycloak"): + keycloak_config = KeycloakConfig(**settings["keycloak"]) +elif onboard_env == "dev": + keycloak_config = KeycloakConfig(enable=False) +else: + logger.warn("Missing Keycloak Config") + + +class TelemetryConfig(BaseModel): + url: Optional[str] = None + enable: Optional[bool] = False + env: Optional[str] = "dev" + + +telemetry_config = TelemetryConfig(**settings.get("telemetry", {})) + + +class DatabaseConfig(BaseModel): + url: str + + +if settings.get("database"): + database_config = DatabaseConfig(**settings["database"]) +elif onboard_env == "dev": + database_config = DatabaseConfig(url="sqlite:///:memory:") +else: + logger.warn("Missing database config") + + +class HttpConfig(BaseModel): + domain: str + + +if settings.get("http"): + http_config = HttpConfig(**settings["http"]) +elif onboard_env == "dev": + http_config = HttpConfig(domain="localhost:8000") +else: + logger.warn("Missing http config") + + +class SingletonBaseSettingsMeta(type(BaseSettings), type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta): + discord: DiscordConfig = discord_config + email: EmailConfig = email_config + jwt: JwtConfig = jwt_config + database: DatabaseConfig = database_config + http: HttpConfig = http_config + keycloak: KeycloakConfig = keycloak_config + google_wallet: GoogleWalletConfig = google_wallet_config + telemetry: Optional[TelemetryConfig] = telemetry_config + env: Optional[str] = onboard_env diff --git a/util/websockets.py b/app/util/websockets.py similarity index 100% rename from util/websockets.py rename to app/util/websockets.py diff --git a/database/database.db b/database/database.db new file mode 100644 index 0000000000000000000000000000000000000000..e2175673d28165e9b8d6b84c118d482a4abc71ba GIT binary patch literal 24576 zcmeI)O>g5w7zc2>?+J9f;u5h^RZD24MrG65j_n3%#io^83TeV70jXT9i9Kni@k_8L zWrM__1%&uMT=*&oi34AP3lg3*4NltK<-%cC|B>U2pRva?zZpexMoH^=jd7}Uqc|X3 z(WQryEKAQ6MUteI$t!&ur^R%?G|7uMIg@``c10>({=B~Nm$dxOFVe>P#;@=G{ouDW ziv$4(KmY;|fB*y_009X6M}h03<<-LD$MO#iPC7pISmH*p7epTQv-y?7rd@5@O1pYc zvz2U#vYpQ{PdTZ#?PI&C)EjN3{za|!p_0$hfG}VAvf4cSq}mjLpi7j4Mx$m|>*A1H z5>Ddm2^|uKbSC0PeiYxxXtr5wS7T_=z&P3tL`W!;;vOq~meXZbbpakDOV55*1%0uX=z1Rwwb2tWV=5P$##AOL~4RUj`q zhUfpcb$HP)2tWV=5P$##AOHafKmY;|fWTNltpCd!S5mqmK>z{}fB*y_009U<00Izz z00bcLe+hgizjwO%zP^@wvbm;8QchZ5Thr)1C6;RK5ko7xnnB7&w^Ok!s#BtuUF*~I z#|XSf!WJL-G@1N@v_%Qa!$TTaOW$szJZt9k4 z>19i-|H~V{OX-FL0SG_<0uX=z1Rwwb2tWV=5P-lNDDc7JY3_AjC5+eqAN(n$8xjN{ z009U<00Izz00bZa0SG|gO%}L*B(F<)?y7ZlwkQ`iPw!bT?{PjzKHA-Ny|6SXCWC=k zN#DKs!^>p%?$Y_LSFThldsOWhT8E0YdTo!GR)tn7mZ^FMDGkE&j}!JSeQK!MD{gV~ z51RMhf;eSrsKng&v`U4Td+NSxmMztEjjm-7jkx>&-b6KY^U;rgEYR@D@xetY;UkqM IuY3Og2U=+4o&W#< literal 0 HcmV?d00001 diff --git a/index.py b/index.py deleted file mode 100644 index beb953b..0000000 --- a/index.py +++ /dev/null @@ -1,424 +0,0 @@ -import json, re, uuid -import os -import requests - -from datetime import datetime, timedelta -import time -from typing import Optional, Union - -# FastAPI -from fastapi import Depends, FastAPI, HTTPException, status, Request, Response, Cookie -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from fastapi.templating import Jinja2Templates -from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse -from pydantic import BaseModel - -from jose import JWTError, jwt -from urllib.parse import urlparse -from requests_oauthlib import OAuth2Session - -import boto3 -from boto3.dynamodb.conditions import Key, Attr - -# Import the page rendering library -from util.kennelish import Kennelish - -# Import middleware -from util.authentication import Authentication - -# Import error handling -from util.errors import Errors - -# Import options -from util.options import Options -from util.plinko import Plinko - -options = Options.fetch() - -# Import data types -from models.user import UserModel - -# Import routes -from routes import api, admin, wallet, plinko - -### TODO: TEMP -os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" -### - - -# Initiate FastAPI. -app = FastAPI() -templates = Jinja2Templates(directory="templates") -app.mount("/static", StaticFiles(directory="static"), name="static") - -# Import endpoints from ./routes -app.include_router(api.router) -app.include_router(admin.router) -app.include_router(wallet.router) -app.include_router(plinko.router) - - -@app.get("/") -async def index(request: Request, token: Optional[str] = Cookie(None)): - """ - Home page. - """ - try: - payload = jwt.decode( - token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), - ) - - if payload.get("waitlist") and payload.get("waitlist") > 0: - return RedirectResponse("/profile/", status_code=status.HTTP_302_FOUND) - elif payload.get("sudo") == True: - return RedirectResponse("/profile/", status_code=status.HTTP_302_FOUND) - else: - return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) - except: - return RedirectResponse( - "/discord/new/?redir=/", status_code=status.HTTP_302_FOUND - ) - - -""" -Redirects to Discord for OAuth. -This is what is linked to by Onboard. -""" - - -@app.get("/discord/new/") -async def oauth_transformer(redir: str = "/join/2"): - # Open redirect check - hostname = urlparse(redir).netloc - print(hostname) - if hostname != "" and hostname != options.get("http", {}).get( - "domain", "my.hackucf.org" - ): - redir = "/join/2" - - oauth = OAuth2Session( - options.get("discord").get("client_id"), - redirect_uri=options.get("discord").get("redirect_base") + "_redir", - scope=options.get("discord").get("scope"), - ) - authorization_url, state = oauth.authorization_url( - "https://discord.com/api/oauth2/authorize" - ) - - rr = RedirectResponse(authorization_url, status_code=302) - - rr.set_cookie(key="redir_endpoint", value=redir) - - return rr - - -""" -Logs the user into Onboard via Discord OAuth and updates their Discord metadata. -This is what Discord will redirect to. -""" - - -@app.get("/api/oauth/") -async def oauth_transformer_new( - request: Request, - response: Response, - code: str = None, - redir: str = "/join/2", - redir_endpoint: Optional[str] = Cookie(None), -): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - hackucf_table = dynamodb.Table(options.get("aws").get("dynamodb").get("hackucf")) - - # Open redirect check - if redir == "_redir": - redir = redir_endpoint - - hostname = urlparse(redir).netloc - - if hostname != "" and hostname != options.get("http", {}).get( - "domain", "my.hackucf.org" - ): - redir = "/join/2" - - if code is None: - return Errors.generate( - request, - 401, - "You declined Discord log-in", - essay="We need your Discord account to sign up for the Horse Plinko Cyber Challenge.", - ) - - # Get data from Discord - oauth = OAuth2Session( - options.get("discord").get("client_id"), - redirect_uri=options.get("discord").get("redirect_base") + "_redir", - scope=options.get("discord")["scope"], - ) - - token = oauth.fetch_token( - "https://discord.com/api/oauth2/token", - client_id=options.get("discord").get("client_id"), - client_secret=options.get("discord").get("secret"), - # authorization_response=code - code=code, - ) - - r = oauth.get("https://discord.com/api/users/@me") - discordData = r.json() - - # Generate a new user ID or reuse an existing one. - query_for_id = table.scan( - FilterExpression=Attr("discord_id").eq(str(discordData["id"])) - ) - query_for_id = query_for_id.get("Items") - - # BACKPORT: I didn't realize that Snowflakes were strings because of an integer overflow bug. - # So this will do a query for the "mistaken" value and then fix its data. - hackucf_data = None - - if not query_for_id: - # Check Hack@UCF Onboard - print("Federating with Onboard...") - hackucf_query = hackucf_table.scan( - FilterExpression=Attr("discord_id").eq(str(discordData["id"])) - ) - hackucf_query = hackucf_query.get("Items") - - if not hackucf_query: - hackucf_query = hackucf_table.scan( - FilterExpression=Attr("discord_id").eq(int(discordData["id"])) - ) - hackucf_query = hackucf_query.get("Items") - - print(hackucf_query) - - if hackucf_query: - hackucf_federated_experience = 0 - if hackucf_query[0].get("experience"): - hackucf_federated_experience = int( - hackucf_query[0].get("experience", 0) - ) - - hackucf_data = { - "first_name": hackucf_query[0].get("first_name"), - "last_name": hackucf_query[0].get("surname"), - "email": hackucf_query[0].get("email"), - "experience": hackucf_federated_experience, - "hackucf_id": hackucf_query[0].get("id"), - "sudo": hackucf_query[0].get("sudo", False), - } - # table.update_item( - # Key={"id": query_for_id[0].get("id")}, - # UpdateExpression="SET discord_id = :discord_id", - # ExpressionAttributeValues={":discord_id": str(discordData["id"])}, - # ) - - is_new = False - print(query_for_id) - - if query_for_id: - query_for_id = query_for_id[0] - member_id = query_for_id.get("id") - do_sudo = query_for_id.get("sudo") - is_full_member = query_for_id.get("is_full_member") - infra_email = query_for_id.get("infra_email", "") - else: - member_id = str(uuid.uuid4()) - do_sudo = False - is_new = True - infra_email = "" - - # Make user join the Hack@UCF Discord, if it's their first rodeo. - discord_id = str(discordData["id"]) - headers = { - "Authorization": f"Bot {options.get('discord', {}).get('bot_token')}", - "Content-Type": "application/json", - "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", - } - put_join_guild = {"access_token": token["access_token"]} - req = requests.put( - f"https://discordapp.com/api/guilds/{options.get('discord', {}).get('guild_id')}/members/{discord_id}", - headers=headers, - data=json.dumps(put_join_guild), - ) - - data = { - "id": member_id, - "discord_id": int(discordData["id"]), - "discord": { - "email": discordData["email"], - "mfa": discordData["mfa_enabled"], - "avatar": f"https://cdn.discordapp.com/avatars/{discordData['id']}/{discordData['avatar']}.png?size=512", - "banner": f"https://cdn.discordapp.com/banners/{discordData['id']}/{discordData['banner']}.png?size=1536", - "color": discordData["accent_color"], - "nitro": discordData["public_flags"], - "locale": discordData["locale"], - "username": discordData["username"], - }, - "email": discordData["email"] - ## Consider making this a separate table. - # "attendance": None # t/f based on dict/object keyed on iso-8601 date. - } - - if hackucf_data: - data = dict(hackucf_data, **data) - print(f"Merged data with Hack@UCF data: {data}") - - # Populate the full table. - full_data = UserModel(**data).dict() - - # Push data back to DynamoDB - if is_new: - table.put_item(Item=full_data) - else: - table.update_item( - Key={"id": member_id}, - UpdateExpression="SET discord = :discord", - ExpressionAttributeValues={":discord": full_data["discord"]}, - ) - - # Create JWT. This should be the only way to issue JWTs. - jwtData = { - "discord": token, - "name": discordData["username"], - "pfp": full_data["discord"]["avatar"], - "id": member_id, - "sudo": do_sudo, - "issued": time.time(), - } - bearer = jwt.encode( - jwtData, - options.get("jwt").get("secret"), - algorithm=options.get("jwt").get("algorithm"), - ) - rr = RedirectResponse(redir, status_code=status.HTTP_302_FOUND) - rr.set_cookie(key="token", value=bearer) - - # Clear redirect cookie. - rr.delete_cookie("redir_endpoint") - - return rr - - -""" -Renders the landing page for the sign-up flow. -""" - - -@app.get("/join/") -async def join(request: Request, token: Optional[str] = Cookie(None)): - signups, wl_status, group = Plinko.get_waitlist_status() - if token == None: - return templates.TemplateResponse( - "signup.html", {"request": request, "waitlist_status": wl_status} - ) - else: - try: - payload = jwt.decode( - token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), - ) - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) - - if user_data.get("waitlist") and user_data.get("waitlist") > 0: - return RedirectResponse("/profile", status_code=status.HTTP_302_FOUND) - - except Exception as e: - pass - - return RedirectResponse("/join/2/", status_code=status.HTTP_302_FOUND) - - -""" -Renders a basic "my membership" page -""" - - -@app.get("/profile/") -@Authentication.member -async def profile( - request: Request, - token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, -): - # Get data from DynamoDB - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) - - team_data = Plinko.get_team(payload.get("id")) - - return templates.TemplateResponse( - "profile.html", - {"request": request, "user_data": user_data, "team_data": team_data}, - ) - - -""" -Renders a Kennelish form page, complete with stylings and UI controls. -""" - - -@app.get("/join/{num}/") -@Authentication.member -async def forms( - request: Request, - token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, - num: str = 1, -): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - if num == "1": - return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) - - data = Options.get_form_body(num) - - # Get data from DynamoDB - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) - - # Have Kennelish parse the data. - body = Kennelish.parse(data, user_data) - - # return num - return templates.TemplateResponse( - "form.html", - { - "request": request, - "icon": payload["pfp"], - "name": payload["name"], - "id": payload["id"], - "body": body, - }, - ) - - -@app.get("/final") -async def final(request: Request): - return templates.TemplateResponse("done.html", {"request": request}) - - -@app.get("/logout") -async def logout(request: Request): - rr = RedirectResponse("/", status_code=status.HTTP_302_FOUND) - rr.delete_cookie(key="token") - return rr - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/models/user.py b/models/user.py deleted file mode 100644 index fcc6ec4..0000000 --- a/models/user.py +++ /dev/null @@ -1,100 +0,0 @@ -from pydantic import BaseModel -from typing import Optional - - -class DiscordModel(BaseModel): - email: Optional[str] = None - mfa: Optional[bool] = None - avatar: Optional[str] = None - banner: Optional[str] = None - color: Optional[int] = None - nitro: Optional[int] = None - locale: Optional[str] = None - username: str - - -class UserModel(BaseModel): - # Identifiers - id: str - - # PII - first_name: Optional[str] = "" - last_name: Optional[str] = "" - email: Optional[str] = "" - did_get_shirt: Optional[bool] = False - - # The MLH required stuff - did_agree_to_do_kh: Optional[bool] = False # must be True to compete - - # HPCC data (user-mutable) - team_name: Optional[str] = "" - availability: Optional[str] = "" - - # Permissions and Member Status - sudo: Optional[bool] = False - - # Collected from Discord - discord_id: str - discord: DiscordModel - - # Collected from HackUCF Onboard - hackucf_id: Optional[str] = None - experience: Optional[int] = None - - # HPCC data (internal) - waitlist: Optional[int] = None - team_number: Optional[int] = None - assigned_run: Optional[str] = "" - - checked_in: Optional[bool] = False - - did_sign_photo_release: Optional[bool] = False - - did_get_shirt: Optional[bool] = False - - -# What admins can edit. -class UserModelMutable(BaseModel): - # Identifiers - id: str - - # PII - first_name: Optional[str] - last_name: Optional[str] - email: Optional[str] - shirt_size: Optional[str] - - # The MLH required stuff - did_agree_to_do_kh: Optional[bool] # must be True to compete - - # HPCC data (user-mutable) - team_name: Optional[str] - availability: Optional[str] - - # Permissions and Member Status - sudo: Optional[bool] - - # Collected from Discord - discord_id: Optional[str] - discord: Optional[DiscordModel] - - # Collected from HackUCF Onboard - hackucf_id: Optional[str] - experience: Optional[int] - - # HPCC data (internal) - waitlist: Optional[int] - team_number: Optional[int] - assigned_run: Optional[str] - - checked_in: Optional[bool] - - did_sign_photo_release: Optional[bool] - - did_get_shirt: Optional[bool] - - -class PublicContact(BaseModel): - first_name: str - surname: str - ops_email: str diff --git a/requirements.txt b/requirements.txt index 131546e..f2df37e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ pydantic==1.10.10 python-jose requests_oauthlib gspread -boto3 botocore stripe uvicorn[standard] diff --git a/routes/admin.py b/routes/admin.py deleted file mode 100644 index ba19436..0000000 --- a/routes/admin.py +++ /dev/null @@ -1,224 +0,0 @@ -import boto3, json, requests -from boto3.dynamodb.conditions import Key, Attr - -from jose import JWTError, jwt - -from fastapi import APIRouter, Cookie, Request, Response, Body -from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse -from fastapi.encoders import jsonable_encoder - -from pydantic import validator, error_wrappers - -from typing import Optional, Any -from models.user import UserModelMutable -from models.info import InfoModel - -from util.authentication import Authentication -from util.errors import Errors -from util.options import Options -from util.discord import Discord -from util.kennelish import Kennelish, Transformer - -options = Options.fetch() - -templates = Jinja2Templates(directory="templates") - -router = APIRouter(prefix="/admin", tags=["Admin"], responses=Errors.basic_http()) - - -@router.get("/") -@Authentication.admin -async def admin(request: Request, token: Optional[str] = Cookie(None)): - """ - Renders the Admin home page. - """ - payload = jwt.decode( - token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), - ) - return templates.TemplateResponse( - "admin_searcher.html", - { - "request": request, - "icon": payload["pfp"], - "name": payload["name"], - "id": payload["id"], - }, - ) - - -@router.get("/get/") -@Authentication.admin -async def admin_get_single( - request: Request, - token: Optional[str] = Cookie(None), - member_id: Optional[str] = "FAIL", -): - """ - API endpoint that gets a specific user's data as JSON - """ - if member_id == "FAIL": - return {"data": {}, "error": "Missing ?member_id"} - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.get_item(Key={"id": member_id}).get("Item", None) - - if not data: - return Errors.generate(request, 404, "User Not Found") - - return {"data": data} - - -@router.get("/get_by_snowflake/") -@Authentication.admin -async def admin_get_snowflake( - request: Request, - token: Optional[str] = Cookie(None), - discord_id: Optional[str] = "FAIL", -): - """ - API endpoint that gets a specific user's data as JSON, given a Discord snowflake. - Designed for trusted federated systems to exchange data. - """ - if discord_id == "FAIL": - return {"data": {}, "error": "Missing ?discord_id"} - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.scan(FilterExpression=Attr("discord_id").eq(str(discord_id))).get( - "Items" - ) - - print(data) - - if not data: - # Try a legacy-user-ID search (deprecated, but still neccesary) - data = table.scan(FilterExpression=Attr("discord_id").eq(int(discord_id))).get( - "Items" - ) - print(data) - - if not data: - return Errors.generate(request, 404, "User Not Found") - - data = data[0] - - return {"data": data} - - -@router.post("/message/") -@Authentication.admin -async def admin_post_discord_message( - request: Request, - token: Optional[str] = Cookie(None), - member_id: Optional[str] = "FAIL", - payload: dict = Body(None), -): - """ - API endpoint that gets a specific user's data as JSON - """ - if member_id == "FAIL": - return {"data": {}, "error": "Missing ?member_id"} - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.get_item(Key={"id": member_id}).get("Item", None) - - if not data: - return Errors.generate(request, 404, "User Not Found") - - message_text = payload.get("msg") - - res = Discord.send_message(data.get("discord_id"), message_text) - - if res: - return {"msg": "Message sent."} - else: - return {"msg": "An error occured!"} - - -@router.post("/get/") -@Authentication.admin -async def admin_edit( - request: Request, - token: Optional[str] = Cookie(None), - input_data: Optional[UserModelMutable] = {}, -): - """ - API endpoint that modifies a given user's data - """ - member_id = input_data.id - - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - old_data = table.get_item(Key={"id": member_id}).get("Item", None) - - if not old_data: - return Errors.generate(request, 404, "User Not Found") - - # Take Pydantic data -> dict -> strip null values - new_data = {k: v for k, v in jsonable_encoder(input_data).items() if v is not None} - - # Existing U Provided - union = {**old_data, **new_data} - - # This is how this works: - # 1. Get old data - # 2. Get new data (pydantic-validated) - # 3. Union the two - # 4. Put back as one giant entry - - table.put_item(Item=union) - - return {"data": union, "msg": "Updated successfully!"} - - -@router.get("/list") -@Authentication.admin -async def admin_list(request: Request, token: Optional[str] = Cookie(None)): - """ - API endpoint that dumps all users as JSON. - """ - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.scan().get("Items", None) - return {"data": data} - - -@router.get("/csv") -@Authentication.admin -async def admin_list(request: Request, token: Optional[str] = Cookie(None)): - """ - API endpoint that dumps all users as CSV. - """ - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - data = table.scan().get("Items", None) - - output = "Membership ID, First Name, Last Name, NID, Is Returning, Gender, Major, Class Standing, Shirt Size, Discord Username, Experience, Cyber Interests, Event Interest, Is C3 Interest, Comments, Ethics Form Timestamp, Minecraft, Infra Email\n" - for user in data: - output += f'"{user.get("id")}", ' - output += f'"{user.get("first_name")}", ' - output += f'"{user.get("surname")}", ' - output += f'"{user.get("nid")}", ' - output += f'"{user.get("is_returning")}", ' - output += f'"{user.get("gender")}", ' - output += f'"{user.get("major")}", ' - output += f'"{user.get("class_standing")}", ' - output += f'"{user.get("shirt_size")}", ' - output += f'"{user.get("discord", {}).get("username")}", ' - output += f'"{user.get("experience")}", ' - output += f'"{user.get("curiosity")}", ' - output += f'"{user.get("attending")}", ' - output += f'"{user.get("c3_interest")}", ' - - output += f'"{user.get("comments")}", ' - - output += f'"{user.get("ethics_form", {}).get("signtime")}", ' - output += f'"{user.get("minecraft")}", ' - output += f'"{user.get("infra_email")}"\n' - - return Response(content=output, headers={"Content-Type": "text/csv"}) diff --git a/routes/api.py b/routes/api.py deleted file mode 100644 index 2547524..0000000 --- a/routes/api.py +++ /dev/null @@ -1,188 +0,0 @@ -import boto3 -from botocore.exceptions import ClientError - -from fastapi import APIRouter, Cookie, Request -from fastapi.responses import HTMLResponse - -from pydantic import validator, error_wrappers - -from typing import Optional -from models.user import PublicContact -from models.info import InfoModel - -from util.authentication import Authentication -from util.errors import Errors -from util.options import Options -from util.kennelish import Kennelish, Transformer - -options = Options.fetch() - -router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http()) - - -""" -Get API information. -""" - - -@router.get("/") -async def get_root(): - return InfoModel( - name="OnboardLite", - description="Hack@UCF's in-house membership management suite.", - credits=[ - PublicContact( - first_name="Jeffrey", - surname="DiVincent", - ops_email="jdivincent@hackucf.org", - ) - ], - ) - - -""" -Gets the JSON markup for a Kennelish file. For client-side rendering (if that ever becomes a thing). -Note that Kennelish form files are NOT considered sensitive. -""" - - -@router.get("/form/{num}") -async def get_form(num: str): - return Options.get_form_body(num) - - -""" -Renders a Kennelish form file as HTML (with user data). Intended for AJAX applications. -""" - - -@router.get("/form/{num}/html", response_class=HTMLResponse) -@Authentication.member -async def get_form_html( - request: Request, - token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, - num: str = 1, -): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - # Get form object - data = Options.get_form_body(num) - - # Get data from DynamoDB - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) - - # Have Kennelish parse the data. - body = Kennelish.parse(data, user_data) - - return body - - -""" -Allows updating the user's database using a schema assumed by the Kennelish file. -""" - - -@router.post("/form/{num}") -@Authentication.member -async def post_form( - request: Request, - token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, - num: str = 1, -): - # Get Kennelish data - kennelish_data = Options.get_form_body(num) - model = Transformer.kennelish_to_pydantic(kennelish_data) - - try: - inp = await request.json() - except: - return {"description": "Malformed JSON input."} - - try: - validated = model(**inp) - except error_wrappers.ValidationError as e: - return {"description": "Malformed input."} - - # Remove items we did not update - items_to_update = list(validated.dict().items()) - items_to_keep = [] - for item in items_to_update: - if item[1] != None: - # English -> Boolean - if ( - item[1] == "Yes" - or item[1] == "I promise not to do this." - or item[1] == "I agree." - or item[1] == "I am okay with being messaged by MLH." - ): - item = (item[0], True) - elif ( - item[1] == "No" - or item[1] == "I do NOT agree." - or item[1] - == "I disagree with this and do not wish to be part of Hack@UCF" - ): - item = (item[0], False) - - items_to_keep.append(item) - - update_expression = "SET " - expression_attribute_values = {} - - # Here, the variable 'items_to_keep' is validated input. We can update the user's profile from here. - - # Prepare to update to DynamoDB - for item in items_to_keep: - update_expression += f"{item[0]} = :{item[0].replace('.', '_')}, " - expression_attribute_values[f":{item[0].replace('.', '_')}"] = item[1] - - # Strip last comma for update_expression - update_expression = update_expression[:-2] - - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) - - # Push data back to DynamoDB - try: - table.update_item( - Key={"id": payload.get("id")}, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_attribute_values, - ) - except ClientError as e: - # We need to do a migration on *something*. We know it's a subtype. - # So we will find it and migrate it. - print("MIGRATION TIEM") - print(e) - - for item in items_to_keep: - if "." in item[0]: - dot_loc = item[0].find(".") - key_to_make = item[0][:dot_loc] - - print(update_expression) - print(":3") - print(expression_attribute_values) - - # Create dictionary - table.update_item( - Key={"id": payload.get("id")}, - # key_to_make is not user-supplied, rather, it's from the form JSON. - # if this noSQLi's, then it's because of an insider threat. - UpdateExpression=f"SET {key_to_make} = :dicty", - ExpressionAttributeValues={":dicty": {}}, - ) - - # After all dicts are a thing, re-run query. - table.update_item( - Key={"id": payload.get("id")}, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_attribute_values, - ) - - return validated From b6e50d9dfc82a48da66248f3270bb76110ae2acc Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 14:11:37 -0400 Subject: [PATCH 2/6] Added Precommit and workflows --- .github/workflows/codeql.yml | 91 ++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 40 +++++++++++ .github/workflows/gitleaks-baseline.json | 42 +++++++++++ .github/workflows/pre-commit.yaml | 22 ++++++ .github/workflows/semgrep.yml | 42 +++++++++++ .pre-commit-config.yaml | 26 +++++++ 6 files changed, 263 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/gitleaks-baseline.json create mode 100644 .github/workflows/pre-commit.yaml create mode 100644 .github/workflows/semgrep.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ce01239 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,91 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "dev", "main" ] + pull_request: + branches: [ "dev", "main" ] + schedule: + - cron: '26 9 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..91536c3 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,40 @@ +name: CI/CD Workflow + +on: + push: + branches: + - main + - dev + schedule: + - cron: '21 18 * * 3' + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract branch name + id: extract_branch + run: echo "::set-output name=branch::$(echo ${GITHUB_REF#refs/heads/})" + + - name: Build Docker image + run: docker build --target prod -t onboardlite:${{ steps.extract_branch.outputs.branch }} . + + - name: Tag Docker image + run: docker tag onboardlite:${{ steps.extract_branch.outputs.branch }} ghcr.io/hackucf/onboardlite:${{ steps.extract_branch.outputs.branch }} + + - name: Push Docker image + run: docker push ghcr.io/hackucf/onboardlite:${{ steps.extract_branch.outputs.branch }} diff --git a/.github/workflows/gitleaks-baseline.json b/.github/workflows/gitleaks-baseline.json new file mode 100644 index 0000000..62d966f --- /dev/null +++ b/.github/workflows/gitleaks-baseline.json @@ -0,0 +1,42 @@ +[ + { + "Description": "Identified a Discord client ID, which may lead to unauthorized integrations and data exposure in Discord applications.", + "StartLine": 27, + "EndLine": 27, + "StartColumn": 28, + "EndColumn": 61, + "Match": "discord_id == \"669276074563666347\"", + "Secret": "669276074563666347", + "File": "tests/test_user.py", + "SymlinkFile": "", + "Commit": "7522882299ff338c63e58ebe64c69e4a18168211", + "Entropy": 2.5917933, + "Author": "Jonathan Styles", + "Email": "64489881+jontyms@users.noreply.github.com", + "Date": "2024-05-27T19:01:27Z", + "Message": "Switch to SQLModel Database stuff (#93)", + "Tags": [], + "RuleID": "discord-client-id", + "Fingerprint": "7522882299ff338c63e58ebe64c69e4a18168211:tests/test_user.py:discord-client-id:27" + }, + { + "Description": "Identified a Discord client ID, which may lead to unauthorized integrations and data exposure in Discord applications.", + "StartLine": 56, + "EndLine": 56, + "StartColumn": 10, + "EndColumn": 40, + "Match": "discord_id=\"669276074563666347\"", + "Secret": "669276074563666347", + "File": "tests/conftest.py", + "SymlinkFile": "", + "Commit": "7522882299ff338c63e58ebe64c69e4a18168211", + "Entropy": 2.5917933, + "Author": "Jonathan Styles", + "Email": "64489881+jontyms@users.noreply.github.com", + "Date": "2024-05-27T19:01:27Z", + "Message": "Switch to SQLModel Database stuff (#93)", + "Tags": [], + "RuleID": "discord-client-id", + "Fingerprint": "7522882299ff338c63e58ebe64c69e4a18168211:tests/conftest.py:discord-client-id:56" + } +] diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..0f6d0bd --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,22 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [dev, main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + cache: 'pip' + - run: python -m pip install -r requirements.txt && python -m pip install -r requirements-dev.txt + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit/ + key: pre-commit-4|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - run: echo "SKIP=semgrep" >> $GITHUB_ENV + - run: pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..ff91679 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,42 @@ +# Name of this GitHub Actions workflow. +name: Semgrep OSS scan + +on: + # Scan changed files in PRs (diff-aware scanning): + pull_request: {} + # Scan on-demand through GitHub Actions interface: + workflow_dispatch: {} + # Scan mainline branches and report all findings: + push: + branches: ["master", "main", "dev"] + # Schedule the CI job (this method uses cron syntax): + schedule: + - cron: '14 21 * * *' + +jobs: + semgrep: + # User definable name of this GitHub Actions job. + name: semgrep-oss/scan + # If you are self-hosting, change the following `runs-on` value: + runs-on: ubuntu-latest + + container: + # A Docker image with Semgrep installed. Do not change this. + image: semgrep/semgrep + + # Skip any PR created by dependabot to avoid permission issues: + if: (github.actor != 'dependabot[bot]') + + steps: + # Fetch project source with GitHub Actions Checkout. + - uses: actions/checkout@v4 + # Run the "semgrep scan" command on the command line of the docker image. + - run: semgrep scan --config auto --sarif > findings.sarif + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + # Path to SARIF file relative to the root of the repository + sarif_file: findings.sarif + # Optional category for the results + # Used to differentiate multiple results for one commit + category: semgrep-oss diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b9efee7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.1 + hooks: + - id: ruff-format + - id: ruff + exclude: ^tests/codegen/snapshots/python/ + args: [--select, I, "--fix"] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + files: '^docs/.*\.mdx?$' + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: check-merge-conflict + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.4 + hooks: + - id: gitleaks + args: ["--baseline-path", ".github/workflows/gitleaks-baseline.json"] From 2e07f79de387fa850e28e5d77e6076058d98ed02 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 14:12:18 -0400 Subject: [PATCH 3/6] Ruff format --- app/index.py | 135 ++++++++++-------- .../versions/3337a749d431_initial_commit.py | 81 ++++++----- app/models/user.py | 1 - app/routes/api.py | 2 +- app/routes/plinko.py | 51 ++++--- app/routes/wallet.py | 16 +-- app/util/authentication.py | 9 +- app/util/database.py | 6 +- app/util/discord.py | 33 +++-- app/util/plinko.py | 22 ++- app/util/settings.py | 5 +- app/util/websockets.py | 4 +- 12 files changed, 192 insertions(+), 173 deletions(-) diff --git a/app/index.py b/app/index.py index 83547de..76c5f20 100644 --- a/app/index.py +++ b/app/index.py @@ -1,51 +1,50 @@ -import json, re, uuid -import os -import requests +import json import logging - -from datetime import datetime, timedelta +import os +import re import time +import uuid +from datetime import datetime, timedelta from typing import Optional, Union +from urllib.parse import urlparse + +import requests # FastAPI -from fastapi import Depends, FastAPI, HTTPException, status, Request, Response, Cookie +from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles -from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from jose import JWTError, jwt from pydantic import BaseModel +from requests_oauthlib import OAuth2Session from sqlalchemy.orm import selectinload from sqlmodel import Session, select -from jose import JWTError, jwt -from urllib.parse import urlparse -from requests_oauthlib import OAuth2Session - +# Import data types +from app.models.user import DiscordModel, UserModel, user_to_dict -# Import the page rendering library -from app.util.kennelish import Kennelish +# Import routes +from app.routes import admin, api, plinko, wallet # Import middleware from app.util.authentication import Authentication -from app.util.database import get_session, init_db -from app.util.forms import Forms + +# import db functions +from app.util.database import get_session, get_user, get_user_discord, init_db +from app.util.discord import Discord # Import error handling from app.util.errors import Errors +from app.util.forms import Forms -# Import options -from app.util.settings import Settings +# Import the page rendering library +from app.util.kennelish import Kennelish from app.util.plinko import Plinko -from app.util.discord import Discord - -# Import data types -from app.models.user import UserModel, DiscordModel, user_to_dict - -# import db functions -from app.util.database import get_session, init_db, get_user, get_user_discord -# Import routes -from app.routes import api, admin, wallet, plinko +# Import options +from app.util.settings import Settings # Init Logger logging.basicConfig( @@ -67,6 +66,7 @@ app.include_router(wallet.router) app.include_router(plinko.router) + @app.get("/") async def index(request: Request, token: Optional[str] = Cookie(None)): """ @@ -92,11 +92,13 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): "/discord/new/?redir=/", status_code=status.HTTP_302_FOUND ) + """ Redirects to Discord for OAuth. This is what is linked to by Onboard. """ + @app.get("/discord/new/") async def oauth_transformer(redir: str = "/join/2"): # Open redirect check @@ -120,11 +122,13 @@ async def oauth_transformer(redir: str = "/join/2"): return rr + """ Logs the user into Onboard via Discord OAuth and updates their Discord metadata. This is what Discord will redirect to. """ + @app.get("/api/oauth/") async def oauth_transformer_new( request: Request, @@ -134,7 +138,6 @@ async def oauth_transformer_new( redir_endpoint: Optional[str] = Cookie(None), session: Session = Depends(get_session), ): - # Open redirect check if redir == "_redir": redir = redir_endpoint or "/join/2" @@ -176,38 +179,37 @@ async def oauth_transformer_new( is_new = False - if user: member_id = user.id do_sudo = user.sudo else: - if not discordData.get("verified"): - tr = Errors.generate( - request, - 403, - "Discord email not verfied please try again", - ) - return tr - infra_email = "" - discord_id = discordData["id"] - Discord().join_plinko_server(discord_id, token) - user = UserModel(discord_id=discord_id) - discord_data = { - "email": discordData.get("email"), - "mfa": discordData.get("mfa_enabled"), - "avatar": f"https://cdn.discordapp.com/avatars/{discordData['id']}/{discordData['avatar']}.png?size=512", - "banner": f"https://cdn.discordapp.com/banners/{discordData['id']}/{discordData['banner']}.png?size=1536", - "color": discordData.get("accent_color"), - "nitro": discordData.get("premium_type"), - "locale": discordData.get("locale"), - "username": discordData.get("username"), - "user_id": user.id, - } - discord_model = DiscordModel(**discord_data) - user.discord = discord_model - session.add(user) - session.commit() - session.refresh(user) + if not discordData.get("verified"): + tr = Errors.generate( + request, + 403, + "Discord email not verfied please try again", + ) + return tr + infra_email = "" + discord_id = discordData["id"] + Discord().join_plinko_server(discord_id, token) + user = UserModel(discord_id=discord_id) + discord_data = { + "email": discordData.get("email"), + "mfa": discordData.get("mfa_enabled"), + "avatar": f"https://cdn.discordapp.com/avatars/{discordData['id']}/{discordData['avatar']}.png?size=512", + "banner": f"https://cdn.discordapp.com/banners/{discordData['id']}/{discordData['banner']}.png?size=1536", + "color": discordData.get("accent_color"), + "nitro": discordData.get("premium_type"), + "locale": discordData.get("locale"), + "username": discordData.get("username"), + "user_id": user.id, + } + discord_model = DiscordModel(**discord_data) + user.discord = discord_model + session.add(user) + session.commit() + session.refresh(user) # Create JWT. This should be the only way to issue JWTs. jwtData = { @@ -252,12 +254,18 @@ async def oauth_transformer_new( return rr + """ Renders the landing page for the sign-up flow. """ + @app.get("/join/") -async def join(request: Request, token: Optional[str] = Cookie(None), session: Session = Depends(get_session)): +async def join( + request: Request, + token: Optional[str] = Cookie(None), + session: Session = Depends(get_session), +): signups, wl_status, group = Plinko.get_waitlist_status(session) if token is None: return templates.TemplateResponse( @@ -281,10 +289,12 @@ async def join(request: Request, token: Optional[str] = Cookie(None), session: S return RedirectResponse("/join/2/", status_code=status.HTTP_302_FOUND) + """ Renders a basic "my membership" page """ + @app.get("/profile/") @Authentication.member async def profile( @@ -300,13 +310,19 @@ async def profile( return templates.TemplateResponse( "profile.html", - {"request": request, "user_data": user_to_dict(user_data), "team_data": team_data}, + { + "request": request, + "user_data": user_to_dict(user_data), + "team_data": team_data, + }, ) + """ Renders a Kennelish form page, complete with stylings and UI controls. """ + @app.get("/join/{num}/") @Authentication.member async def forms( @@ -340,16 +356,19 @@ async def forms( }, ) + @app.get("/final") async def final(request: Request): return templates.TemplateResponse("done.html", {"request": request}) + @app.get("/logout") async def logout(request: Request): rr = RedirectResponse("/", status_code=status.HTTP_302_FOUND) rr.delete_cookie(key="token") return rr + if __name__ == "__main__": import uvicorn diff --git a/app/migrations/versions/3337a749d431_initial_commit.py b/app/migrations/versions/3337a749d431_initial_commit.py index d0868ac..10a9437 100644 --- a/app/migrations/versions/3337a749d431_initial_commit.py +++ b/app/migrations/versions/3337a749d431_initial_commit.py @@ -1,19 +1,19 @@ """Initial Commit Revision ID: 3337a749d431 -Revises: +Revises: Create Date: 2024-08-23 13:09:07.970763 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa import sqlmodel - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '3337a749d431' +revision: str = "3337a749d431" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,45 +21,50 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('usermodel', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('did_get_shirt', sa.Boolean(), nullable=True), - sa.Column('did_agree_to_do_kh', sa.Boolean(), nullable=True), - sa.Column('team_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('availability', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('sudo', sa.Boolean(), nullable=True), - sa.Column('discord_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('hackucf_id', sa.Uuid(), nullable=True), - sa.Column('experience', sa.Integer(), nullable=True), - sa.Column('waitlist', sa.Integer(), nullable=True), - sa.Column('team_number', sa.Integer(), nullable=True), - sa.Column('assigned_run', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('checked_in', sa.Boolean(), nullable=True), - sa.Column('did_sign_photo_release', sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint('id') + op.create_table( + "usermodel", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("first_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("last_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("did_get_shirt", sa.Boolean(), nullable=True), + sa.Column("did_agree_to_do_kh", sa.Boolean(), nullable=True), + sa.Column("team_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("availability", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("sudo", sa.Boolean(), nullable=True), + sa.Column("discord_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("hackucf_id", sa.Uuid(), nullable=True), + sa.Column("experience", sa.Integer(), nullable=True), + sa.Column("waitlist", sa.Integer(), nullable=True), + sa.Column("team_number", sa.Integer(), nullable=True), + sa.Column("assigned_run", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("checked_in", sa.Boolean(), nullable=True), + sa.Column("did_sign_photo_release", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('discordmodel', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('mfa', sa.Boolean(), nullable=True), - sa.Column('avatar', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('banner', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('color', sa.Integer(), nullable=True), - sa.Column('nitro', sa.Integer(), nullable=True), - sa.Column('locale', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['usermodel.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + "discordmodel", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("mfa", sa.Boolean(), nullable=True), + sa.Column("avatar", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("banner", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("color", sa.Integer(), nullable=True), + sa.Column("nitro", sa.Integer(), nullable=True), + sa.Column("locale", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["usermodel.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('discordmodel') - op.drop_table('usermodel') + op.drop_table("discordmodel") + op.drop_table("usermodel") # ### end Alembic commands ### diff --git a/app/models/user.py b/app/models/user.py index d70465b..c1dcd7e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -107,7 +107,6 @@ class PublicContact(BaseModel): ops_email: str - def user_to_dict(model): if model is None: return None diff --git a/app/routes/api.py b/app/routes/api.py index 194a769..f35cf65 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -131,7 +131,7 @@ async def post_form( request: Request, token: Optional[str] = Cookie(None), payload: Optional[object] = {}, - num: str = '1', + num: str = "1", session: Session = Depends(get_session), ): # Get Kennelish data diff --git a/app/routes/plinko.py b/app/routes/plinko.py index b53c091..b8e31a7 100644 --- a/app/routes/plinko.py +++ b/app/routes/plinko.py @@ -1,34 +1,37 @@ -from jose import JWTError, jwt +import asyncio import logging import uuid - -from fastapi import APIRouter, Cookie, Request, Response, status, WebSocket, WebSocketDisconnect, Depends -from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.encoders import jsonable_encoder - -from pydantic import validator, error_wrappers - from typing import Optional -from sqlalchemy.sql.sqltypes import UUID +from fastapi import ( + APIRouter, + Cookie, + Depends, + Request, + Response, + WebSocket, + WebSocketDisconnect, + status, +) +from fastapi.encoders import jsonable_encoder +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from jose import JWTError, jwt +from pydantic import error_wrappers, validator from sqlalchemy.orm import selectinload -from app.models.user import UserModelMutable -from app.models.info import InfoModel -from app.models.user import PublicContact, UserModel, DiscordModel -from app.util.database import get_user, Session, get_session +from sqlalchemy.sql.sqltypes import UUID +from app.models.info import InfoModel +from app.models.user import DiscordModel, PublicContact, UserModel, UserModelMutable from app.util.authentication import Authentication +from app.util.database import Session, get_session, get_user +from app.util.discord import Discord from app.util.errors import Errors +from app.util.kennelish import Kennelish, Transformer from app.util.options import Options -from app.util.discord import Discord from app.util.plinko import Plinko -from app.util.websockets import ConnectionManager -from app.util.kennelish import Kennelish, Transformer from app.util.settings import Settings - -import asyncio - +from app.util.websockets import ConnectionManager logger = logging.getLogger(__name__) @@ -38,6 +41,7 @@ wsm = ConnectionManager() + @router.get("/") async def get_root(): return InfoModel( @@ -120,7 +124,10 @@ async def get_team_info( @router.get("/bot") @Authentication.admin async def get_team_info( - request: Request, token: Optional[str] = Cookie(None), run: Optional[str] = "FAIL", session: Session = Depends(get_session) + request: Request, + token: Optional[str] = Cookie(None), + run: Optional[str] = "FAIL", + session: Session = Depends(get_session), ): """ Expose teams for a given run in a format understood by PlinkoBot. @@ -204,7 +211,6 @@ async def checkin( if member_id == "FAIL" or run == "FAIL": return Errors.generate(request, 404, "User Not Found (or run not defined)") - user_data = get_user(session, uuid.UUID(member_id)) if not user_data: @@ -221,7 +227,6 @@ async def checkin( "user": user_data, } - user_data.checked_in = True session.add(user_data) session.commit() diff --git a/app/routes/wallet.py b/app/routes/wallet.py index e03ee19..e987a88 100644 --- a/app/routes/wallet.py +++ b/app/routes/wallet.py @@ -1,28 +1,22 @@ import json -import requests import os import uuid +from typing import Optional import boto3 +import requests +from airpress import PKPass from botocore.exceptions import ClientError - from fastapi import APIRouter, Cookie, Request, Response from fastapi.responses import HTMLResponse +from pydantic import error_wrappers, validator -from pydantic import validator, error_wrappers - -from typing import Optional -from app.models.user import PublicContact from app.models.info import InfoModel - +from app.models.user import PublicContact from app.util.authentication import Authentication from app.util.errors import Errors from app.util.options import Options -from airpress import PKPass - - - router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() ) diff --git a/app/util/authentication.py b/app/util/authentication.py index dc130ce..dd380e1 100644 --- a/app/util/authentication.py +++ b/app/util/authentication.py @@ -1,11 +1,10 @@ import time - from functools import wraps -from jose import JWTError, jwt from typing import Optional from fastapi import Request, status from fastapi.responses import RedirectResponse +from jose import JWTError, jwt # Import options and errors from app.util.errors import Errors @@ -13,7 +12,6 @@ from app.util.settings import Settings - class Authentication: def __init__(self): super(Authentication, self).__init__ @@ -55,7 +53,8 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): payload = jwt.decode( token, Settings().jwt.secret.get_secret_value(), - algorithms=Settings().jwt.algorithm,) + algorithms=Settings().jwt.algorithm, + ) is_admin: bool = payload.get("sudo", False) creation_date: float = payload.get("issued", -1) except Exception: @@ -95,7 +94,7 @@ async def wrapper_member( token: Optional[str], payload: Optional[object], *args, - **kwargs + **kwargs, ): # Validate auth. if not token: diff --git a/app/util/database.py b/app/util/database.py index 1af08a3..1869afe 100644 --- a/app/util/database.py +++ b/app/util/database.py @@ -4,11 +4,11 @@ # Create the database from alembic import script from alembic.runtime import migration +from sqlalchemy.orm import selectinload from sqlmodel import Session, SQLModel, create_engine from sqlmodel.pool import StaticPool -from sqlalchemy.orm import selectinload -from app.models.user import UserModel, DiscordModel +from app.models.user import DiscordModel, UserModel from app.util.settings import Settings DATABASE_URL = Settings().database.url @@ -44,12 +44,14 @@ def check_current_head(alembic_cfg, connectable): context = migration.MigrationContext.configure(connection) return set(context.get_current_heads()) == set(directory.get_heads()) + def get_user(session, user_id: UUID, use_selectinload: bool = False): query = session.query(UserModel).filter(UserModel.id == user_id) if selectinload: query = query.options(selectinload(UserModel.discord)) return query.one_or_none() + def get_user_discord(session, discord_id, use_selectinload: bool = False): query = session.query(UserModel).filter(UserModel.discord_id == discord_id) if selectinload: diff --git a/app/util/discord.py b/app/util/discord.py index a655e7b..c208a0b 100644 --- a/app/util/discord.py +++ b/app/util/discord.py @@ -1,10 +1,9 @@ import json -import requests import logging -from app.util.settings import Settings - +import requests +from app.util.settings import Settings logger = logging.getLogger(__name__) @@ -78,18 +77,18 @@ def send_message(discord_id, message): return req.status_code < 400 def join_plinko_server(self, discord_id, token): - if not Settings().discord.enable: - return - if self.check_presence(discord_id, Settings().discord.guild_id) != "joined": - logger.info(f"Joining {discord_id} to Plinko Discord") - headers = { - "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", - "Content-Type": "application/json", - "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", - } - put_join_guild = {"access_token": token["access_token"]} - requests.put( - f"https://discordapp.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}", - headers=headers, - data=json.dumps(put_join_guild), + if not Settings().discord.enable: + return + if self.check_presence(discord_id, Settings().discord.guild_id) != "joined": + logger.info(f"Joining {discord_id} to Plinko Discord") + headers = { + "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", + "Content-Type": "application/json", + "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", + } + put_join_guild = {"access_token": token["access_token"]} + requests.put( + f"https://discordapp.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}", + headers=headers, + data=json.dumps(put_join_guild), ) diff --git a/app/util/plinko.py b/app/util/plinko.py index 053799b..8c07046 100644 --- a/app/util/plinko.py +++ b/app/util/plinko.py @@ -1,17 +1,12 @@ import json -import requests import logging import uuid -from app.util.settings import Settings -from app.util.database import get_user, Session -from app.models.user import UserModel, DiscordModel - -from app.util.settings import Settings - - - +import requests +from app.models.user import DiscordModel, UserModel +from app.util.database import Session, get_user +from app.util.settings import Settings class Plinko: @@ -35,6 +30,7 @@ def check_elgible(user_data): return False, data return True, data + @staticmethod def get_team(session: Session, user_id): """ @@ -53,8 +49,11 @@ def get_team(session: Session, user_id): teammates = [] - all_users_with_team_number = session.query(UserModel).filter(UserModel.team_number == user_team_number).all() - + all_users_with_team_number = ( + session.query(UserModel) + .filter(UserModel.team_number == user_team_number) + .all() + ) for user in all_users_with_team_number: if user.get("assigned_run") == user_run and user.get("waitlist") == 1: @@ -76,7 +75,6 @@ def get_waitlist_status(session: Session, plus_one=False): waitlist_groups = 15 # 150, 180, 210, etc. hard_cap = 200 - data = session.query(UserModel).filter(UserModel.waitlist > 0).all() current_count = len(data) currently_registered = 0 diff --git a/app/util/settings.py b/app/util/settings.py index 3d2972e..b67d0a2 100644 --- a/app/util/settings.py +++ b/app/util/settings.py @@ -132,7 +132,7 @@ def check_required_fields(cls, values): "redirect_base", "scope", "secret", - "organizer_guild_id" + "organizer_guild_id", ] for field in required_fields: if getattr(values, field) is None: @@ -148,8 +148,6 @@ def check_required_fields(cls, values): logger.warn("Missing discord config") - - class GoogleWalletConfig(BaseModel): """ #TODO fix docs @@ -246,7 +244,6 @@ class JwtConfig(BaseModel): jwt_config = JwtConfig(secret=secret) - class KeycloakConfig(BaseModel): username: Optional[str] = Field(None) password: Optional[SecretStr] = Field(None) diff --git a/app/util/websockets.py b/app/util/websockets.py index d7c3b44..0a0acec 100644 --- a/app/util/websockets.py +++ b/app/util/websockets.py @@ -1,9 +1,11 @@ from fastapi import WebSocket + class ConnectionManager: """ Adapted from https://fastapi.tiangolo.com/advanced/websockets/ """ + def __init__(self): self.active_connections: list[WebSocket] = [] self.new_id = 0 @@ -26,4 +28,4 @@ async def send_personal_message(self, message: str, websocket: WebSocket): async def broadcast(self, message: str): print(f"Broadcast -> {message}") for connection in self.active_connections: - await connection.send_text(message) \ No newline at end of file + await connection.send_text(message) From 8b1a321df73d87fc0bf9ef4276beb8cb315c58ac Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 14:17:46 -0400 Subject: [PATCH 4/6] Added Dockerfile --- .github/workflows/docker-publish.yml | 4 +- Dockerfile | 58 ++++++++++++++++++++++++++++ app/entry.py | 52 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 app/entry.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 91536c3..9905f26 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,7 +6,7 @@ on: - main - dev schedule: - - cron: '21 18 * * 3' + - cron: "21 18 * * 3" jobs: build-and-push: @@ -34,7 +34,7 @@ jobs: run: docker build --target prod -t onboardlite:${{ steps.extract_branch.outputs.branch }} . - name: Tag Docker image - run: docker tag onboardlite:${{ steps.extract_branch.outputs.branch }} ghcr.io/hackucf/onboardlite:${{ steps.extract_branch.outputs.branch }} + run: docker tag onboardlite:${{ steps.extract_branch.outputs.branch }} ghcr.io/hackucf/plinkonboard:${{ steps.extract_branch.outputs.branch }} - name: Push Docker image run: docker push ghcr.io/hackucf/onboardlite:${{ steps.extract_branch.outputs.branch }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fad38e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Use the official Python base image +FROM python:3.11-bookworm AS base + +# Set the working directory in the container +WORKDIR /src + +# Install build-essential +RUN apt-get update && apt-get install -y build-essential + +# Clean up +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + +ADD https://github.com/bitwarden/sdk/releases/download/bws-v0.4.0/bws-x86_64-unknown-linux-gnu-0.4.0.zip /tmp + +RUN unzip /tmp/bws-x86_64-unknown-linux-gnu-0.4.0.zip + +RUN mv bws /usr/local/bin + +RUN rm -r /tmp/ + + + +FROM base AS dev + +COPY . . + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt + +EXPOSE 8000 + +ENTRYPOINT ["python3", "app/entry.py" ] + +CMD dev + + + +FROM dev AS test + +CMD ["pytest"] + + + +FROM base as prod + +COPY requirements.txt . + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +COPY ./app ./app + +EXPOSE 8000 + +# Start the FastAPI application +ENTRYPOINT ["/bin/python3", "app/entry.py"] + +CMD [] diff --git a/app/entry.py b/app/entry.py new file mode 100644 index 0000000..f095e76 --- /dev/null +++ b/app/entry.py @@ -0,0 +1,52 @@ +import os +import subprocess +import sys + + +# Define the default command to run uvicorn with environment variables +def run_uvicorn(): + host = os.getenv("ONBOARD_HOST", "0.0.0.0") + port = os.getenv("ONBOARD_PORT", "8000") + proxy_headers = os.getenv("ONBOARD_PROXY_HEADERS") + forwarded_allow_ips = os.getenv("ONBOARD_FORWARDED_ALLOW_IPS") + + command = [ + "uvicorn", + "app.main:app", + "--host", + host, + "--port", + port, + "--workers", + "2", + ] + + if forwarded_allow_ips is not None: + command.extend(["--forwarded-allow-ips", forwarded_allow_ips]) + command.append("--proxy-headers") + + subprocess.run(command) + + +def run_dev(): + host = os.getenv("ONBOARD_HOST", "0.0.0.0") + port = os.getenv("ONBOARD_PORT", "8000") + command = ["uvicorn", "app.main:app", "--host", host, "--port", port, "--reload"] + subprocess.run(command) + + +# Define the migrate command +def run_migrate(): + os.chdir("./app") + command = ["alembic", "upgrade", "head"] + subprocess.run(command) + + +# Entry point +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "migrate": + run_migrate() + elif len(sys.argv) > 1 and sys.argv[1] == "dev": + run_dev() + else: + run_uvicorn() From 745d3dcf1d7096c0adc5aee573092e35911ab5f6 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 14:18:02 -0400 Subject: [PATCH 5/6] added dev requirements --- requirements-dev.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..02cc331 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pre-commit==3.8.0 +semgrep==1.85.0 +virtualenv==20.26.3 +httpx +pytest From 8d738aae4d45799777020f8d609bfa63490084a1 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 24 Aug 2024 17:55:43 -0400 Subject: [PATCH 6/6] Added onboard federation --- .gitignore | 1 + app/index.py | 46 ++++++++++++++----- ...mmit.py => d1a00822bc48_initial_commit.py} | 7 +-- app/models/user.py | 1 + app/util/settings.py | 10 ++++ 5 files changed, 51 insertions(+), 14 deletions(-) rename app/migrations/versions/{3337a749d431_initial_commit.py => d1a00822bc48_initial_commit.py} (94%) diff --git a/.gitignore b/.gitignore index 49bc7e1..a493fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ clouds.yaml infra_options.json _deploy.sh config/ +database/ diff --git a/app/index.py b/app/index.py index 76c5f20..abac6fa 100644 --- a/app/index.py +++ b/app/index.py @@ -177,12 +177,9 @@ async def oauth_transformer_new( # TODO implament Onboard Federation - is_new = False - - if user: - member_id = user.id - do_sudo = user.sudo - else: + if not user: + discord_id = discordData["id"] + user = UserModel(discord_id=discord_id) if not discordData.get("verified"): tr = Errors.generate( request, @@ -190,10 +187,37 @@ async def oauth_transformer_new( "Discord email not verfied please try again", ) return tr + cookies = {"token": Settings().hack_ucf_onboard.token.get_secret_value()} + url = ( + Settings().hack_ucf_onboard.url + + "/admin/get_by_snowflake" + + "?discord_id=" + + discord_id + ) + hackucf_data = requests.get(url, cookies=cookies) + if hackucf_data.status_code == 200 and Settings().hack_ucf_onboard.enable: + hackucf_data = hackucf_data.json().get("data") + user.hackucf_id = uuid.UUID(hackucf_data.get("id")) + user.first_name = hackucf_data.get("first_name") + user.last_name = hackucf_data.get("surname") + user.experience = hackucf_data.get("experience") + user.hackucf_member = hackucf_data.get("is_full_member") + user.sudo = hackucf_data.get("sudo") + logger.info( + "User found in Hackucf\n Plinko ID: " + + str(user.id) + + "\n Hackucf ID: " + + str(user.hackucf_id) + ) + + else: + logger.info( + discord_id + "not found in Hackucf" + str(hackucf_data.status_code) + ) + infra_email = "" - discord_id = discordData["id"] + Discord().join_plinko_server(discord_id, token) - user = UserModel(discord_id=discord_id) discord_data = { "email": discordData.get("email"), "mfa": discordData.get("mfa_enabled"), @@ -216,8 +240,8 @@ async def oauth_transformer_new( "discord": token, "name": discordData["username"], "pfp": discordData.get("avatar"), - "id": str(member_id), - "sudo": do_sudo, + "id": str(user.id), + "sudo": user.sudo, "issued": time.time(), } bearer = jwt.encode( @@ -281,7 +305,7 @@ async def join( user_data = get_user(session, uuid.UUID(payload.get("id"))) - if user_data.get("waitlist") and user_data.get("waitlist") > 0: + if user_data.waitlist and user_data.waitlist > 0: return RedirectResponse("/profile", status_code=status.HTTP_302_FOUND) except Exception as e: diff --git a/app/migrations/versions/3337a749d431_initial_commit.py b/app/migrations/versions/d1a00822bc48_initial_commit.py similarity index 94% rename from app/migrations/versions/3337a749d431_initial_commit.py rename to app/migrations/versions/d1a00822bc48_initial_commit.py index 10a9437..d2e69b5 100644 --- a/app/migrations/versions/3337a749d431_initial_commit.py +++ b/app/migrations/versions/d1a00822bc48_initial_commit.py @@ -1,8 +1,8 @@ """Initial Commit -Revision ID: 3337a749d431 +Revision ID: d1a00822bc48 Revises: -Create Date: 2024-08-23 13:09:07.970763 +Create Date: 2024-08-24 17:46:30.644010 """ @@ -13,7 +13,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "3337a749d431" +revision: str = "d1a00822bc48" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -34,6 +34,7 @@ def upgrade() -> None: sa.Column("sudo", sa.Boolean(), nullable=True), sa.Column("discord_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("hackucf_id", sa.Uuid(), nullable=True), + sa.Column("hackucf_member", sa.Boolean(), nullable=True), sa.Column("experience", sa.Integer(), nullable=True), sa.Column("waitlist", sa.Integer(), nullable=True), sa.Column("team_number", sa.Integer(), nullable=True), diff --git a/app/models/user.py b/app/models/user.py index c1dcd7e..ade15b8 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -46,6 +46,7 @@ class UserModel(SQLModel, table=True): # Collected from HackUCF Onboard hackucf_id: Optional[uuid.UUID] = None + hackucf_member: Optional[bool] = False experience: Optional[int] = None # HPCC data (internal) diff --git a/app/util/settings.py b/app/util/settings.py index b67d0a2..7c92632 100644 --- a/app/util/settings.py +++ b/app/util/settings.py @@ -281,6 +281,15 @@ class TelemetryConfig(BaseModel): telemetry_config = TelemetryConfig(**settings.get("telemetry", {})) +class OnboardFederationConfig(BaseModel): + url: Optional[str] = None + token: Optional[SecretStr] = None + enable: Optional[bool] = False + + +hack_ucf_onboard = OnboardFederationConfig(**settings.get("hack_ucf_onboard", {})) + + class DatabaseConfig(BaseModel): url: str @@ -323,4 +332,5 @@ class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta): keycloak: KeycloakConfig = keycloak_config google_wallet: GoogleWalletConfig = google_wallet_config telemetry: Optional[TelemetryConfig] = telemetry_config + hack_ucf_onboard: OnboardFederationConfig = hack_ucf_onboard env: Optional[str] = onboard_env