From 9477f1ad8d0e3d23f22824a3551715d1f2bf0aba Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Wed, 22 Nov 2023 23:45:27 -0500 Subject: [PATCH] refactor: clean up old FastAPI logic, temporary utils --- Taskfile.backend.yml | 9 - backend/Dockerfile | 6 +- backend/Dockerfile-django | 22 -- backend/pyproject.toml | 2 +- .../{rotini_django => rotini}/auth/admin.py | 0 .../{rotini_django => rotini}/auth/apps.py | 0 backend/rotini/auth/base.py | 31 --- backend/rotini/auth/decorators.py | 25 -- backend/{rotini_django => rotini}/auth/jwt.py | 0 backend/rotini/auth/middleware.py | 57 ++-- .../auth/middleware_test.py | 0 backend/rotini/auth/routes.py | 68 ----- .../{rotini_django => rotini}/auth/tests.py | 0 .../{rotini_django => rotini}/auth/urls.py | 0 backend/rotini/auth/use_cases.py | 149 ---------- .../{rotini_django => rotini}/auth/views.py | 0 backend/rotini/{ => base}/__init__.py | 0 .../{rotini_django => rotini}/base/asgi.py | 0 .../base/env_settings/test.py | 0 .../base/settings.py | 0 .../{rotini_django => rotini}/base/urls.py | 0 .../{rotini_django => rotini}/base/wsgi.py | 0 backend/{rotini_django => rotini}/conftest.py | 0 backend/rotini/db.py | 16 -- backend/rotini/envs/ci.py | 5 - backend/rotini/envs/local.py | 5 - backend/rotini/envs/migrate.py | 3 - backend/rotini/envs/test.py | 9 - backend/rotini/exceptions.py | 4 - .../{rotini_django => rotini}/files/apps.py | 0 backend/rotini/files/base.py | 13 - .../files/migrations/0001_initial.py | 0 .../files/migrations/0002_file_owner.py | 0 .../files/migrations}/__init__.py | 0 .../{rotini_django => rotini}/files/models.py | 0 backend/rotini/files/routes.py | 133 --------- .../files/serializers.py | 0 .../files/test_views.py | 0 .../{rotini_django => rotini}/files/urls.py | 0 backend/rotini/files/use_cases.py | 119 -------- .../{rotini_django => rotini}/files/views.py | 0 backend/rotini/main.py | 34 --- backend/{rotini_django => rotini}/manage.py | 0 backend/rotini/migrations/migrate.py | 256 ------------------ .../rotini/migrations/migration_0_initial.py | 20 -- .../migrations/migration_1_user_table.py | 25 -- .../migrations/migration_2_permissions.py | 27 -- backend/rotini/migrations/test.py | 16 -- backend/rotini/permissions/base.py | 23 -- backend/rotini/permissions/files.py | 26 -- backend/rotini/settings.py | 60 ---- backend/rotini_django/auth/middleware.py | 39 --- backend/rotini_django/base/__init__.py | 0 backend/rotini_django/files/__init__.py | 0 .../files/migrations/__init__.py | 0 backend/tests/__init__.py | 0 backend/tests/conftest.py | 95 ------- backend/tests/test_auth_routes.py | 99 ------- backend/tests/test_files_routes.py | 121 --------- 59 files changed, 30 insertions(+), 1487 deletions(-) delete mode 100644 backend/Dockerfile-django rename backend/{rotini_django => rotini}/auth/admin.py (100%) rename backend/{rotini_django => rotini}/auth/apps.py (100%) delete mode 100644 backend/rotini/auth/base.py delete mode 100644 backend/rotini/auth/decorators.py rename backend/{rotini_django => rotini}/auth/jwt.py (100%) rename backend/{rotini_django => rotini}/auth/middleware_test.py (100%) delete mode 100644 backend/rotini/auth/routes.py rename backend/{rotini_django => rotini}/auth/tests.py (100%) rename backend/{rotini_django => rotini}/auth/urls.py (100%) delete mode 100644 backend/rotini/auth/use_cases.py rename backend/{rotini_django => rotini}/auth/views.py (100%) rename backend/rotini/{ => base}/__init__.py (100%) rename backend/{rotini_django => rotini}/base/asgi.py (100%) rename backend/{rotini_django => rotini}/base/env_settings/test.py (100%) rename backend/{rotini_django => rotini}/base/settings.py (100%) rename backend/{rotini_django => rotini}/base/urls.py (100%) rename backend/{rotini_django => rotini}/base/wsgi.py (100%) rename backend/{rotini_django => rotini}/conftest.py (100%) delete mode 100644 backend/rotini/db.py delete mode 100644 backend/rotini/envs/ci.py delete mode 100644 backend/rotini/envs/local.py delete mode 100644 backend/rotini/envs/migrate.py delete mode 100644 backend/rotini/envs/test.py delete mode 100644 backend/rotini/exceptions.py rename backend/{rotini_django => rotini}/files/apps.py (100%) delete mode 100644 backend/rotini/files/base.py rename backend/{rotini_django => rotini}/files/migrations/0001_initial.py (100%) rename backend/{rotini_django => rotini}/files/migrations/0002_file_owner.py (100%) rename backend/{rotini_django/auth => rotini/files/migrations}/__init__.py (100%) rename backend/{rotini_django => rotini}/files/models.py (100%) delete mode 100644 backend/rotini/files/routes.py rename backend/{rotini_django => rotini}/files/serializers.py (100%) rename backend/{rotini_django => rotini}/files/test_views.py (100%) rename backend/{rotini_django => rotini}/files/urls.py (100%) delete mode 100644 backend/rotini/files/use_cases.py rename backend/{rotini_django => rotini}/files/views.py (100%) delete mode 100644 backend/rotini/main.py rename backend/{rotini_django => rotini}/manage.py (100%) delete mode 100644 backend/rotini/migrations/migrate.py delete mode 100644 backend/rotini/migrations/migration_0_initial.py delete mode 100644 backend/rotini/migrations/migration_1_user_table.py delete mode 100644 backend/rotini/migrations/migration_2_permissions.py delete mode 100644 backend/rotini/migrations/test.py delete mode 100644 backend/rotini/permissions/base.py delete mode 100644 backend/rotini/permissions/files.py delete mode 100644 backend/rotini/settings.py delete mode 100644 backend/rotini_django/auth/middleware.py delete mode 100644 backend/rotini_django/base/__init__.py delete mode 100644 backend/rotini_django/files/__init__.py delete mode 100644 backend/rotini_django/files/migrations/__init__.py delete mode 100644 backend/tests/__init__.py delete mode 100644 backend/tests/conftest.py delete mode 100644 backend/tests/test_auth_routes.py delete mode 100644 backend/tests/test_files_routes.py diff --git a/Taskfile.backend.yml b/Taskfile.backend.yml index 9c76afa0..fcadab79 100644 --- a/Taskfile.backend.yml +++ b/Taskfile.backend.yml @@ -42,11 +42,6 @@ tasks: deps: [docker-build] cmd: docker run -d -p 8000:8000 --name {{ .APP_CONTAINER_NAME }} {{ .CLI_ARGS }} --add-host docker.host.internal:host-gateway rotini:dev dir: backend/rotini - start-django: - desc: "Starts the backend application." - deps: [docker-build-django] - cmd: docker run -d -p 8000:8000 --name {{ .APP_CONTAINER_NAME }} {{ .CLI_ARGS }} --add-host docker.host.internal:host-gateway rotini:dev-django - dir: backend/rotini stop: desc: "Stops the backend application." cmd: docker rm -f {{ .APP_CONTAINER_NAME }} @@ -78,8 +73,4 @@ tasks: desc: "Builds a docker image from /backend" cmd: docker build --build-arg PYTHON_VERSION=$(cat .python-version) -t rotini:dev . dir: backend - docker-build-django: - desc: "Builds a docker image from /backend" - cmd: docker build --file Dockerfile-django -t rotini:dev-django . - dir: backend diff --git a/backend/Dockerfile b/backend/Dockerfile index 83b0cc73..fd44d82a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,4 @@ -ARG PYTHON_VERSION - -FROM python:$PYTHON_VERSION-slim +FROM python:3.11-slim ENV DEBIAN_FRONTEND=noninteractive @@ -21,4 +19,4 @@ COPY ./rotini ./rotini WORKDIR ./rotini -CMD python3 -m uvicorn main:app --host 0.0.0.0 +CMD python3 -m uvicorn base.asgi:application --host 0.0.0.0 diff --git a/backend/Dockerfile-django b/backend/Dockerfile-django deleted file mode 100644 index 348ef03f..00000000 --- a/backend/Dockerfile-django +++ /dev/null @@ -1,22 +0,0 @@ -FROM python:3.11-slim - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt update && apt upgrade -y - -RUN apt-get install -y \ - gcc \ - libpq-dev - -WORKDIR /app - -COPY ./requirements.txt ./requirements.txt - -RUN python3 -m pip install -U pip==23.0.0 pip-tools==7.1.0 -RUN python3 -m pip install -r ./requirements.txt - -COPY ./rotini_django ./rotini - -WORKDIR ./rotini - -CMD python3 -m uvicorn base.asgi:application --host 0.0.0.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2aad8137..39d6db6d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,7 +28,7 @@ packages = ["rotini"] DJANGO_SETTINGS_MODULE="base.settings" pythonpath=[ ".", - "./rotini_django", + "./rotini", ] [tool.pylint.'MASTER'] diff --git a/backend/rotini_django/auth/admin.py b/backend/rotini/auth/admin.py similarity index 100% rename from backend/rotini_django/auth/admin.py rename to backend/rotini/auth/admin.py diff --git a/backend/rotini_django/auth/apps.py b/backend/rotini/auth/apps.py similarity index 100% rename from backend/rotini_django/auth/apps.py rename to backend/rotini/auth/apps.py diff --git a/backend/rotini/auth/base.py b/backend/rotini/auth/base.py deleted file mode 100644 index 0b728cab..00000000 --- a/backend/rotini/auth/base.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Class declarations and constants for the auth module. -""" -import pydantic - - -class LoginRequestData(pydantic.BaseModel): - """Payload for login requests""" - - username: str - password: str - - -class CreateUserRequestData(pydantic.BaseModel): - """Payload for user creation""" - - username: str - password: str - - -class IdentityTokenData(pydantic.BaseModel): - """Contents of an identity token""" - - exp: int - user_id: int - username: str - token_id: str - - -class UsernameAlreadyExists(Exception): - """Signals a unique constraint violation on username values""" diff --git a/backend/rotini/auth/decorators.py b/backend/rotini/auth/decorators.py deleted file mode 100644 index 16ce3118..00000000 --- a/backend/rotini/auth/decorators.py +++ /dev/null @@ -1,25 +0,0 @@ -import functools - -import fastapi - - -def requires_logged_in(func): - """ - Returns a 401 if the request received does not specify a logged - in user in its state. - - The state is added through auth.middleware functionality. - - Note that this requires the endpoint to be aware of the fastapi.Request - keyword argument passed to it. - """ - - @functools.wraps(func) - async def wrapper(request: fastapi.Request, *args, **kwargs): - if not hasattr(request.state, "user"): - raise fastapi.HTTPException(status_code=401) - - response = await func(request, *args, **kwargs) - return response - - return wrapper diff --git a/backend/rotini_django/auth/jwt.py b/backend/rotini/auth/jwt.py similarity index 100% rename from backend/rotini_django/auth/jwt.py rename to backend/rotini/auth/jwt.py diff --git a/backend/rotini/auth/middleware.py b/backend/rotini/auth/middleware.py index f1f8eb7f..bd4f2246 100644 --- a/backend/rotini/auth/middleware.py +++ b/backend/rotini/auth/middleware.py @@ -1,42 +1,39 @@ -""" -Authentication & authorization middleware logic. -""" import logging -import jwt.exceptions -from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware +import django.http +import django.contrib.auth -import auth.use_cases as auth_use_cases +import auth.jwt logger = logging.getLogger(__name__) +AuthUser = django.contrib.auth.get_user_model() -class AuthenticationMiddleware(BaseHTTPMiddleware): - """ - Decodes Authorization headers if present on the request and sets - identifying fields in the request state. - This information is then leveraged by individual routes to determine - authorization. +class JwtMiddleware: + """ + Middleware that handles using credentials supplied via the authorization + headers on requests to log users in seamlessly. """ - async def dispatch(self, request: Request, call_next): - auth_header = request.headers.get("authorization") - decoded_token = None + def __init__(self, get_response): + self.get_response = get_response - if auth_header is not None: - _, token = auth_header.split(" ") + def __call__(self, request: django.http.HttpRequest) -> django.http.HttpResponse: + authorization_header = request.META.get("HTTP_AUTHORIZATION") + + if authorization_header is not None: try: - decoded_token = auth_use_cases.decode_token(token) - except jwt.exceptions.ExpiredSignatureError as exc: - logger.exception(exc) - - if decoded_token is not None: - logger.info(decoded_token) - request.state.user = { - "username": decoded_token["username"], - "user_id": decoded_token["user_id"], - } - - return await call_next(request) + _, token = authorization_header.split(" ") + decoded_token = auth.jwt.decode_token(token) + + logger.info("Token: %s\nDecoded token: %s", token, decoded_token) + + user = AuthUser.objects.get(pk=decoded_token["user_id"]) + + request.user = user + except Exception as e: + print(e) + return django.http.HttpResponse(status=401) + + return self.get_response(request) diff --git a/backend/rotini_django/auth/middleware_test.py b/backend/rotini/auth/middleware_test.py similarity index 100% rename from backend/rotini_django/auth/middleware_test.py rename to backend/rotini/auth/middleware_test.py diff --git a/backend/rotini/auth/routes.py b/backend/rotini/auth/routes.py deleted file mode 100644 index 01bd1c51..00000000 --- a/backend/rotini/auth/routes.py +++ /dev/null @@ -1,68 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse - -from exceptions import DoesNotExist - -import auth.use_cases as auth_use_cases -import auth.base as auth_base - -router = APIRouter(prefix="/auth") - - -@router.post("/users/", status_code=201) -async def create_user(payload: auth_base.CreateUserRequestData): - """ - POST /auth/users/ - - { - username: string - password: string - } - - 201 { } - - If the user is created successfully, the user object is returned. - - 400 {} - - If the username already exists, or the password is not adequate, - 400 is returned. - """ - try: - user = auth_use_cases.create_new_user( - username=payload.username, raw_password=payload.password - ) - except auth_base.UsernameAlreadyExists as exc: - raise HTTPException(status_code=400) from exc - - return user - - -@router.post("/sessions/") -async def log_in(payload: auth_base.LoginRequestData): - """ - Attempts to log a user in. - - 200 { } - - If the supplied credentials are correct, the user is returned. - - 401 {} - - If the credentials are incorrect, immediate failure. - """ - - try: - user = auth_use_cases.get_user(username=payload.username) - except DoesNotExist as exc: - raise HTTPException(status_code=401) from exc - - if not auth_use_cases.validate_password_for_user(user["id"], payload.password): - raise HTTPException(status_code=401) - - token = auth_use_cases.generate_token_for_user(user) - - return JSONResponse( - content={"username": user["username"]}, - headers={"Authorization": f"Bearer {token}"}, - ) diff --git a/backend/rotini_django/auth/tests.py b/backend/rotini/auth/tests.py similarity index 100% rename from backend/rotini_django/auth/tests.py rename to backend/rotini/auth/tests.py diff --git a/backend/rotini_django/auth/urls.py b/backend/rotini/auth/urls.py similarity index 100% rename from backend/rotini_django/auth/urls.py rename to backend/rotini/auth/urls.py diff --git a/backend/rotini/auth/use_cases.py b/backend/rotini/auth/use_cases.py deleted file mode 100644 index ab897ca6..00000000 --- a/backend/rotini/auth/use_cases.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -User-related use cases. - -Functions in this file are focused on users and passwords. -""" -import datetime -import uuid - -import typing_extensions as typing -import argon2 -import jwt - -from db import get_connection -from exceptions import DoesNotExist -from settings import settings - -import auth.base as auth_base - -password_hasher = argon2.PasswordHasher() - - -class User(typing.TypedDict): - """ - User representation. - - The password hash is never included in these records and should - not leave the database. - """ - - id: int - username: str - created_at: datetime.datetime - updated_at: datetime.datetime - password_updated_at: datetime.datetime - - -def create_new_user(*, username: str, raw_password: str) -> User: - """ - Creates a new user record given a username and password. - - The password is hashed and the hash is stored. - - If successful, returns a dictionary representing the user. - """ - password_hash = password_hasher.hash(raw_password) - - with get_connection() as connection, connection.cursor() as cursor: - try: - cursor.execute( - "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", - (username, password_hash), - ) - returned = cursor.fetchone() - except Exception as exc: - raise auth_base.UsernameAlreadyExists() from exc - - inserted_id = returned[0] - created_username = returned[1] - - return User( - id=inserted_id, - username=created_username, - created_at=datetime.datetime.now(), - updated_at=datetime.datetime.now(), - password_updated_at=datetime.datetime.now(), - ) - - -def get_user( - *, username: str = None, user_id: int = None -) -> typing.Union[typing.NoReturn, User]: - """ - Retrieves a user record, if one exists, for the given user. - - Querying can be done via username or user ID. The first one supplied, in this - order, is used and any other values are ignored. - """ - with get_connection() as connection, connection.cursor() as cursor: - if username is not None: - cursor.execute( - "SELECT id, username, created_at, updated_at, password_updated_at FROM users WHERE username = %s;", - (username,), - ) - elif user_id is not None: - cursor.execute( - "SELECT id, username, created_at, updated_at, password_updated_at FROM users WHERE id = %s", - (user_id,), - ) - - fetched = cursor.fetchone() - - if fetched is None: - raise DoesNotExist() - - return User( - id=fetched[0], - username=fetched[1], - created_at=fetched[2], - updated_at=fetched[3], - password_updated_at=fetched[4], - ) - - -def validate_password_for_user(user_id: int, raw_password: str) -> bool: - """ - Validates whether a password is correct for the given user. - - Always returns a boolean representing whether it was a match or not. - """ - try: - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute("SELECT password_hash FROM users WHERE id = %s", (user_id,)) - fetched = cursor.fetchone() - - current_secret_hash = fetched[0] - return password_hasher.verify(current_secret_hash, raw_password) - except Exception: # pylint: disable=broad-exception-caught - return False - - -def generate_token_for_user(user: User) -> str: - """ - Generates an identity token for a given user. - """ - token_data: auth_base.IdentityTokenData = { - "exp": ( - datetime.datetime.now() + datetime.timedelta(seconds=settings.JWT_LIFETIME) - ).timestamp(), - "user_id": user["id"], - "username": user["username"], - "token_id": str(uuid.uuid4()), - } - - return jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm="HS256") - - -def decode_token( - token: str, -) -> typing.Union[typing.NoReturn, auth_base.IdentityTokenData]: - """ - Decodes the given token. - - This may raise if the token is expired or invalid. - """ - token_data: auth_base.IdentityTokenData = jwt.decode( - token, settings.JWT_SECRET_KEY, algorithms=["HS256"] - ) - - return token_data diff --git a/backend/rotini_django/auth/views.py b/backend/rotini/auth/views.py similarity index 100% rename from backend/rotini_django/auth/views.py rename to backend/rotini/auth/views.py diff --git a/backend/rotini/__init__.py b/backend/rotini/base/__init__.py similarity index 100% rename from backend/rotini/__init__.py rename to backend/rotini/base/__init__.py diff --git a/backend/rotini_django/base/asgi.py b/backend/rotini/base/asgi.py similarity index 100% rename from backend/rotini_django/base/asgi.py rename to backend/rotini/base/asgi.py diff --git a/backend/rotini_django/base/env_settings/test.py b/backend/rotini/base/env_settings/test.py similarity index 100% rename from backend/rotini_django/base/env_settings/test.py rename to backend/rotini/base/env_settings/test.py diff --git a/backend/rotini_django/base/settings.py b/backend/rotini/base/settings.py similarity index 100% rename from backend/rotini_django/base/settings.py rename to backend/rotini/base/settings.py diff --git a/backend/rotini_django/base/urls.py b/backend/rotini/base/urls.py similarity index 100% rename from backend/rotini_django/base/urls.py rename to backend/rotini/base/urls.py diff --git a/backend/rotini_django/base/wsgi.py b/backend/rotini/base/wsgi.py similarity index 100% rename from backend/rotini_django/base/wsgi.py rename to backend/rotini/base/wsgi.py diff --git a/backend/rotini_django/conftest.py b/backend/rotini/conftest.py similarity index 100% rename from backend/rotini_django/conftest.py rename to backend/rotini/conftest.py diff --git a/backend/rotini/db.py b/backend/rotini/db.py deleted file mode 100644 index 58be6b70..00000000 --- a/backend/rotini/db.py +++ /dev/null @@ -1,16 +0,0 @@ -import psycopg2 - -from settings import settings - - -def get_connection(): - """ - Create a database connection. - """ - return psycopg2.connect( - user=settings.DATABASE_USERNAME, - password=settings.DATABASE_PASSWORD, - host=settings.DATABASE_HOST, - port=settings.DATABASE_PORT, - database=settings.DATABASE_NAME, - ) diff --git a/backend/rotini/envs/ci.py b/backend/rotini/envs/ci.py deleted file mode 100644 index f5e97a3b..00000000 --- a/backend/rotini/envs/ci.py +++ /dev/null @@ -1,5 +0,0 @@ -DATABASE_USERNAME = "postgres" -DATABASE_PASSWORD = "test" -DATABASE_HOST = "localhost" -DATABASE_PORT = 5431 -DATABASE_NAME = "postgres" diff --git a/backend/rotini/envs/local.py b/backend/rotini/envs/local.py deleted file mode 100644 index d6fe4e53..00000000 --- a/backend/rotini/envs/local.py +++ /dev/null @@ -1,5 +0,0 @@ -DATABASE_USERNAME = "postgres" -DATABASE_PASSWORD = "test" -DATABASE_HOST = "localhost" -DATABASE_PORT = 5432 -DATABASE_NAME = "postgres" diff --git a/backend/rotini/envs/migrate.py b/backend/rotini/envs/migrate.py deleted file mode 100644 index 708f430a..00000000 --- a/backend/rotini/envs/migrate.py +++ /dev/null @@ -1,3 +0,0 @@ -from envs.local import * - -DATABASE_HOST = "localhost" diff --git a/backend/rotini/envs/test.py b/backend/rotini/envs/test.py deleted file mode 100644 index d348b5cf..00000000 --- a/backend/rotini/envs/test.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - -DATABASE_USERNAME = "postgres" -DATABASE_PASSWORD = "test" -DATABASE_HOST = "localhost" -DATABASE_PORT = 5431 -DATABASE_NAME = "postgres" - -STORAGE_ROOT = os.getenv("ROTINI_STORAGE_ROOT") diff --git a/backend/rotini/exceptions.py b/backend/rotini/exceptions.py deleted file mode 100644 index 7e4079ff..00000000 --- a/backend/rotini/exceptions.py +++ /dev/null @@ -1,4 +0,0 @@ -class DoesNotExist(Exception): - """ - General purpose exception signalling a failure to find a database record. - """ diff --git a/backend/rotini_django/files/apps.py b/backend/rotini/files/apps.py similarity index 100% rename from backend/rotini_django/files/apps.py rename to backend/rotini/files/apps.py diff --git a/backend/rotini/files/base.py b/backend/rotini/files/base.py deleted file mode 100644 index ae8eed3a..00000000 --- a/backend/rotini/files/base.py +++ /dev/null @@ -1,13 +0,0 @@ -import typing_extensions as typing - - -class FileRecord(typing.TypedDict): - """ - Database record associated with a file tracked - by the system. - """ - - id: str - size: int - path: str - filename: str diff --git a/backend/rotini_django/files/migrations/0001_initial.py b/backend/rotini/files/migrations/0001_initial.py similarity index 100% rename from backend/rotini_django/files/migrations/0001_initial.py rename to backend/rotini/files/migrations/0001_initial.py diff --git a/backend/rotini_django/files/migrations/0002_file_owner.py b/backend/rotini/files/migrations/0002_file_owner.py similarity index 100% rename from backend/rotini_django/files/migrations/0002_file_owner.py rename to backend/rotini/files/migrations/0002_file_owner.py diff --git a/backend/rotini_django/auth/__init__.py b/backend/rotini/files/migrations/__init__.py similarity index 100% rename from backend/rotini_django/auth/__init__.py rename to backend/rotini/files/migrations/__init__.py diff --git a/backend/rotini_django/files/models.py b/backend/rotini/files/models.py similarity index 100% rename from backend/rotini_django/files/models.py rename to backend/rotini/files/models.py diff --git a/backend/rotini/files/routes.py b/backend/rotini/files/routes.py deleted file mode 100644 index 8c59bc38..00000000 --- a/backend/rotini/files/routes.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Files API. - -This API allows users to create and query for existing data about -files that live in the system. -""" - -import pathlib - -from fastapi import APIRouter, HTTPException, UploadFile, Request -from fastapi.responses import FileResponse - -import files.use_cases as files_use_cases -from settings import settings - -router = APIRouter(prefix="/files") - - -@router.get("/", status_code=200) -async def list_files(request: Request): - """ - Fetches all files owned by the logged-in user. - - 200 { [, ...] } - - If the user is logged in, file records that they - own are returned. - - 401 {} - - If the request is not authenticated, it fails. - """ - # FIXME: Temporarily fetching files belonging to the base user. - # to be resolved once users can log in. - current_user_id = ( - request.state.user["user_id"] if hasattr(request.state, "user") else 1 - ) - return files_use_cases.get_all_files_owned_by_user(current_user_id) - - -@router.post("/", status_code=201) -async def upload_file(request: Request, file: UploadFile) -> files_use_cases.FileRecord: - """ - Receives files uploaded by the user, saving them to disk and - recording their existence in the database. - - 201 { } - - The file was uploaded and registered successfully. - """ - - content = await file.read() - size = len(content) - dest_path = pathlib.Path(settings.STORAGE_ROOT, file.filename) - - with open(dest_path, "wb") as f: - f.write(content) - # FIXME: Temporarily fetching files belonging to the base user. - # to be resolved once users can log in. - created_record = files_use_cases.create_file_record( - str(dest_path), - size, - request.state.user["user_id"] if hasattr(request.state, "user") else 1, - ) - - return created_record - - -@router.get("/{file_id}/") -def get_file_details(file_id: str): - file = files_use_cases.get_file_record_by_id(file_id) - - if file is None: - raise HTTPException(status_code=404) - - return file - - -@router.get("/{file_id}/content/") -def get_file_content(file_id: str) -> FileResponse: - """ - Retrieves the file data associated with a given File ID. - - This returns the file for download as a streamed file. - - GET /files/{file_id}/content/ - - 200 { } - - The file data is returned as a stream if the file exists. - - 404 {} - - The file ID did not map to anything. - """ - file = files_use_cases.get_file_record_by_id(file_id) - - if file is None: - raise HTTPException(status_code=404) - - return FileResponse( - path=file["path"], - media_type="application/octet-stream", - filename=file["filename"], - ) - - -@router.delete("/{file_id}/") -def delete_file(file_id: str) -> files_use_cases.FileRecord: - """ - Deletes a file given its ID. - - This will delete the file in the database records as well - as on disk. The operation is not reversible. - - DELETE /files/{file_id}/ - - 200 { } - - The file exists and has been deleted from storage and - from the database. - - 404 {} - - The file ID did not map to anything. - - """ - try: - file = files_use_cases.delete_file_record_by_id(file_id) - except files_use_cases.DoesNotExist as exc: - raise HTTPException(status_code=404) from exc - - return file diff --git a/backend/rotini_django/files/serializers.py b/backend/rotini/files/serializers.py similarity index 100% rename from backend/rotini_django/files/serializers.py rename to backend/rotini/files/serializers.py diff --git a/backend/rotini_django/files/test_views.py b/backend/rotini/files/test_views.py similarity index 100% rename from backend/rotini_django/files/test_views.py rename to backend/rotini/files/test_views.py diff --git a/backend/rotini_django/files/urls.py b/backend/rotini/files/urls.py similarity index 100% rename from backend/rotini_django/files/urls.py rename to backend/rotini/files/urls.py diff --git a/backend/rotini/files/use_cases.py b/backend/rotini/files/use_cases.py deleted file mode 100644 index 15903a6e..00000000 --- a/backend/rotini/files/use_cases.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -File-related use cases. - -Use cases and data structures defined in this file -manipulate file records in the database or represent them -after they have been read. -""" -import pathlib - -import typing_extensions as typing - -from db import get_connection -from settings import settings - -from permissions.base import Permissions -from permissions.files import set_file_permission - -from exceptions import DoesNotExist - -from files.base import FileRecord - - -def create_file_record(path: str, size: int, owner_id: int) -> FileRecord: - """ - Creates a record representing an uploaded file in the database. - - The record itself does not ensure that the file exists on disk, but just - that it's tracked by the system. - """ - inserted_id = None - - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute( - "INSERT INTO files (path, size) VALUES (%s, %s) RETURNING id", (path, size) - ) - - inserted_id = cursor.fetchone()[0] - - set_file_permission(inserted_id, owner_id, list(Permissions)) - - filename = pathlib.Path(path).name - - return FileRecord(id=inserted_id, size=size, path=path, filename=filename) - - -def get_all_files_owned_by_user(user_id: int) -> typing.Tuple[FileRecord]: - """ - Gets all the file records owned by the user. - - A file is considered owned if the user has all permissions on a given file. There - can be more than one owner to a file, but all files must have an owner. - """ - rows = None - - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute( - """SELECT - f.* - from files f - join permissions_files pf - on f.id = pf.file_id - where - pf.user_id = %s - and pf.value = %s;""", - (user_id, sum(p.value for p in Permissions)), - ) - rows = cursor.fetchall() - - if rows is None: - raise RuntimeError("Failed to get files.") - - return ( - FileRecord( - id=row[0], path=row[1], size=row[2], filename=pathlib.Path(row[1]).name - ) - for row in rows - ) - - -def get_file_record_by_id(file_id: str) -> typing.Optional[FileRecord]: - """ - Fetches a single file by ID. - - If the ID doesn't correspond to a record, None is returned. - """ - - row = None - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute("SELECT * FROM files WHERE id=%s;", (file_id,)) - row = cursor.fetchone() - - if row is None: - return None - - return FileRecord( - id=row[0], path=row[1], size=row[2], filename=pathlib.Path(row[1]).name - ) - - -def delete_file_record_by_id(file_id: str) -> typing.Union[typing.NoReturn, FileRecord]: - """ - Deletes a single file by ID, including its presence in storage. - - If the ID doesn't correspond to a record, DoesNotExist is raised. - """ - - row = None - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute("DELETE FROM files WHERE id=%s RETURNING *;", (file_id,)) - row = cursor.fetchone() - - if row is None: - raise DoesNotExist() - - pathlib.Path(pathlib.Path(settings.STORAGE_ROOT, row[1])).unlink() - - return FileRecord( - id=row[0], path=row[1], size=row[2], filename=pathlib.Path(row[1]).name - ) diff --git a/backend/rotini_django/files/views.py b/backend/rotini/files/views.py similarity index 100% rename from backend/rotini_django/files/views.py rename to backend/rotini/files/views.py diff --git a/backend/rotini/main.py b/backend/rotini/main.py deleted file mode 100644 index a3d362ff..00000000 --- a/backend/rotini/main.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Rotini: a self-hosted cloud storage & productivity app. -""" - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -import auth.middleware as auth_middleware -import auth.routes as auth_routes - -import files.routes as files_routes - -app = FastAPI() - -origins = ["http://localhost:1234"] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) -app.add_middleware(auth_middleware.AuthenticationMiddleware) - -routers = [files_routes.router, auth_routes.router] - -for router in routers: - app.include_router(router) - - -@app.get("/", status_code=204) -def healthcheck(): - pass diff --git a/backend/rotini_django/manage.py b/backend/rotini/manage.py similarity index 100% rename from backend/rotini_django/manage.py rename to backend/rotini/manage.py diff --git a/backend/rotini/migrations/migrate.py b/backend/rotini/migrations/migrate.py deleted file mode 100644 index 79cf84e2..00000000 --- a/backend/rotini/migrations/migrate.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Migration handler. - -This module handles database migrations. - -Migrations are expected to be Python files of the format: - -``` -UID = - -PARENT = - -MESSAGE = - -UP_SQL = - -DOWN_SQL = -``` - -where UP_SQL is the change the migration represents and DOWN_SQL its inverse. - -Usage: - -python migrate.py [] - -Not including a migration name executes everything from the last executed -migration. -""" - -import collections -import pathlib -import datetime -import uuid -import typing -import importlib -import sys - -import psycopg2 - -from settings import settings - -VALID_COMMANDS = ["up", "down", "new"] - -DIRECTION_UP = 1 -DIRECTION_DOWN = -1 - -# UUID attached to a migration. -MigrationID = str - -# Filename (without ext.) of a migration. -MigrationModuleName = str - -MigrationItem = collections.namedtuple("MigrationItem", "id module") - - -def _get_connection(): - """ - Create a database connection. - """ - return psycopg2.connect( - user=settings.DATABASE_USERNAME, - password=settings.DATABASE_PASSWORD, - host=settings.DATABASE_HOST, - port=settings.DATABASE_PORT, - database=settings.DATABASE_NAME, - ) - - -def _ensure_migration_table(): - """ - Ensure that the migration tracking table exists. - """ - connection = _get_connection() - - maybe_create_sql = """ - CREATE TABLE IF NOT EXISTS migrations_lastapplied ( - migration_uid text NOT NULL - ); - """ - - with connection: - with connection.cursor() as cursor: - cursor.execute(maybe_create_sql) - - -def _get_migration_sequence() -> typing.List[MigrationItem]: - """ - Collects migration files and builds a historical - timeline. - - This will detect duplicates and breaks in the sequence - and raise if the history is not linear and complete. - """ - migrations_dir = pathlib.Path(__file__).parent - migrations: typing.Dict[MigrationID, MigrationModuleName] = {} - dependency_map: typing.Dict[MigrationID, MigrationID] = {} - - for file in migrations_dir.iterdir(): - if file.name.startswith("migration_") and file.suffix == ".py": - migration = importlib.import_module(file.stem) - migration_id = migration.UID - migration_parent = migration.PARENT - - if migration_id in migrations: - raise RuntimeError("Duplicate migrations.") - - if migration_parent in dependency_map: - raise RuntimeError("History must be linear.") - - migrations[migration_id] = str(file.stem) - dependency_map[migration_parent] = migration_id - - if not dependency_map: - print("No migrations yet!") - return [] - - root_id = dependency_map["None"] - history: typing.List[MigrationItem] = [MigrationItem(root_id, migrations[root_id])] - - while history: - next_id = dependency_map.get(history[-1].id) - - if next_id is None: - break - - history.append(MigrationItem(next_id, migrations[next_id])) - - return history - - -def migrate(direction: typing.Union[typing.Literal[1], typing.Literal[-1]]): - """ - Runs a migration (expected to be in the current directory - and labeled 'migration_