From 7522882299ff338c63e58ebe64c69e4a18168211 Mon Sep 17 00:00:00 2001 From: Jonathan Styles <64489881+jontyms@users.noreply.github.com> Date: Mon, 27 May 2024 15:01:27 -0400 Subject: [PATCH] Switch to SQLModel Database stuff (#93) --- .gitignore | 1 + .pre-commit-config.yaml | 8 +- Dockerfile | 7 +- Dockerfile-testing | 34 ++ app/__init__.py | 0 app/alembic.ini | 112 ++++++ app/entry.py | 25 ++ {forms => app/forms}/2.json | 0 {forms => app/forms}/3.json | 0 {forms => app/forms}/edit.json | 0 {forms => app/forms}/ethics_form.json | 0 {forms => app/forms}/ethics_form_midway.json | 7 + {forms => app/forms}/mentee.json | 0 {forms => app/forms}/non_ucf.json | 0 {forms => app/forms}/ops.json | 0 {forms => app/forms}/signed.json | 0 index.py => app/main.py | 240 ++++++------- app/migrations/README | 1 + app/migrations/__init__.py | 0 app/migrations/env.py | 92 +++++ app/migrations/script.py.mako | 27 ++ app/migrations/versions/b4cd696ddc31_.py | 108 ++++++ app/models/__init__.py | 0 {models => app/models}/info.py | 2 +- app/models/user.py | 216 +++++++++++ app/routes/__init__.py | 0 {routes => app/routes}/admin.py | 186 +++++----- app/routes/api.py | 174 +++++++++ app/routes/infra.py | 336 +++++++++++++++++ {routes => app/routes}/stripe.py | 95 +++-- {routes => app/routes}/wallet.py | 51 ++- {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_ops.png | Bin .../static}/apple_wallet/logo_ops@2x.png | Bin .../static}/apple_wallet/logo_reg.png | Bin .../static}/apple_wallet/logo_reg@2x.png | Bin {static => app/static}/favicon.ico | Bin {static => app/static}/favicon.svg | 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_hack_dark.svg | 0 {static => app/static}/qr_hack_light.svg | 0 .../templates}/admin_searcher.html | 0 {templates => app/templates}/done.html | 0 {templates => app/templates}/error.html | 0 {templates => app/templates}/form.html | 0 {templates => app/templates}/index.html | 0 {templates => app/templates}/pay.html | 0 {templates => app/templates}/profile.html | 0 {templates => app/templates}/signup.html | 0 app/util/__init__.py | 0 app/util/approve.py | 231 ++++++++++++ {util => app/util}/authentication.py | 26 +- app/util/database.py | 34 ++ {util => app/util}/discord.py | 2 +- {util => app/util}/email.py | 2 +- {util => app/util}/errors.py | 2 +- app/util/forms.py | 70 ++++ {util => app/util}/horsepass.py | 0 {util => app/util}/kennelish.py | 16 +- {util => app/util}/limiter.py | 0 {util => app/util}/settings.py | 111 ++++-- config.yml | 4 + docker-compose-tests.yml | 46 +++ docker-compose.yml | 34 +- models/user.py | 147 -------- pytest.ini | 4 + requirements.txt | 7 +- routes/api.py | 186 ---------- routes/infra.py | 340 ------------------ ruff.toml | 7 + tests/__init__.py | 0 tests/conftest.py | 92 +++++ tests/test_static.py | 31 ++ tests/test_user.py | 31 ++ util/approve.py | 222 ------------ util/forms.py | 13 - 85 files changed, 2118 insertions(+), 1262 deletions(-) create mode 100644 Dockerfile-testing create mode 100644 app/__init__.py create mode 100644 app/alembic.ini create mode 100644 app/entry.py rename {forms => app/forms}/2.json (100%) rename {forms => app/forms}/3.json (100%) rename {forms => app/forms}/edit.json (100%) rename {forms => app/forms}/ethics_form.json (100%) rename {forms => app/forms}/ethics_form_midway.json (91%) rename {forms => app/forms}/mentee.json (100%) rename {forms => app/forms}/non_ucf.json (100%) rename {forms => app/forms}/ops.json (100%) rename {forms => app/forms}/signed.json (100%) rename index.py => app/main.py (63%) 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/b4cd696ddc31_.py create mode 100644 app/models/__init__.py rename {models => app/models}/info.py (84%) create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py rename {routes => app/routes}/admin.py (61%) create mode 100644 app/routes/api.py create mode 100644 app/routes/infra.py rename {routes => app/routes}/stripe.py (63%) rename {routes => app/routes}/wallet.py (89%) 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_ops.png (100%) rename {static => app/static}/apple_wallet/logo_ops@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}/favicon.ico (100%) rename {static => app/static}/favicon.svg (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_hack_dark.svg (100%) rename {static => app/static}/qr_hack_light.svg (100%) rename {templates => app/templates}/admin_searcher.html (100%) rename {templates => app/templates}/done.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}/pay.html (100%) rename {templates => app/templates}/profile.html (100%) rename {templates => app/templates}/signup.html (100%) create mode 100644 app/util/__init__.py create mode 100644 app/util/approve.py rename {util => app/util}/authentication.py (85%) create mode 100644 app/util/database.py rename {util => app/util}/discord.py (97%) rename {util => app/util}/email.py (96%) rename {util => app/util}/errors.py (92%) create mode 100644 app/util/forms.py rename {util => app/util}/horsepass.py (100%) rename {util => app/util}/kennelish.py (95%) rename {util => app/util}/limiter.py (100%) rename {util => app/util}/settings.py (75%) create mode 100644 docker-compose-tests.yml delete mode 100644 models/user.py create mode 100644 pytest.ini delete mode 100644 routes/api.py delete mode 100644 routes/infra.py create mode 100644 ruff.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_static.py create mode 100644 tests/test_user.py delete mode 100644 util/approve.py delete mode 100644 util/forms.py diff --git a/.gitignore b/.gitignore index 3676d1c..add3455 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.pyc __pycache__ config/ +database/ .terraform* terraform.* clouds.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf269ba..6b7a443 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,8 @@ repos: - 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 @@ -12,12 +14,6 @@ repos: - id: prettier files: '^docs/.*\.mdx?$' - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - name: isort (python) - - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: diff --git a/Dockerfile b/Dockerfile index e4d79a3..15769aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.11-bookworm # Set the working directory in the container -WORKDIR /app +WORKDIR /src # Copy the requirements file to the container COPY requirements.txt . @@ -22,7 +22,7 @@ RUN mv bws /usr/local/bin RUN rm -r /tmp/ # Install the dependencies -RUN pip install --no-cache-dir -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt # Copy the application code to the container COPY . . @@ -31,4 +31,5 @@ COPY . . EXPOSE 8000 # Start the FastAPI application -CMD ["uvicorn", "index:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +#CMD ["sleep", "1h"] diff --git a/Dockerfile-testing b/Dockerfile-testing new file mode 100644 index 0000000..135e601 --- /dev/null +++ b/Dockerfile-testing @@ -0,0 +1,34 @@ +# Use the official Python base image +FROM python:3.11-bookworm + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements file to the container +COPY requirements.txt . + +# 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/ + +# Install the dependencies +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +# Copy the application code to the container +COPY . . + +# Expose the port that the FastAPI application will run on +EXPOSE 8000 + +# Start the FastAPI application +CMD ["sleep", "infinity"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 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/app/entry.py b/app/entry.py new file mode 100644 index 0000000..8627123 --- /dev/null +++ b/app/entry.py @@ -0,0 +1,25 @@ +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") + command = ["uvicorn", "app.main:app", "--host", host, "--port", port] + subprocess.run(command) + + +# Define the migrate command +def run_migrate(): + command = ["alembic", "upgrade", "head"] + subprocess.run(command) + + +# Entry point +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "migrate": + run_migrate() + else: + run_uvicorn() 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/3.json b/app/forms/3.json similarity index 100% rename from forms/3.json rename to app/forms/3.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/forms/ethics_form.json b/app/forms/ethics_form.json similarity index 100% rename from forms/ethics_form.json rename to app/forms/ethics_form.json diff --git a/forms/ethics_form_midway.json b/app/forms/ethics_form_midway.json similarity index 91% rename from forms/ethics_form_midway.json rename to app/forms/ethics_form_midway.json index ff92dc7..d27cc4f 100644 --- a/forms/ethics_form_midway.json +++ b/app/forms/ethics_form_midway.json @@ -65,6 +65,13 @@ "options": ["I promise not to do this."], "key": "ethics_form.host_at_ucf" }, + { + "caption": "I have read and agree to the Hack@UCF Cloud Acceptable Use Policy located at here ", + "input": "radio", + "required": true, + "options": ["I promise not to do this."], + "key": "ethics_form.cloud_aup" + }, { "input": "signature", diff --git a/forms/mentee.json b/app/forms/mentee.json similarity index 100% rename from forms/mentee.json rename to app/forms/mentee.json diff --git a/forms/non_ucf.json b/app/forms/non_ucf.json similarity index 100% rename from forms/non_ucf.json rename to app/forms/non_ucf.json diff --git a/forms/ops.json b/app/forms/ops.json similarity index 100% rename from forms/ops.json rename to app/forms/ops.json diff --git a/forms/signed.json b/app/forms/signed.json similarity index 100% rename from forms/signed.json rename to app/forms/signed.json diff --git a/index.py b/app/main.py similarity index 63% rename from index.py rename to app/main.py index fa81ae1..3e4c75b 100644 --- a/index.py +++ b/app/main.py @@ -1,52 +1,73 @@ import json import logging -import os -import time import uuid from typing import Optional from urllib.parse import urlparse -import boto3 import requests -from boto3.dynamodb.conditions import Attr + # FastAPI -from fastapi import Cookie, FastAPI, Request, Response, status +from fastapi import Cookie, Depends, FastAPI, Request, Response, status from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from jose import jwt from requests_oauthlib import OAuth2Session +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select # Import data types -from models.user import UserModel +from app.models.user import DiscordModel, EthicsFormModel, UserModel, user_to_dict + # Import routes -from routes import admin, api, infra, stripe, wallet -from util.approve import Approve +from app.routes import admin, api, infra, stripe, wallet +from app.util.approve import Approve + # Import middleware -from util.authentication import Authentication +from app.util.authentication import Authentication +from app.util.database import get_session, init_db + # Import error handling -from util.errors import Errors -from util.forms import Forms +from app.util.errors import Errors +from app.util.forms import Forms + # Import the page rendering library -from util.kennelish import Kennelish +from app.util.kennelish import Kennelish + # Import options -from util.settings import Settings +from app.util.settings import Settings +if Settings().telemetry.enable: + import sentry_sdk ### TODO: TEMP -os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" +# os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" ### -logging.basicConfig(level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(name)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') +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="templates") -app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="app/templates") +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +if Settings().telemetry.enable: + sentry_sdk.init( + dsn=Settings().telemetry.url, + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + traces_sample_rate=1.0, + # Set profiles_sample_rate to 1.0 to profile 100% + # of sampled transactions. + # We recommend adjusting this value in production. + profiles_sample_rate=1.0, + ) # Import endpoints from ./routes app.include_router(api.router) @@ -77,6 +98,11 @@ """ +@app.on_event("startup") +def on_startup(): + init_db() + + @app.get("/") async def index(request: Request, token: Optional[str] = Cookie(None)): is_full_member = False @@ -85,19 +111,19 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): infra_email = None if token is not None: - try: - user_jwt = jwt.decode( - token, - Settings().jwt.secret.get_secret_value(), - algorithms=Settings().jwt.algorithm, - ) - is_full_member: bool = user_jwt.get("is_full_member", False) - is_admin: bool = user_jwt.get("sudo", False) - user_id: bool = user_jwt.get("id", None) - infra_email: bool = user_jwt.get("infra_email", None) - except Exception as e: - logger.exception(e) - pass + try: + user_jwt = jwt.decode( + token, + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, + ) + is_full_member: bool = user_jwt.get("is_full_member", False) + is_admin: bool = user_jwt.get("sudo", False) + user_id: bool = user_jwt.get("id", None) + infra_email: bool = user_jwt.get("infra_email", None) + except Exception as e: + logger.exception(e) + pass return templates.TemplateResponse( "index.html", @@ -153,11 +179,8 @@ async def oauth_transformer_new( code: str = None, redir: str = "/join/2", redir_endpoint: Optional[str] = Cookie(None), + session: Session = Depends(get_session), ): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - # Open redirect check if redir == "_redir": redir = redir_endpoint @@ -194,40 +217,27 @@ async def oauth_transformer_new( 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") - + statement = select(UserModel).where(UserModel.discord_id == discordData["id"]) + user = session.exec(statement).one_or_none() + # TODO: Discuss removing # 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. - if not query_for_id: - logger.info("Beginning Discord ID attribute migration...") - query_for_id = table.scan( - FilterExpression=Attr("discord_id").eq(int(discordData["id"])) - ) - query_for_id = query_for_id.get("Items") - - if query_for_id: - 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 - - 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: - is_full_member = False + # if not query_for_id: + # logger.info("Beginning Discord ID attribute migration...") + # query_for_id = table.scan( + # FilterExpression=Attr("discord_id").eq(int(discordData["id"])) + # ) + # query_for_id = query_for_id.get("Items") + # + # if query_for_id: + # table.update_item( + # Key={"id": query_for_id[0].get("id")}, + # UpdateExpression="SET discord_id = :discord_id", + # ExpressionAttributeValues={":discord_id": str(discordData["id"])}, + # ) + + if not user: 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. @@ -243,54 +253,28 @@ async def oauth_transformer_new( headers=headers, data=json.dumps(put_join_guild), ) - - data = { - "id": member_id, - "discord_id": discordData["id"], - "discord": { - "email": discordData["email"], - "mfa": discordData["mfa_enabled"], + user = UserModel(discord_id=discord_id, id=member_id, infra_email=infra_email) + 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["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. - } - - # 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"]}, - ) + "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) + ethics_form = EthicsFormModel() + user.discord = discord_model + user.ethics_form = ethics_form + 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": full_data["discord"]["avatar"], - "id": member_id, - "sudo": do_sudo, - "is_full_member": is_full_member, - "issued": time.time(), - "infra_email": infra_email, - } - bearer = jwt.encode( - jwtData, - Settings().jwt.secret.get_secret_value(), - algorithm=Settings().jwt.algorithm, - ) + bearer = Authentication.create_jwt(user) rr = RedirectResponse(redir, status_code=status.HTTP_302_FOUND) rr.set_cookie(key="token", value=bearer) @@ -324,12 +308,14 @@ async def profile( request: Request, token: Optional[str] = Cookie(None), user_jwt: Optional[object] = {}, + session: Session = Depends(get_session), ): - # Get data from DynamoDB - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) + statement = ( + select(UserModel) + .where(UserModel.id == user_jwt["id"]) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + user_data = user_to_dict(session.exec(statement).one_or_none()) # Re-run approval workflow. Approve.approve_member(user_jwt.get("id")) @@ -351,11 +337,8 @@ async def forms( token: Optional[str] = Cookie(None), user_jwt: Optional[object] = {}, num: str = 1, + session: Session = Depends(get_session), ): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - if num == "1": return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) try: @@ -368,11 +351,17 @@ async def forms( essay="This form does not exist.", ) + # Get data from SqlModel - # Get data from DynamoDB - user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) - + statement = ( + select(UserModel) + .where(UserModel.id == user_jwt.get("id")) + .options(selectinload(UserModel.discord)) + ) + user_data = session.exec(statement).one_or_none() # Have Kennelish parse the data. + user_data = user_to_dict(user_data) + logger.info("Parsing form data" + str(user_data)) body = Kennelish.parse(data, user_data) # return num @@ -399,9 +388,10 @@ async def logout(request: Request): rr.delete_cookie(key="token") return rr -@app.get('/favicon.ico', include_in_schema=False) + +@app.get("/favicon.ico", include_in_schema=False) async def favicon(): - return FileResponse("./static/favicon.ico") + return FileResponse("./app/static/favicon.ico") if __name__ == "__main__": 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..ed3826c --- /dev/null +++ b/app/migrations/env.py @@ -0,0 +1,92 @@ +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 + +from app.models.user import DiscordModel, EthicsFormModel, UserModel +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"}, + ) + + 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/b4cd696ddc31_.py b/app/migrations/versions/b4cd696ddc31_.py new file mode 100644 index 0000000..b7ee56c --- /dev/null +++ b/app/migrations/versions/b4cd696ddc31_.py @@ -0,0 +1,108 @@ +"""Init DB + +Revision ID: b4cd696ddc31 +Revises: +Create Date: 2024-05-26 08:57:17.927834 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b4cd696ddc31" +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", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("discord_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("ucf_id", sa.Integer(), nullable=True), + sa.Column("nid", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("ops_email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("infra_email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("minecraft", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("github", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("first_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("surname", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("is_returning", sa.Boolean(), nullable=True), + sa.Column("gender", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("major", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("class_standing", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("shirt_size", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("did_get_shirt", sa.Boolean(), nullable=True), + sa.Column( + "time_availability", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("phone_number", sa.Integer(), nullable=True), + sa.Column("sudo", sa.Boolean(), nullable=True), + sa.Column("did_pay_dues", sa.Boolean(), nullable=True), + sa.Column("join_date", sa.Integer(), nullable=True), + sa.Column("mentor_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("is_full_member", sa.Boolean(), nullable=True), + sa.Column("can_vote", sa.Boolean(), nullable=True), + sa.Column("experience", sa.Integer(), nullable=True), + sa.Column("curiosity", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("c3_interest", sa.Boolean(), nullable=True), + sa.Column("attending", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("comments", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("discord_id"), + sa.UniqueConstraint("nid"), + sa.UniqueConstraint("ucf_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", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["usermodel.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ethicsformmodel", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("hack_others", sa.Boolean(), nullable=True), + sa.Column("hack_ucf", sa.Boolean(), nullable=True), + sa.Column("interrupt_ucf", sa.Boolean(), nullable=True), + sa.Column("manip_traffic", sa.Boolean(), nullable=True), + sa.Column("bypass_dhcp", sa.Boolean(), nullable=True), + sa.Column("pirate", sa.Boolean(), nullable=True), + sa.Column("host_at_ucf", sa.Boolean(), nullable=True), + sa.Column("cloud_aup", sa.Boolean(), nullable=True), + sa.Column("signtime", sa.Integer(), nullable=True), + sa.Column("user_id", sqlmodel.sql.sqltypes.GUID(), 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("ethicsformmodel") + op.drop_table("discordmodel") + op.drop_table("usermodel") + # ### end Alembic commands ### diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 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 984fafb..2651246 100644 --- a/models/info.py +++ b/app/models/info.py @@ -3,7 +3,7 @@ from pydantic import BaseModel # 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..396f441 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,216 @@ +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: str + mfa: bool + avatar: str + banner: str + color: int + nitro: int + locale: str + username: str + + user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="usermodel.id") + user: "UserModel" = Relationship(back_populates="discord") + + +class EthicsFormModel(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + hack_others: Optional[bool] = False + hack_ucf: Optional[bool] = False + interrupt_ucf: Optional[bool] = False + manip_traffic: Optional[bool] = False + bypass_dhcp: Optional[bool] = False + pirate: Optional[bool] = False + host_at_ucf: Optional[bool] = False + cloud_aup: Optional[bool] = False + signtime: Optional[int] = 0 + + user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="usermodel.id") + user: "UserModel" = Relationship(back_populates="ethics_form") + + +class UserModel(SQLModel, table=True): + id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True) + discord_id: str = Field(unique=True) + ucf_id: Optional[int] = Field(unique=True, default=None) + nid: Optional[str] = Field(unique=True, default=None) + ops_email: Optional[str] = None + infra_email: Optional[str] = None + minecraft: Optional[str] = "" + github: Optional[str] = "" + first_name: Optional[str] = "" + surname: Optional[str] = "" + email: Optional[str] = "" + is_returning: Optional[bool] = False + gender: Optional[str] = "" + major: Optional[str] = "" + class_standing: Optional[str] = "" + shirt_size: Optional[str] = "" + did_get_shirt: Optional[bool] = False + time_availability: Optional[str] = "" + phone_number: Optional[int] = 0 + sudo: Optional[bool] = False + did_pay_dues: Optional[bool] = False + join_date: Optional[int] = None + mentor_name: Optional[str] = None + is_full_member: Optional[bool] = False + can_vote: Optional[bool] = False + experience: Optional[int] = None + curiosity: Optional[str] = None + c3_interest: Optional[bool] = False + attending: Optional[str] = "" + comments: Optional[str] = "" + + discord: DiscordModel = Relationship(back_populates="user") + ethics_form: EthicsFormModel = Relationship(back_populates="user") + # cyberlab_monitor: CyberLabModel = Relationship(back_populates="user") + # mentee: MenteeModel = Relationship(back_populates="user") + + @validator("nid") + def nid_length(cls, nid): + # regex for NID + pattern = re.compile(r"^([a-z]{2}[0-9]{6})$") + if pattern.match(nid) is None: + raise ValueError("NID must be 2 letters followed by 6 numbers") + if len(nid) != 8: + raise ValueError("NID must be 8 characters long") + return nid + + @validator("shirt_size") + def shirt_size_length(cls, shirt_size): + # enum for shirt sizes + sizes = ["S", "M", "L", "XL", "2XL", "3XL"] + if shirt_size not in sizes: + raise ValueError("Shirt size must be one of S, M, L, XL, 2XL, 3XL") + return shirt_size + + @validator("email") + def nid_regex(cls, email): + # regex for email + pattern = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") + if pattern.match(email) is None: + raise ValueError("Email failed regex validation") + + +# What admins can edit. +class UserModelMutable(BaseModel): + # Identifiers + id: Optional[str] = None + discord_id: Optional[str] = None + ucf_id: Optional[int] = None + nid: Optional[str] = None + ops_email: Optional[str] = None + infra_email: Optional[str] = None + + minecraft: Optional[str] = None + github: Optional[str] = None + + # PII + first_name: Optional[str] = None + surname: Optional[str] = None + email: Optional[str] = None + is_returning: Optional[bool] = None + gender: Optional[str] = None + major: Optional[str] = None + class_standing: Optional[str] = None + shirt_size: Optional[str] = None + did_get_shirt: Optional[bool] = None + phone_number: Optional[int] = None + + # Permissions and Member Status + sudo: Optional[bool] = None + did_pay_dues: Optional[bool] = None + + # Mentorship Program + mentor_name: Optional[str] = None + + is_full_member: Optional[bool] = None + can_vote: Optional[bool] = False + + # Other models + experience: Optional[int] = None + curiosity: Optional[str] = None + c3_interest: Optional[bool] = None + + # Other things + attending: Optional[str] = None + comments: Optional[str] = None + discord: Optional[DiscordModel] = None + ethics_form: Optional[EthicsFormModel] = None + + +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/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/app/routes/admin.py similarity index 61% rename from routes/admin.py rename to app/routes/admin.py index 0522300..542ebf5 100644 --- a/routes/admin.py +++ b/app/routes/admin.py @@ -1,21 +1,29 @@ +import logging from typing import Optional -import boto3 -from boto3.dynamodb.conditions import Attr -from fastapi import APIRouter, Body, Cookie, Request, Response -from fastapi.encoders import jsonable_encoder +from fastapi import APIRouter, Body, Cookie, Depends, Request, Response from fastapi.templating import Jinja2Templates from jose import jwt - -from models.user import UserModelMutable -from util.approve import Approve -from util.authentication import Authentication -from util.discord import Discord -from util.email import Email -from util.errors import Errors -from util.settings import Settings - -templates = Jinja2Templates(directory="templates") +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select + +from app.models.user import ( + UserModel, + UserModelMutable, + user_to_dict, + user_update_instance, +) +from app.util.approve import Approve +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()) @@ -46,8 +54,9 @@ async def admin(request: Request, token: Optional[str] = Cookie(None)): @Authentication.admin async def get_infra( request: Request, - token: Optional[str] = Cookie(None), + 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!!!) @@ -63,13 +72,12 @@ async def get_infra( return Errors.generate(request, 404, "User Not Found") # Get user data - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - user_data = table.get_item(Key={"id": member_id}).get("Item", None) + user_data = session.exec( + select(UserModel).where(UserModel.id == member_id) + ).one_or_none() # Send DM... - new_creds_msg = f"""Hello {user_data.get('first_name')}, + new_creds_msg = f"""Hello {user_data.first_name}, We are happy to grant you Hack@UCF Private Cloud access! @@ -89,8 +97,10 @@ async def get_infra( """ # 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.get("email")) + # 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")} @@ -98,8 +108,9 @@ async def get_infra( @Authentication.admin async def get_refresh( request: Request, - token: Optional[str] = Cookie(None), + user_jwt: Optional[str] = Cookie(None), member_id: Optional[str] = "FAIL", + session: Session = Depends(get_session), ): """ API endpoint that re-runs the member verification workflow @@ -109,22 +120,23 @@ async def get_refresh( Approve.approve_member(member_id) - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.get_item(Key={"id": member_id}).get("Item", None) + user_data = session.exec( + select(UserModel).where(UserModel.id == member_id) + ).one_or_none() - if not data: + if not user_data: return Errors.generate(request, 404, "User Not Found") - return {"data": data} + return {"data": user_data} @router.get("/get/") @Authentication.admin async def admin_get_single( request: Request, - token: Optional[str] = Cookie(None), + 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 @@ -132,14 +144,17 @@ async def admin_get_single( if member_id == "FAIL": return {"data": {}, "error": "Missing ?member_id"} - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.get_item(Key={"id": member_id}).get("Item", None) + statement = ( + select(UserModel) + .where(UserModel.id == user_jwt["id"]) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + user_data = user_to_dict(session.exec(statement).one_or_none()) - if not data: + if not user_data: return Errors.generate(request, 404, "User Not Found") - return {"data": data} + return {"data": user_data} @router.get("/get_by_snowflake/") @@ -148,6 +163,7 @@ 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. @@ -156,22 +172,22 @@ async def admin_get_snowflake( if discord_id == "FAIL": return {"data": {}, "error": "Missing ?discord_id"} - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.scan(FilterExpression=Attr("discord_id").eq(str(discord_id))).get( - "Items" + statement = ( + select(UserModel) + .where(UserModel.discord_id == discord_id) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) ) - - 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] + 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} @@ -183,6 +199,7 @@ async def admin_post_discord_message( 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 @@ -190,16 +207,16 @@ async def admin_post_discord_message( if member_id == "FAIL": return {"data": {}, "error": "Missing ?member_id"} - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.get_item(Key={"id": member_id}).get("Item", None) + data = session.exec( + select(UserModel).where(UserModel.id == 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.get("discord_id"), message_text) + res = Discord.send_message(data.discord_id, message_text) if res: return {"msg": "Message sent."} @@ -213,57 +230,66 @@ 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 - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - old_data = table.get_item(Key={"id": member_id}).get("Item", None) + 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 old_data: + 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) - # 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!"} + 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)): +async def admin_list( + request: Request, + token: Optional[str] = Cookie(None), + session: Session = Depends(get_session), +): """ API endpoint that dumps all users as JSON. """ - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.scan().get("Items", None) + 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)): +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. """ - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - data = table.scan().get("Items", None) + 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: diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..92a7b6b --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,174 @@ +import json +import logging +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select + +from app.models.info import InfoModel +from app.models.user import PublicContact, UserModel, user_to_dict, 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="Jeffrey", + surname="DiVincent", + ops_email="jdivincent@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), + user_jwt: 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 == user_jwt["id"]) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + 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) + session.commit() + session.refresh(user) + + return user.model_dump() diff --git a/app/routes/infra.py b/app/routes/infra.py new file mode 100644 index 0000000..d6203d4 --- /dev/null +++ b/app/routes/infra.py @@ -0,0 +1,336 @@ +import logging +from typing import Optional + +import openstack +from fastapi import APIRouter, Cookie, Depends, Request +from fastapi.responses import FileResponse +from fastapi.templating import Jinja2Templates +from python_terraform import Terraform +from sqlmodel import Session, select + +from app.models.info import InfoModel +from app.models.user import PublicContact, UserModel +from app.util.approve import Approve +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.limiter import RateLimiter +from app.util.settings import Settings + +logger = logging.getLogger(__name__) + + +templates = Jinja2Templates(directory="app/templates") + +router = APIRouter(prefix="/infra", tags=["Infra"], responses=Errors.basic_http()) + +tf = Terraform(working_dir="./") + +rate_limiter = RateLimiter( + Settings().redis.host, Settings().redis.port, Settings().redis.db +) + +rate_limiter.get_redis() + + +# def get_shitty_database(): +# """ +# Dump contents of the file that stores infra options. +# I lovingly call this the "shitty database." +# """ +# data = {} +# opts_path = "infra_options.json" +# try: +# with open(opts_path, "r") as f: +# data = json.loads(f.read()) +# except Exception as e: +# logger.exception(f"Invalid config file at {opts_path}", e) +# data = {"gbmName": None, "imageId": None} +# +# return data + + +# async def create_resource(project, callback_discord_id=None): +# shitty_database = get_shitty_database() +# proj_name = project.name +# +# logger.info(f"Creating resources for {proj_name}...") +# +# tf_vars = { +# "application_credential_id": Settings().infra.application_credential_id, +# "application_credential_secret": Settings().infra.application_credential_secret.get_secret_value(), +# "tenant_name": proj_name, +# "gbmname": shitty_database.get("gbmName"), +# "imageid": shitty_database.get("imageId"), +# "member_username": project.id, +# } +# return_code, stdout, stderr = tf.apply(var=tf_vars, skip_plan=True) +# if return_code != 0: +# logger.exception("Terraform failed!") +# logger.debug(f"\treturn: {return_code}") +# logger.debug(f"\tstderr: {stderr}\n") +# +# # clean up +# try: +# os.remove("terraform.tfstate") +# except Exception: +# pass +# +# try: +# os.remove("terraform.tfstate.backup") +# except Exception: +# pass +# +# if callback_discord_id: +# resource_create_msg = f"""Hello! +# +# Your requested virtual machine has been created! You can now view it at {Settings().infra.horizon}. +# +# Enjoy, +# - Hack@UCF Bot +# """ +# Discord.send_message(callback_discord_id, resource_create_msg) +# +# logger.info("\tDone!") +# +# +# async def teardown(): +# logger.debug("Initializing post-GBM teardown...") +# death_word = "gbm" +# +# conn = openstack.connect(cloud="hackucf_infra") +# +# logger.debug("\tServers...") +# for resource in conn.compute.servers(all_projects=True): +# # logger.debug("\t" + resource.name) +# if death_word in resource.name.lower(): +# logger.debug(f"\t\tdelete {resource.name}") +# conn.compute.delete_server(resource) +# +# logger("\tSec Groups...") +# for resource in conn.network.security_groups(): +# # logger.debug("\t" + resource.name) +# if death_word in resource.name.lower(): +# logger.debug(f"\t\tdelete {resource.name}") +# conn.network.delete_security_group(resource) +# +# logger.debug("\tRouters...") +# for resource in conn.network.routers(): +# # logger.debug("\t" + resource.name) +# if death_word in resource.name.lower(): +# logger.debug(f"\t\tdelete {resource.name}") +# try: +# conn.network.delete_router(resource) +# except openstack.exceptions.ConflictException as e: +# port_id_list = str(e).split(": ")[-1].split(",") +# for port_id in port_id_list: +# logger.debug(f"\t\t\tdelete/abandon port: {port_id}") +# conn.network.remove_interface_from_router(resource, port_id=port_id) +# conn.network.delete_port(port_id) +# try: +# conn.network.delete_router(resource) +# except: # noqa +# logger.debug("\t\t\t\tFailed and gave up.") +# +# logger.debug("\tNetworks...") +# for resource in conn.network.networks(): +# # logger.debug("\t" + resource.name) +# if death_word in resource.name.lower(): +# logger.debug(f"\t\tdelete {resource.name}") +# try: +# conn.network.delete_network(resource) +# except openstack.exceptions.ConflictException as e: +# port_id_list = str(e).split(": ")[-1][:-1].split(",") +# for port_id in port_id_list: +# logger.debug(f"\t\t\tdelete port: {port_id}") +# try: +# conn.network.delete_port(port_id) +# except: # noqa +# pass +# try: +# conn.network.delete_network(resource) +# except: # noqa +# logger.debug("\t\t\t\tFailed and gave up.") +# logger.debug("\tDone!") +# +# + + +@router.get("/") +async def get_root(): + """ + Get API information. + """ + return InfoModel( + name="Onboard Infra", + description="Infrastructure Management via Onboard.", + credits=[ + PublicContact( + first_name="Jeffrey", + surname="DiVincent", + ops_email="jdivincent@hackucf.org", + ), + PublicContact( + first_name="Caleb", + surname="Sjostedt", + ops_email="csjostedt@hackucf.org", + ), + ], + ) + + +# +# """ +# API endpoint to self-service create a GBM environment. +# """ +# +# +# @router.get("/provision/") +# @Authentication.member +# async def get_provision( +# request: Request, +# token: Optional[str] = Cookie(None), +# user_jwt: Optional[object] = {}, +# ): +# conn = openstack.connect(cloud="hackucf_infra") +# +# # Get single user +# user = conn.identity.find_user(user_jwt.get("infra_email")) +# +# # Get project +# project = conn.identity.get_project(user.default_project_id) +# +# # Provision everything +# asyncio.create_task( +# create_resource(project, user_jwt.get("discord_id")) +# ) # runs teardown async +# return {"msg": "Queued."} +# +# +# """ +# API endpoint to trigger tear-down of GBM-provisioned stuff. +# """ +# +# +# @router.get("/teardown/") +# @Authentication.admin +# async def get_teardown(request: Request, token: Optional[str] = Cookie(None)): +# asyncio.create_task(teardown()) # runs teardown async +# return {"msg": "Queued."} +# +# +# """ +# API endpoint to SET the one-click deploy Settings(). +# """ +# +# +# @router.get("/options/get") +# @Authentication.member +# async def get_options( +# request: Request, +# token: Optional[str] = Cookie(None), +# user_jwt: Optional[object] = {}, +# ): +# return get_shitty_database() +# +# +# """ +# API endpoint to SET the one-click deploy Settings(). +# """ +# +# +# @router.get("/options/set") +# @Authentication.admin +# async def set_options( +# request: Request, +# token: Optional[str] = Cookie(None), +# gbmName: Optional[str] = None, +# imageId: Optional[str] = None, +# ): +# shitty_database = {"gbmName": gbmName, "imageId": imageId} +# +# with open("infra_options.json", "w") as f: +# f.write(json.dumps(shitty_database)) +# +# return shitty_database +# + + +@router.get("/reset/") +@Authentication.member +@rate_limiter.rate_limit(1, 604800, "reset") +async def get_infra( + request: Request, + token: Optional[str] = Cookie(None), + user_jwt: Optional[object] = {}, + session: Session = Depends(get_session), +): + """ + API endpoint to self-service reset Infra credentials (membership-validating) + """ + member_id = user_jwt.get("id") + + if not (user_jwt.get("is_full_member") or user_jwt.get("infra_email")): + return Errors.generate( + request, 403, "This API endpoint is restricted to Dues-Paying Members." + ) + + # This also reprovisions Infra access if an account already exists. + # This is useful for cleaning up things + nuking in case of an error. + creds = Approve.provision_infra(member_id) + + if not creds: + creds = {} + + # Get user data + user_data = session.exec( + select(UserModel).where(UserModel.id == user_jwt.get("id")) + ).one_or_none() + + # Send DM... + new_creds_msg = f"""Hello {user_data.get('first_name')}, + +You have requested to reset your Hack@UCF Infrastructure credentials. This change comes with new credentials. + +A reminder that you can use these credentials 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.")} +``` + +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). + +By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula + +Happy Hacking, + + - Hack@UCF Bot + """ + + # Send Discord message + # Discord.send_message(user_data.get("discord_id"), new_creds_msg) + # Send Email + Email.send_email("Reset Infra Credentials", new_creds_msg, user_data.get("email")) + + return {"username": creds.get("username"), "password": creds.get("password")} + + +@router.get("/openvpn") +@Authentication.member +@rate_limiter.rate_limit(5, 60, "ovpn") +async def download_file( + request: Request, + token: Optional[str] = Cookie(None), + user_jwt: Optional[object] = {}, +): + """ + An endpoint to Download OpenVPN profile + """ + # Replace 'path/to/your/file.txt' with the actual path to your file + file_path = "../HackUCF.ovpn" + return FileResponse( + file_path, filename="HackUCF.ovpn", media_type="application/octet-stream" + ) diff --git a/routes/stripe.py b/app/routes/stripe.py similarity index 63% rename from routes/stripe.py rename to app/routes/stripe.py index 77e80ca..45b5a20 100644 --- a/routes/stripe.py +++ b/app/routes/stripe.py @@ -1,19 +1,20 @@ import logging from typing import Optional -import boto3 import stripe -from boto3.dynamodb.conditions import Attr -from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select -from util.approve import Approve -from util.authentication import Authentication -from util.errors import Errors -from util.settings import Settings +from app.models.user import UserModel +from app.util.approve import Approve +from app.util.authentication import Authentication +from app.util.database import get_session +from app.util.errors import Errors +from app.util.settings import Settings -templates = Jinja2Templates(directory="templates") +templates = Jinja2Templates(directory="app/templates") logger = logging.getLogger(__name__) @@ -24,28 +25,23 @@ stripe.api_key = Settings().stripe.api_key.get_secret_value() -""" -Get API information. -""" - - @router.get("/") @Authentication.member async def get_root( request: Request, token: Optional[str] = Cookie(None), user_jwt: Optional[object] = {}, + session: Session = Depends(get_session), ): - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - # Get data from DynamoDB - user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) - - did_pay_dues = user_data.get("did_pay_dues", False) - - is_nid = True if user_data.get("nid", False) else False + """ + Get API information. + """ + user_data = session.exec( + select(UserModel).where(UserModel.id == user_jwt.get("id")) + ).one_or_none() + did_pay_dues = user_data.did_pay_dues + + is_nid = True if user_data.nid else False paused_payments = Settings().stripe.pause_payments return templates.TemplateResponse( @@ -57,7 +53,7 @@ async def get_root( "id": user_jwt["id"], "did_pay_dues": did_pay_dues, "is_nid": is_nid, - "paused_payments": paused_payments + "paused_payments": paused_payments, }, ) @@ -68,18 +64,17 @@ async def create_checkout_session( request: Request, token: Optional[str] = Cookie(None), user_jwt: Optional[object] = {}, + session: Session = Depends(get_session), ): if Settings().stripe.pause_payments: - return Errors.generate(request, 503, "Payments Paused") - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) + return Errors.generate(request, 503, "Payments Paused") - # Get data from DynamoDB - user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) + user_data = session.exec( + select(UserModel).where(UserModel.id == user_jwt.get("id")) + ).one_or_none() try: - stripe_email = user_data.get("email") + stripe_email = user_data.email checkout_session = stripe.checkout.Session.create( line_items=[ { @@ -101,7 +96,7 @@ async def create_checkout_session( @router.post("/webhook/validate") -async def webhook(request: Request): +async def webhook(request: Request, session: Session = Depends(get_session)): payload = await request.body() sig_header = request.headers.get("stripe-signature") event = None @@ -121,40 +116,34 @@ async def webhook(request: Request): # Event Handling if event["type"] == "checkout.session.completed": # Retrieve the session. If you require line items in the response, you may include them by expanding line_items. - session = event["data"]["object"] + checkout_session = event["data"]["object"] - if session.payment_status == "paid": + if checkout_session.payment_status == "paid": # Mark as paid. - pay_dues(session) + pay_dues(checkout_session, session) elif event["type"] == "checkout.session.async_payment_succeeded": - session = event["data"]["object"] - pay_dues(session) + checkout_session = event["data"]["object"] + pay_dues(checkout_session, session) # Passed signature verification return HTTPException(status_code=200, detail="Success.") -def pay_dues(session): - customer_email = session.get("customer_email") - - # AWS dependencies - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) +def pay_dues(checkout_session, db_session): + customer_email = checkout_session.get("customer_email") - # Get data from DynamoDB - response = table.scan(FilterExpression=Attr("email").eq(customer_email)).get( - "Items", None - )[0] + user_data = db_session.exec( + select(UserModel).where(UserModel.email == customer_email) + ).one_or_none() - member_id = response.get("id") + member_id = user_data.id # Set PAID. - table.update_item( - Key={"id": member_id}, - UpdateExpression="SET did_pay_dues = :val", - ExpressionAttributeValues={":val": True}, - ) + user_data.did_pay_dues = True + db_session.add(user_data) + db_session.commit() + db_session.refresh(user_data) # Do checks to approve membership status. Approve.approve_member(member_id) diff --git a/routes/wallet.py b/app/routes/wallet.py similarity index 89% rename from routes/wallet.py rename to app/routes/wallet.py index 4ebb59c..ed2be41 100644 --- a/routes/wallet.py +++ b/app/routes/wallet.py @@ -3,28 +3,27 @@ import uuid from typing import Optional -import boto3 import requests from airpress import PKPass -from fastapi import APIRouter, Cookie, Request, Response +from fastapi import APIRouter, Cookie, Depends, Request, Response +from sqlalchemy.orm import selectinload +from sqlmodel import select -from models.info import InfoModel -from models.user import PublicContact -from util.authentication import Authentication -from util.errors import Errors -from util.settings import Settings +from app.models.info import InfoModel +from app.models.user import PublicContact, UserModel, user_to_dict +from app.util.authentication import Authentication +from app.util.database import get_session +from app.util.errors import Errors router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() ) -""" -Used to get Discord image. -""" - - def get_img(url): + """ + Used to get Discord image. + """ resp = requests.get(url, stream=True) status = resp.status_code if status < 400: @@ -33,12 +32,10 @@ def get_img(url): return get_img("https://cdn.hackucf.org/PFP.png") -""" -User data -> Apple Wallet blob -""" - - def apple_wallet(user_data): + """ + User data -> Apple Wallet blob + """ # Create empty pass package p = PKPass() @@ -210,13 +207,11 @@ def apple_wallet(user_data): return p -""" -Get API information. -""" - - @router.get("/") async def get_root(): + """ + Get API information. + """ return InfoModel( name="Onboard for Mobile Wallets", description="Apple Wallet support.", @@ -236,12 +231,14 @@ async def aapl_gen( request: Request, token: Optional[str] = Cookie(None), user_jwt: Optional[object] = {}, + session=Depends(get_session), ): - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - # Get data from DynamoDB - user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) + statement = ( + select(UserModel) + .where(UserModel.id == user_jwt["id"]) + .options(selectinload(UserModel.discord), selectinload(UserModel.ethics_form)) + ) + user_data = user_to_dict(session.exec(statement).one_or_none()) p = apple_wallet(user_data) 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_ops.png b/app/static/apple_wallet/logo_ops.png similarity index 100% rename from static/apple_wallet/logo_ops.png rename to app/static/apple_wallet/logo_ops.png diff --git a/static/apple_wallet/logo_ops@2x.png b/app/static/apple_wallet/logo_ops@2x.png similarity index 100% rename from static/apple_wallet/logo_ops@2x.png rename to app/static/apple_wallet/logo_ops@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/favicon.ico b/app/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to app/static/favicon.ico diff --git a/static/favicon.svg b/app/static/favicon.svg similarity index 100% rename from static/favicon.svg rename to app/static/favicon.svg 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_hack_dark.svg b/app/static/qr_hack_dark.svg similarity index 100% rename from static/qr_hack_dark.svg rename to app/static/qr_hack_dark.svg diff --git a/static/qr_hack_light.svg b/app/static/qr_hack_light.svg similarity index 100% rename from static/qr_hack_light.svg rename to app/static/qr_hack_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/done.html b/app/templates/done.html similarity index 100% rename from templates/done.html rename to app/templates/done.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/pay.html b/app/templates/pay.html similarity index 100% rename from templates/pay.html rename to app/templates/pay.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/signup.html b/app/templates/signup.html similarity index 100% rename from templates/signup.html rename to app/templates/signup.html diff --git a/app/util/__init__.py b/app/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/util/approve.py b/app/util/approve.py new file mode 100644 index 0000000..ee9cdf2 --- /dev/null +++ b/app/util/approve.py @@ -0,0 +1,231 @@ +import logging +import os + +import openstack +from python_terraform import Terraform +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select + +from app.models.user import UserModel +from app.util.database import engine +from app.util.discord import Discord +from app.util.email import Email +from app.util.horsepass import HorsePass +from app.util.settings import Settings + +logger = logging.getLogger() + + +# tf = Terraform(working_dir=Settings().infra.tf_directory) + + +class Approve: + """ + This function will ensure a member meets all requirements to be a member, and if so, creates an + Infra account + whitelist them to the Hack@UCF Minecraft server. + + If approval fails, dispatch a Discord message saying that something went wrong and how to fix it. + """ + + def __init__(self): + pass + + def provision_infra(member_id, user_data=None): + # Log into OpenStack + conn = openstack.connect(cloud="hackucf_infra") + + try: + os.remove("terraform.tfstate") + except Exception: + pass + + try: + os.remove("terraform.tfstate.backup") + except Exception: + pass + + try: + with Session(engine) as session: + if not user_data: + user_data = UserModel( + session.exec( + select(UserModel).where(UserModel.id == member_id) + ).one_or_none() + ) + # See if existing email. + username = user_data.infra_email + if username: + user = conn.identity.find_user(username) + if user: + # Delete user's default project + logger.debug(f"user // {user.default_project_id}") + proj = conn.identity.get_project(user.default_project_id) + proj = conn.identity.delete_project(proj) + + # Delete user + conn.identity.delete_user(user) + logger.debug(f"{username}: User deleted.") + else: + logger.debug(f"{username}: No user.") + + else: + username = ( + user_data.discord.username.replace(" ", "_") + + "@infra.hackucf.org" + ) + # Add username to Onboard database + user_data.infra_email = username + session.add(user_data) + session.commit() + session.refresh(user_data) + + password = HorsePass.gen() + + ### + # Let's create a new OpenStack user with the SDK! + ### + + # Create a project for the new users + try: + new_proj = conn.identity.create_project( + name=member_id, + description="Automatically provisioning with Hack@UCF Onboard", + ) + except openstack.exceptions.ConflictException: + # This happens sometimes. + new_proj = conn.identity.find_project("member_id") + + # Create account and important resources via Terraform magics. + new_user = conn.identity.create_user( + default_project_id=new_proj.id, + name=username, + description="Hack@UCF Dues Paying Member", + password=password, + ) + + # Find member role + assign it to user and project + member_role = conn.identity.find_role("member") + conn.identity.assign_project_role_to_user( + project=new_proj, user=new_user, role=member_role + ) + + # Find admin role + assign it to Onboard user + user project + admin_role = conn.identity.find_role("admin") + conn.identity.assign_project_role_to_user( + project=new_proj, + user=conn.identity.find_user("onboard-service"), + role=admin_role, + ) + + ## Push account to OpenStack via Terraform magics (not used rn) + # tf_vars = {'os_password': options.get('infra', {}).get('ad', {}).get('password'), 'tenant_name': member_id, 'handle': username, 'password': password} + # tf.apply(var=tf_vars, skip_plan=True) + + return {"username": username, "password": password} + except Exception as e: + logger.exception(e) + return None + + # !TODO finish the post-sign-up stuff + testing + def approve_member(member_id): + with Session(engine) as session: + logger.info(f"Re-running approval for {member_id}") + statement = ( + select(UserModel) + .where(UserModel.id == member_id) + .options( + selectinload(UserModel.discord), selectinload(UserModel.ethics_form) + ) + ) + + result = session.exec(statement) + user_data = result.one_or_none() + if not user_data: + raise Exception("User not found.") + # If a member was already approved, kill process. + if user_data.is_full_member: + logger.info("\tAlready full member.") + return True + + # Sorry for the long if statement. But we consider someone a "member" iff: + # - They have a name + # - We have their Discord snowflake + # - They paid dues + # - They signed their ethics form + if ( + user_data.first_name + and user_data.discord_id + and user_data.did_pay_dues + and user_data.ethics_form.signtime != 0 + ): + logger.info("\tNewly-promoted full member!") + + discord_id = user_data.discord_id + + # Create an Infra account. + creds = Approve.provision_infra( + member_id, user_data=user_data + ) # TODO(err): sometimes this is None + if creds is None: + creds = {} + + # Minecraft server + if user_data.minecraft: + pass + # + + # Assign the Dues-Paying Member role + Discord.assign_role(discord_id, Settings().discord.member_role) + + # Send Discord message saying they are a member + welcome_msg = f"""Hello {user_data.first_name}, and welcome to Hack@UCF! + +This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{Settings().http.domain}/profile. + +These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi. + +```yaml +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.")} +``` + +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). + +By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula + +Happy Hacking, + - Hack@UCF Bot + """ + + Discord.send_message(discord_id, welcome_msg) + Email.send_email("Welcome to Hack@UCF", welcome_msg, user_data.email) + # Set member as a "full" member. + user_data.is_full_member = True + session.add(user_data) + session.commit() + session.refresh(user_data) + + elif user_data.did_pay_dues: + logger.info("\tPaid dues but did not do other step!") + # Send a message on why this check failed. + fail_msg = f"""Hello {user_data.first_name}, + +We wanted to let you know that you **did not** complete all of the steps for being able to become an Hack@UCF member. + +- Provided a name: {'✅' if user_data.first_name else '❌'} +- Signed Ethics Form: {'✅' if user_data.ethics_form.signtime != 0 else '❌'} +- Paid $10 dues: ✅ + +Please complete all of these to become a full member. Once you do, visit https://{Settings().http.domain}/profile to re-run this check. + +If you think you have completed all of these, please reach out to an Exec on the Hack@UCF Discord. + +We hope to see you soon, + - Hack@UCF Bot +""" + Discord.send_message(discord_id, fail_msg) + + else: + logger.info("\tDid not pay dues yet.") + + return False diff --git a/util/authentication.py b/app/util/authentication.py similarity index 85% rename from util/authentication.py rename to app/util/authentication.py index 60f4dc9..0f5c0f8 100644 --- a/util/authentication.py +++ b/app/util/authentication.py @@ -6,9 +6,11 @@ from fastapi.responses import RedirectResponse from jose import jwt +from app.models.user import UserModel + # Import options and errors -from util.errors import Errors -from util.settings import Settings +from app.util.errors import Errors +from app.util.settings import Settings class Authentication: @@ -73,7 +75,7 @@ async def wrapper_member( token: Optional[str], user_jwt: Optional[object], *args, - **kwargs + **kwargs, ): # Validate auth. if not token: @@ -111,3 +113,21 @@ async def wrapper_member( return await func(request, token, user_jwt, *args, **kwargs) return wrapper_member + + def create_jwt(user: UserModel): + jwtData = { + "discord": user.discord_id, + "name": user.discord.username, + "pfp": user.discord.avatar, + "id": str(user.id), + "sudo": user.sudo, + "is_full_member": user.is_full_member, + "issued": time.time(), + "infra_email": user.infra_email, + } + bearer = jwt.encode( + jwtData, + Settings().jwt.secret.get_secret_value(), + algorithm=Settings().jwt.algorithm, + ) + return bearer diff --git a/app/util/database.py b/app/util/database.py new file mode 100644 index 0000000..2b141a0 --- /dev/null +++ b/app/util/database.py @@ -0,0 +1,34 @@ +# Create the database +from alembic import config, script +from alembic.runtime import migration +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + +from app.util.settings import Settings + +DATABASE_URL = Settings().database.url +# TODO remove echo=True +engine = create_engine( + DATABASE_URL, + # echo=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + + +def init_db(): + 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()) diff --git a/util/discord.py b/app/util/discord.py similarity index 97% rename from util/discord.py rename to app/util/discord.py index 3f1e71e..c5e150c 100644 --- a/util/discord.py +++ b/app/util/discord.py @@ -2,7 +2,7 @@ import requests -from util.settings import Settings +from app.util.settings import Settings headers = { "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", diff --git a/util/email.py b/app/util/email.py similarity index 96% rename from util/email.py rename to app/util/email.py index 8f36c53..0c57dc6 100644 --- a/util/email.py +++ b/app/util/email.py @@ -4,7 +4,7 @@ import commonmark -from util.settings import Settings +from app.util.settings import Settings email = Settings().email.email password = Settings().email.password.get_secret_value() diff --git a/util/errors.py b/app/util/errors.py similarity index 92% rename from util/errors.py rename to app/util/errors.py index e2d14f6..4266d63 100644 --- a/util/errors.py +++ b/app/util/errors.py @@ -1,6 +1,6 @@ from fastapi.templating import Jinja2Templates -templates = Jinja2Templates(directory="templates") +templates = Jinja2Templates(directory="app/templates") class Errors: diff --git a/app/util/forms.py b/app/util/forms.py new file mode 100644 index 0000000..b4767f6 --- /dev/null +++ b/app/util/forms.py @@ -0,0 +1,70 @@ +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"}: + return True + if value_test in {"no", "false", "0", "No"}: + return False + if "i promise not" 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/horsepass.py b/app/util/horsepass.py similarity index 100% rename from util/horsepass.py rename to app/util/horsepass.py diff --git a/util/kennelish.py b/app/util/kennelish.py similarity index 95% rename from util/kennelish.py rename to app/util/kennelish.py index f299d26..d14d7c0 100644 --- a/util/kennelish.py +++ b/app/util/kennelish.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) + # Known bug: You cannot pre-fill data stored in second-level DynamoDB levels. # So "parent.child" won't retrieve a value. class Kennelish: @@ -68,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"): @@ -224,27 +225,30 @@ 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})$)") + 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") is not None: obj[el.get("key")] = (str, None) diff --git a/util/limiter.py b/app/util/limiter.py similarity index 100% rename from util/limiter.py rename to app/util/limiter.py diff --git a/util/settings.py b/app/util/settings.py similarity index 75% rename from util/settings.py rename to app/util/settings.py index 707dd6d..004c149 100644 --- a/util/settings.py +++ b/app/util/settings.py @@ -3,6 +3,7 @@ import os import re import subprocess +from typing import Optional import yaml from pydantic import BaseModel, SecretStr, constr @@ -10,36 +11,42 @@ logger = logging.getLogger(__name__) + 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)): + 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 + bitwarden_raw = subprocess.run( + command, text=True, env=env_vars, capture_output=True + ).stdout except Exception as e: logger.exception(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') + "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 = {} @@ -56,23 +63,27 @@ def BitwardenConfig(settings: dict): settings[top_key][nested_key] = value return settings + settings = dict() # Reads config from ../config/options.yml here = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(here, "../config/options.yml")) as f: +with open(os.path.join(here, "../../config/options.yml")) as f: settings.update(yaml.load(f, Loader=yaml.FullLoader)) + def parse_json_to_dict(json_string): data = json.loads(json_string) - return {item['key']: item['value'] for item in data} + return {item["key"]: item["value"] for item in data} + # If bitwarden is enabled, add secrets to settings -if settings.get('bws').get('enable'): +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. @@ -86,6 +97,7 @@ class DiscordConfig(BaseModel): scope (str): The scope of permissions required for the Discord integration. secret (SecretStr): The secret key for the Discord oauth. """ + bot_token: SecretStr client_id: int guild_id: int @@ -94,7 +106,10 @@ class DiscordConfig(BaseModel): scope: str secret: SecretStr -discord_config = DiscordConfig(**settings['discord']) + +discord_config = DiscordConfig(**settings["discord"]) + + class StripeConfig(BaseModel): """ Configuration class for Stripe integration. @@ -106,13 +121,17 @@ class StripeConfig(BaseModel): url_success (str): The URL to redirect to on successful payment. url_failure (str): The URL to redirect to on failed payment. """ + api_key: SecretStr webhook_secret: SecretStr price_id: str url_success: str url_failure: str pause_payments: bool -stripe_config = StripeConfig(**settings['stripe']) + + +stripe_config = StripeConfig(**settings["stripe"]) + class EmailConfig(BaseModel): """ @@ -123,10 +142,14 @@ class EmailConfig(BaseModel): email (str): The email address to send from also used as the login username. password (SecretStr): The password for the email account. """ + smtp_server: str email: str password: SecretStr -email_config = EmailConfig(**settings['email']) + + +email_config = EmailConfig(**settings["email"]) + class JwtConfig(BaseModel): """ @@ -138,15 +161,15 @@ class JwtConfig(BaseModel): 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: str lifetime_user: int lifetime_sudo: int -jwt_config = JwtConfig(**settings['jwt']) -class DynamodbConfig(BaseModel): - table: str -dynamodb_config = DynamodbConfig(**settings['aws']['dynamodb']) + +jwt_config = JwtConfig(**settings["jwt"]) + class InfraConfig(BaseModel): """ @@ -159,22 +182,47 @@ class InfraConfig(BaseModel): application_credential_secret (SecretStr): The application credential secret used to provision users and projects. tf_directory (str): The Terraform directory. """ + wifi: str horizon: str application_credential_id: str application_credential_secret: SecretStr tf_directory: str -infra_config = InfraConfig(**settings['infra']) + + +infra_config = InfraConfig(**settings["infra"]) + + +class TelemetryConfig(BaseModel): + url: Optional[str] = None + enable: Optional[bool] = False + + +telemetry_config = TelemetryConfig(**settings["telemetry"]) + + +class DatabaseConfig(BaseModel): + url: str + + +database_config = DatabaseConfig(**settings["database"]) + class RedisConfig(BaseModel): host: str port: int db: int -redis_config = RedisConfig(**settings['redis']) + + +redis_config = RedisConfig(**settings["redis"]) + class HttpConfig(BaseModel): domain: str -http_config = HttpConfig(**settings['http']) + + +http_config = HttpConfig(**settings["http"]) + class SingletonBaseSettingsMeta(type(BaseSettings), type): _instances = {} @@ -183,12 +231,15 @@ 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 stripe: StripeConfig = stripe_config email: EmailConfig = email_config jwt: JwtConfig = jwt_config - aws: DynamodbConfig = dynamodb_config + database: DatabaseConfig = database_config infra: InfraConfig = infra_config redis: RedisConfig = redis_config http: HttpConfig = http_config + telemetry: TelemetryConfig = telemetry_config diff --git a/config.yml b/config.yml index 382d41b..e9755ed 100644 --- a/config.yml +++ b/config.yml @@ -48,3 +48,7 @@ redis: host: "localhost" port: 6379 db: 0 + +telementary: + url: + enable: diff --git a/docker-compose-tests.yml b/docker-compose-tests.yml new file mode 100644 index 0000000..98c4b37 --- /dev/null +++ b/docker-compose-tests.yml @@ -0,0 +1,46 @@ +services: + fastapi: + build: + context: . + dockerfile: Dockerfile-testing + environment: + - BWS_ACCESS_TOKEN={$BWS_ACCESS_TOKEN} + ports: + - 8000:8000 + volumes: + - ./config/options.yml:/app/config/options.yml + - $HOME/.aws:/root/.aws + develop: + watch: + # sync static content + - path: ./static + action: sync + target: /app/static + # sync templates + - path: ./templates + action: sync + target: /app/templates + - path: ./forms + action: sync + target: /app/templates + # sync app + - path: ./models/ + action: sync+restart + target: /app/models/ + - path: ./routes/ + action: sync+restart + target: /app/routes/ + - path: ./tests/ + action: rebuild + target: /app/tests/ + - path: ./util/ + action: sync+restart + target: /app/util/ + - path: ./index.py + action: sync+restart + target: /app/index.py + - path: ./requirements.txt + action: rebuild + target: /app/requirements.txt + redis: + image: redis:7.2 diff --git a/docker-compose.yml b/docker-compose.yml index 3e3916d..9593090 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: fastapi: build: . @@ -7,33 +6,36 @@ services: ports: - 8000:8000 volumes: - - ./config/options.yml:/app/config/options.yml - - $HOME/.aws:/root/.aws + - ./config/options.yml:/src/config/options.yml + - ./database/:/data/ develop: watch: # sync static content - - path: ./static + - path: ./app/static action: sync - target: /app/static + target: /src/app/static # sync templates - - path: ./templates + - path: ./app/templates action: sync - target: /app/templates + target: /src/app/templates + - path: ./app/forms + action: sync + target: /src/app/templates # sync app - - path: ./models/ + - path: ./app/app/models/ action: sync+restart - target: /app/models/ - - path: ./routes/ + target: /src/app/models/ + - path: ./app/routes/ action: sync+restart - target: /app/routes/ - - path: ./util/ + target: /src/app/routes/ + - path: ./app/util/ action: sync+restart - target: /app/util/ - - path: ./index.py + target: /src/app/util/ + - path: ./app/index.py action: sync+restart - target: /app/index.py + target: /src/app/index.py - path: ./requirements.txt action: rebuild - target: /app/requirements.txt + target: /src/app/requirements.txt redis: image: redis:7.2 diff --git a/models/user.py b/models/user.py deleted file mode 100644 index 4787b81..0000000 --- a/models/user.py +++ /dev/null @@ -1,147 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - - -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 EthicsFormModel(BaseModel): - hack_others: Optional[bool] = False - hack_ucf: Optional[bool] = False - interrupt_ucf: Optional[bool] = False - manip_traffic: Optional[bool] = False - bypass_dhcp: Optional[bool] = False - pirate: Optional[bool] = False - host_at_ucf: Optional[bool] = False - signtime: Optional[int] = 0 - - -class CyberLabModel(BaseModel): - 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 - - -class MenteeModel(BaseModel): - 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 - - -class UserModel(BaseModel): - # Identifiers - id: str - discord_id: str - ucf_id: Optional[int] = None - nid: Optional[str] = None - ops_email: Optional[str] = None - infra_email: Optional[str] = None - - minecraft: Optional[str] = "" - github: Optional[str] = "" - - # PII - first_name: Optional[str] = "" - surname: Optional[str] = "" - email: Optional[str] = "" - is_returning: Optional[bool] = False - gender: Optional[str] = "" - major: Optional[str] = "" - class_standing: Optional[str] = "" - shirt_size: Optional[str] = "" - did_get_shirt: Optional[bool] = False - time_availability: Optional[str] = "" - phone_number: Optional[int] = 0 - - # Permissions and Member Status - sudo: Optional[bool] = False - did_pay_dues: Optional[bool] = False - join_date: Optional[int] = None - - # Paperwork Signed - ethics_form: Optional[EthicsFormModel] = EthicsFormModel() - cyberlab_monitor: Optional[CyberLabModel] = CyberLabModel() - - # Mentorship Program - mentee: Optional[MenteeModel] = MenteeModel() - mentor_name: Optional[str] = None - - is_full_member: Optional[bool] = False - can_vote: Optional[bool] = False - - # Other models - discord: DiscordModel - experience: Optional[int] = None - curiosity: Optional[str] = None - c3_interest: Optional[bool] = False - - # Other things - attending: Optional[str] = "" - comments: Optional[str] = "" - - -# What admins can edit. -class UserModelMutable(BaseModel): - # Identifiers - id: str - discord_id: Optional[str] = None - ucf_id: Optional[int] = None - nid: Optional[str] = None - ops_email: Optional[str] = None - infra_email: Optional[str] = None - - minecraft: Optional[str] = None - github: Optional[str] = None - - # PII - first_name: Optional[str] = None - surname: Optional[str] = None - email: Optional[str] = None - is_returning: Optional[bool] = None - gender: Optional[str] = None - major: Optional[str] = None - class_standing: Optional[str] = None - shirt_size: Optional[str] = None - did_get_shirt: Optional[bool] = None - phone_number: Optional[int] = None - - # Permissions and Member Status - sudo: Optional[bool] = None - did_pay_dues: Optional[bool] = None - - # Mentorship Program - mentor_name: Optional[str] = None - - is_full_member: Optional[bool] = None - can_vote: Optional[bool] = False - - # Other models - experience: Optional[int] = None - curiosity: Optional[str] = None - c3_interest: Optional[bool] = None - - # Other things - attending: Optional[str] = None - comments: Optional[str] = None - - -class PublicContact(BaseModel): - first_name: str - surname: str - ops_email: str diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e4c7a44 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +; log_cli=true +; log_level=INFO +addopts = -p no:warnings diff --git a/requirements.txt b/requirements.txt index e7f0060..33771dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ airpress==1.0.3 asyncio==3.4.3 -boto3==1.34.102 cryptography==39.0.1 commonmark==0.9.1 fastapi==0.111.0 @@ -24,3 +23,9 @@ stripe==9.6.0 typing_extensions==4.11.0 uvicorn==0.29.0 virtualenv==20.26.1 +httpx +pytest +sqlmodel +sentry_sdk +alembic +aiosqlite diff --git a/routes/api.py b/routes/api.py deleted file mode 100644 index dea1305..0000000 --- a/routes/api.py +++ /dev/null @@ -1,186 +0,0 @@ -import json -from typing import Optional - -import boto3 -from botocore.exceptions import ClientError -from fastapi import APIRouter, Cookie, HTTPException, Request -from fastapi.responses import HTMLResponse -from pydantic import error_wrappers - -from models.info import InfoModel -from models.user import PublicContact -from util.authentication import Authentication -from util.errors import Errors -from util.forms import Forms -from util.kennelish import Kennelish, Transformer -from util.settings import Settings - -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): - 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. -""" - - -@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/{num}") -@Authentication.member -async def post_form( - request: Request, - token: Optional[str] = Cookie(None), - user_jwt: Optional[object] = {}, - num: str = 1, -): - # 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."} - - try: - # this only parses the data into an arbitrary pydantic model, - # it doesn't actually validate form field completion as far as I can tell - validated = model(**inp) - except error_wrappers.ValidationError: - 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: - # What is Item[0] and Item[1]??? - if item[1] is not None: - # English -> Boolean - if item[1] == "Yes" or item[1] == "I promise not to do this.": - item = (item[0], True) - elif ( - item[1] == "No" - 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(Settings().aws.table) - - # Push data back to DynamoDB - try: - table.update_item( - Key={"id": user_jwt.get("id")}, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_attribute_values, - ) - except ClientError: - # We need to do a migration on *something*. We know it's a subtype. - # So we will find it and migrate it. - for item in items_to_keep: - if "." in item[0]: - dot_loc = item[0].find(".") - key_to_make = item[0][:dot_loc] - - # Create dictionary - table.update_item( - Key={"id": user_jwt.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": user_jwt.get("id")}, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_attribute_values, - ) - - return validated diff --git a/routes/infra.py b/routes/infra.py deleted file mode 100644 index bed924e..0000000 --- a/routes/infra.py +++ /dev/null @@ -1,340 +0,0 @@ -import asyncio -import json -import logging -import os -from typing import Optional - -import boto3 -import openstack -from fastapi import APIRouter, Cookie, Request -from fastapi.responses import FileResponse -from fastapi.templating import Jinja2Templates -from python_terraform import Terraform - -from models.info import InfoModel -from models.user import PublicContact -from util.approve import Approve -from util.authentication import Authentication -from util.discord import Discord -from util.email import Email -from util.errors import Errors -from util.limiter import RateLimiter -from util.settings import Settings - -logger = logging.getLogger(__name__) - - -templates = Jinja2Templates(directory="templates") - -router = APIRouter(prefix="/infra", tags=["Infra"], responses=Errors.basic_http()) - -tf = Terraform(working_dir="./") - -rate_limiter = RateLimiter( - Settings().redis.host, Settings().redis.port, Settings().redis.db -) - -rate_limiter.get_redis() - - -def get_shitty_database(): - """ - Dump contents of the file that stores infra options. - I lovingly call this the "shitty database." - """ - data = {} - opts_path = "infra_options.json" - try: - with open(opts_path, "r") as f: - data = json.loads(f.read()) - except Exception as e: - logger.exception(f"Invalid config file at {opts_path}", e) - data = {"gbmName": None, "imageId": None} - - return data - - -async def create_resource(project, callback_discord_id=None): - shitty_database = get_shitty_database() - proj_name = project.name - - logger.info(f"Creating resources for {proj_name}...") - - tf_vars = { - "application_credential_id": Settings().infra.application_credential_id, - "application_credential_secret": Settings().infra.application_credential_secret.get_secret_value(), - "tenant_name": proj_name, - "gbmname": shitty_database.get("gbmName"), - "imageid": shitty_database.get("imageId"), - "member_username": project.id, - } - return_code, stdout, stderr = tf.apply(var=tf_vars, skip_plan=True) - if return_code != 0: - logger.exception("Terraform failed!") - logger.debug(f"\treturn: {return_code}") - logger.debug(f"\tstderr: {stderr}\n") - - # clean up - try: - os.remove("terraform.tfstate") - except Exception: - pass - - try: - os.remove("terraform.tfstate.backup") - except Exception: - pass - - if callback_discord_id: - resource_create_msg = f"""Hello! - -Your requested virtual machine has been created! You can now view it at {Settings().infra.horizon}. - -Enjoy, - - Hack@UCF Bot -""" - Discord.send_message(callback_discord_id, resource_create_msg) - - logger.info("\tDone!") - - -async def teardown(): - logger.debug("Initializing post-GBM teardown...") - death_word = "gbm" - - conn = openstack.connect(cloud="hackucf_infra") - - logger.debug("\tServers...") - for resource in conn.compute.servers(all_projects=True): - # logger.debug("\t" + resource.name) - if death_word in resource.name.lower(): - logger.debug(f"\t\tdelete {resource.name}") - conn.compute.delete_server(resource) - - logger("\tSec Groups...") - for resource in conn.network.security_groups(): - # logger.debug("\t" + resource.name) - if death_word in resource.name.lower(): - logger.debug(f"\t\tdelete {resource.name}") - conn.network.delete_security_group(resource) - - logger.debug("\tRouters...") - for resource in conn.network.routers(): - # logger.debug("\t" + resource.name) - if death_word in resource.name.lower(): - logger.debug(f"\t\tdelete {resource.name}") - try: - conn.network.delete_router(resource) - except openstack.exceptions.ConflictException as e: - port_id_list = str(e).split(": ")[-1].split(",") - for port_id in port_id_list: - logger.debug(f"\t\t\tdelete/abandon port: {port_id}") - conn.network.remove_interface_from_router(resource, port_id=port_id) - conn.network.delete_port(port_id) - try: - conn.network.delete_router(resource) - except: # noqa - logger.debug("\t\t\t\tFailed and gave up.") - - logger.debug("\tNetworks...") - for resource in conn.network.networks(): - # logger.debug("\t" + resource.name) - if death_word in resource.name.lower(): - logger.debug(f"\t\tdelete {resource.name}") - try: - conn.network.delete_network(resource) - except openstack.exceptions.ConflictException as e: - port_id_list = str(e).split(": ")[-1][:-1].split(",") - for port_id in port_id_list: - logger.debug(f"\t\t\tdelete port: {port_id}") - try: - conn.network.delete_port(port_id) - except: # noqa - pass - try: - conn.network.delete_network(resource) - except: #noqa - logger.debug("\t\t\t\tFailed and gave up.") - logger.debug("\tDone!") - - -""" -Get API information. -""" - - -@router.get("/") -async def get_root(): - return InfoModel( - name="Onboard Infra", - description="Infrastructure Management via Onboard.", - credits=[ - PublicContact( - first_name="Jeffrey", - surname="DiVincent", - ops_email="jdivincent@hackucf.org", - ), - PublicContact( - first_name="Caleb", - surname="Sjostedt", - ops_email="csjostedt@hackucf.org", - ), - ], - ) - - -""" -API endpoint to self-service create a GBM environment. -""" - - -@router.get("/provision/") -@Authentication.member -async def get_provision( - request: Request, - token: Optional[str] = Cookie(None), - user_jwt: Optional[object] = {}, -): - conn = openstack.connect(cloud="hackucf_infra") - - # Get single user - user = conn.identity.find_user(user_jwt.get("infra_email")) - - # Get project - project = conn.identity.get_project(user.default_project_id) - - # Provision everything - asyncio.create_task( - create_resource(project, user_jwt.get("discord_id")) - ) # runs teardown async - return {"msg": "Queued."} - - -""" -API endpoint to trigger tear-down of GBM-provisioned stuff. -""" - - -@router.get("/teardown/") -@Authentication.admin -async def get_teardown(request: Request, token: Optional[str] = Cookie(None)): - asyncio.create_task(teardown()) # runs teardown async - return {"msg": "Queued."} - - -""" -API endpoint to SET the one-click deploy Settings(). -""" - - -@router.get("/options/get") -@Authentication.member -async def get_options( - request: Request, - token: Optional[str] = Cookie(None), - user_jwt: Optional[object] = {}, -): - return get_shitty_database() - - -""" -API endpoint to SET the one-click deploy Settings(). -""" - - -@router.get("/options/set") -@Authentication.admin -async def set_options( - request: Request, - token: Optional[str] = Cookie(None), - gbmName: Optional[str] = None, - imageId: Optional[str] = None, -): - shitty_database = {"gbmName": gbmName, "imageId": imageId} - - with open("infra_options.json", "w") as f: - f.write(json.dumps(shitty_database)) - - return shitty_database - - -""" -API endpoint to self-service reset Infra credentials (membership-validating) -""" - - -@router.get("/reset/") -@Authentication.member -@rate_limiter.rate_limit(1, 604800, "reset") -async def get_infra( - request: Request, - token: Optional[str] = Cookie(None), - user_jwt: Optional[object] = {}, -): - member_id = user_jwt.get("id") - - if not (user_jwt.get("is_full_member") or user_jwt.get("infra_email")): - return Errors.generate( - request, 403, "This API endpoint is restricted to Dues-Paying Members." - ) - - # This also reprovisions Infra access if an account already exists. - # This is useful for cleaning up things + nuking in case of an error. - creds = Approve.provision_infra(member_id) - - if not creds: - creds = {} - - # Get user data - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - user_data = table.get_item(Key={"id": member_id}).get("Item", None) - - # Send DM... - new_creds_msg = f"""Hello {user_data.get('first_name')}, - -You have requested to reset your Hack@UCF Infrastructure credentials. This change comes with new credentials. - -A reminder that you can use these credentials 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.")} -``` - -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). - -By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula - -Happy Hacking, - - - Hack@UCF Bot - """ - - # Send Discord message - # Discord.send_message(user_data.get("discord_id"), new_creds_msg) - # Send Email - Email.send_email("Reset Infra Credentials", new_creds_msg, user_data.get("email")) - - return {"username": creds.get("username"), "password": creds.get("password")} - - -""" -An endpoint to Download OpenVPN profile -""" - - -@router.get("/openvpn") -@Authentication.member -@rate_limiter.rate_limit(5, 60, "ovpn") -async def download_file( - request: Request, - token: Optional[str] = Cookie(None), - user_jwt: Optional[object] = {}, -): - # Replace 'path/to/your/file.txt' with the actual path to your file - file_path = "./HackUCF.ovpn" - return FileResponse( - file_path, filename="HackUCF.ovpn", media_type="application/octet-stream" - ) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4b8f16e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,7 @@ +exclude = [ + "tests" +] + + +[lint] +ignore = ["F401"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ed06257 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +import os +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine, inspect +from sqlmodel.pool import StaticPool + +from app.main import app, get_session +from app.models.user import DiscordModel, UserModel +from app.util.authentication import Authentication + + +@pytest.fixture(name="engine") +def engine_fixture(): + url = f"sqlite://" + engine = create_engine( + url, connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture(name="session") +def session_fixture(engine): + with Session(engine) as session: + yield session + + +@pytest.fixture(name="client") +def client_fixture(session: Session): + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture(name="test_user") +def test_user_fixture(session: Session): + test_user_discord = DiscordModel( + id=1, + email="test_user@example.com", + mfa=False, + banner="https://upload.wikimedia.org/wikipedia/commons/e/e1/Banner_on_Wikivoyage.png", + avatar="https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Style_-_Wouldn%27t_It_Be_Nice.png/600px-Style_-_Wouldn%27t_It_Be_Nice.png", + color="1738207", + nitro=False, + locale="en_US", + username="test_user", + ) + test_user = UserModel( + id=uuid.uuid4(), + discord_id="669276074563666347", + ucf_id=123456, + nid="ko123456", + ops_email="ops_test@example.com", + infra_email="infra_test@example.com", + minecraft="test_minecraft", + github="test_github", + first_name="Test", + surname="User", + email="test_user@example.com", + is_returning=False, + gender="M", + major="Computer Science", + class_standing="Senior", + shirt_size="M", + did_get_shirt=False, + phone_number=1234567890, + sudo=False, + did_pay_dues=False, + mentor_name="Test Mentor", + is_full_member=True, + can_vote=False, + experience=1, + curiosity="Very curious", + c3_interest=False, + attending="Yes", + comments="Test comments", + discord=test_user_discord, + ) + session.add(test_user) + session.commit() + return test_user + + +@pytest.fixture(name="jwt") +def jwt_fixture(test_user: UserModel): + return Authentication.create_jwt(test_user) diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 0000000..9b4ca40 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,31 @@ +import os +import sys + +from fastapi.testclient import TestClient + +# Add the project root to the PYTHONPATH +import app.main +from app.main import app + +client = TestClient(app) + + +def test_get_static(): + test_files = [ + "admin.js", + "admin_logo.svg", + "apple_wallet.svg", + "favicon.ico", + "favicon.svg", + "form.js", + "hackucf.css", + "index.html", + "lib/qr-scanner.umd.min.js", + "lib/qr-scanner.min.js", + "lib/qr-scanner-worker.min.js", + "qr_hack_dark.svg", + "qr_hack_light.svg", + ] + for file in test_files: + get_static = client.get("/static/" + file) + assert get_static.status_code == 200, f"Failed to retrieve file: {file}" diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..cfb3f27 --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,31 @@ +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.models.user import UserModel + + +# jwt: str +@patch("app.util.approve.Approve.approve_member", return_value=None) +def test_profile(mock_approve, client: TestClient, jwt: str): + response = client.get("/profile/", cookies={"token": jwt}) + # response = client.get("/profile") + assert response.status_code == 200 + assert "test_user@example.com" in response.text + + +def test_openvpn(client: TestClient, jwt: str): + response = client.get("/infra/openvpn/", cookies={"token": jwt}) + assert response.status_code == 200 + + +def test_db(client: TestClient, session: Session, jwt: str): + user_in_db = ( + session.query(UserModel) + .filter(UserModel.discord_id == "669276074563666347") + .first() + ) + assert user_in_db is not None + assert user_in_db.email == "test_user@example.com" diff --git a/util/approve.py b/util/approve.py deleted file mode 100644 index aeb362b..0000000 --- a/util/approve.py +++ /dev/null @@ -1,222 +0,0 @@ -import logging -import os - -import boto3 -import openstack -from python_terraform import Terraform - -from util.discord import Discord -from util.email import Email -from util.horsepass import HorsePass -from util.settings import Settings - -logger = logging.getLogger() - - -tf = Terraform(working_dir=Settings().infra.tf_directory) - -""" -This function will ensure a member meets all requirements to be a member, and if so, creates an -Infra account + whitelist them to the Hack@UCF Minecraft server. - -If approval fails, dispatch a Discord message saying that something went wrong and how to fix it. -""" - - -class Approve: - def __init__(self): - pass - - def provision_infra(member_id, user_data=None): - # Log into OpenStack - conn = openstack.connect(cloud="hackucf_infra") - - try: - os.remove("terraform.tfstate") - except Exception: - pass - - try: - os.remove("terraform.tfstate.backup") - except Exception: - pass - - try: - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - if not user_data: - user_data = table.get_item(Key={"id": member_id}).get("Item", None) - - # See if existing email. - username = user_data.get("infra_email", False) - if username: - user = conn.identity.find_user(username) - if user: - # Delete user's default project - logger.debug(f"user // {user.default_project_id}") - proj = conn.identity.get_project(user.default_project_id) - proj = conn.identity.delete_project(proj) - - # Delete user - conn.identity.delete_user(user) - logger.debug(f"{username}: User deleted.") - else: - logger.debug(f"{username}: No user.") - - else: - username = ( - user_data.get("discord", {}).get("username").replace(" ", "_") - + "@infra.hackucf.org" - ) - # Add username to Onboard database - table.update_item( - Key={"id": member_id}, - UpdateExpression="SET infra_email = :val", - ExpressionAttributeValues={":val": username}, - ) - - password = HorsePass.gen() - - ### - # Let's create a new OpenStack user with the SDK! - ### - - # Create a project for the new users - try: - new_proj = conn.identity.create_project( - name=member_id, - description="Automatically provisioning with Hack@UCF Onboard", - ) - except openstack.exceptions.ConflictException: - # This happens sometimes. - new_proj = conn.identity.find_project("member_id") - - # Create account and important resources via Terraform magics. - new_user = conn.identity.create_user( - default_project_id=new_proj.id, - name=username, - description="Hack@UCF Dues Paying Member", - password=password, - ) - - # Find member role + assign it to user and project - member_role = conn.identity.find_role("member") - conn.identity.assign_project_role_to_user( - project=new_proj, user=new_user, role=member_role - ) - - # Find admin role + assign it to Onboard user + user project - admin_role = conn.identity.find_role("admin") - conn.identity.assign_project_role_to_user( - project=new_proj, - user=conn.identity.find_user("onboard-service"), - role=admin_role, - ) - - ## Push account to OpenStack via Terraform magics (not used rn) - # tf_vars = {'os_password': options.get('infra', {}).get('ad', {}).get('password'), 'tenant_name': member_id, 'handle': username, 'password': password} - # tf.apply(var=tf_vars, skip_plan=True) - - return {"username": username, "password": password} - except Exception as e: - logger.exception(e) - return None - - # !TODO finish the post-sign-up stuff + testing - def approve_member(member_id): - logger.info(f"Re-running approval for {member_id}") - dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(Settings().aws.table) - - user_data = table.get_item(Key={"id": member_id}).get("Item", None) - - # If a member was already approved, kill process. - if user_data.get("is_full_member", False): - logger.info("\tAlready full member.") - return True - - # Sorry for the long if statement. But we consider someone a "member" iff: - # - They have a name - # - We have their Discord snowflake - # - They paid dues - # - They signed their ethics form - if ( - user_data.get("first_name") - and user_data.get("discord_id") - and user_data.get("did_pay_dues") - and user_data.get("ethics_form", {}).get("signtime", 0) != 0 - ): - logger.info("\tNewly-promoted full member!") - - discord_id = user_data.get("discord_id") - - # Create an Infra account. - creds = Approve.provision_infra( - member_id, user_data=user_data - ) # TODO(err): sometimes this is None - if creds is None: - creds = {} - - # Minecraft server - if user_data.get("minecraft", False): - pass - # - - # Assign the Dues-Paying Member role - Discord.assign_role( - discord_id, Settings().discord.member_role - ) - - # Send Discord message saying they are a member - welcome_msg = f"""Hello {user_data.get('first_name')}, and welcome to Hack@UCF! - -This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{Settings().http.domain}/profile. - -These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi. - -```yaml -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.")} -``` - -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). - -By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula - -Happy Hacking, - - Hack@UCF Bot - """ - - Discord.send_message(discord_id, welcome_msg) - Email.send_email("Welcome to Hack@UCF", welcome_msg, user_data.get("email")) - # Set member as a "full" member. - table.update_item( - Key={"id": member_id}, - UpdateExpression="SET is_full_member = :val", - ExpressionAttributeValues={":val": True}, - ) - - elif user_data.get("did_pay_dues"): - logger.info("\tPaid dues but did not do other step!") - # Send a message on why this check failed. - fail_msg = f"""Hello {user_data.get('first_name')}, - -We wanted to let you know that you **did not** complete all of the steps for being able to become an Hack@UCF member. - -- Provided a name: {'✅' if user_data.get('first_name') else '❌'} -- Signed Ethics Form: {'✅' if user_data.get('ethics_form', {}).get('signtime', 0) != 0 else '❌'} -- Paid $10 dues: ✅ - -Please complete all of these to become a full member. Once you do, visit https://{Settings().http.domain}/profile to re-run this check. - -If you think you have completed all of these, please reach out to an Exec on the Hack@UCF Discord. - -We hope to see you soon, - - Hack@UCF Bot -""" - Discord.send_message(discord_id, fail_msg) - - else: - logger.info("\tDid not pay dues yet.") - - return False diff --git a/util/forms.py b/util/forms.py deleted file mode 100644 index a51f552..0000000 --- a/util/forms.py +++ /dev/null @@ -1,13 +0,0 @@ -import json -import os - - -class Forms: - def get_form_body(file="1"): - #if file.contains("..", "\\"): - # raise ValueError("Invalid file name") - try: - form_file = os.path.join(os.getcwd(), "forms", f"{file}.json") - return json.load(open(form_file, "r")) - except FileNotFoundError: - raise