diff --git a/.env b/.env deleted file mode 100644 index 1d44286..0000000 --- a/.env +++ /dev/null @@ -1,45 +0,0 @@ -# Domain -# This would be set to the production domain with an env var on deployment -# used by Traefik to transmit traffic and aqcuire TLS certificates -DOMAIN=localhost -# To test the local Traefik config -# DOMAIN=localhost.tiangolo.com - -# Used by the backend to generate links in emails to the frontend -FRONTEND_HOST=http://localhost:5173 -# In staging and production, set this env var to the frontend host, e.g. -# FRONTEND_HOST=https://dashboard.example.com - -# Environment: local, staging, production -ENVIRONMENT=local - -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project - -# Backend -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis - -# Emails -SMTP_HOST= -SMTP_USER= -SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com -SMTP_TLS=True -SMTP_SSL=False -SMTP_PORT=587 - -# Postgres -POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis - -SENTRY_DSN= - -# Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a0b2af --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Template for the app stack's .env. The workspace root's scripts/setup.sh copies +# this file to .env on first run and then keeps the shared keys in sync with the +# root .env. Secrets stay as `changethis` here and are generated locally. + +# Domain Traefik routes on and acquires TLS certificates for. +DOMAIN=localhost + +# Used by the backend to build links in outgoing emails. +FRONTEND_HOST=http://localhost:5173 + +# local, staging, production +ENVIRONMENT=local + +PROJECT_NAME="Fluksio" +STACK_NAME=fluksio-app + +# Backend +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173" +SECRET_KEY=changethis +FIRST_SUPERUSER=admin@fluksio.com +FIRST_SUPERUSER_PASSWORD=changethis + +# Emails +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL=noreply@fluksio.com +SMTP_TLS=True +SMTP_SSL=False +SMTP_PORT=587 + +# Postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changethis + +SENTRY_DSN= + +# Docker registry images +DOCKER_IMAGE_BACKEND=fluksio-backend +DOCKER_IMAGE_FRONTEND=fluksio-frontend diff --git a/.gitignore b/.gitignore index 8032175..4fa5595 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -*.png .vscode/* !.vscode/extensions.json node_modules/ @@ -6,3 +5,13 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ + +# Generated locally by the workspace root's scripts/setup.sh; never committed. +.env +secrets/ + +__pycache__/ +*.py[cod] + +# Rendered pipeline graphs (matplotlib output from backend/app/flow). +pipeline.png diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index aaf085e..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "backend"] - path = backend - url = ssh://git@codeberg.org/fluksio/backend.git -[submodule "frontend"] - path = frontend - url = ssh://git@codeberg.org/fluksio/frontend.git \ No newline at end of file diff --git a/NOTEPAD.md b/NOTEPAD.md new file mode 100644 index 0000000..ad6a3c9 --- /dev/null +++ b/NOTEPAD.md @@ -0,0 +1,7 @@ +This file captures tasks which derive from roadmap tasks (unfinished, deferred), bugs encountered during usage and feature requests/improvements which are not fitting directly in the roadmap. +Always sort by priority and put tasks blocked by other tasks/features at the dedicated section. +When working on a task, check for other, similar tasks that could be resolved on the way. +Use following pattern to classify tasks: TYPE/SCOPE +Where TYPE could be BUG, FEAT, PERF, CHORE and SCOPE could be UX, UI, AI, SIM, CAD appended by MOBILE if only for mobile use case. +Don't write temporary reasons for deferring a task in the task description (only strategical reasons should be noted). +Deferring because out of scope is fine, but don't mention deferring than. \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..53947db --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,11 @@ +# Roadmap + +Implementation strategy and record of existing/planned features. Completed items are +terse checklists - technical detail lives in the architecture docs (`docs-private/architecture/`). Remaining tasks keep +enough scope to be actionable. + +Legend: `[x]` done · `[ ]` planned · sub-lists split done vs. remaining for partial items. + +Within each phase, remaining `[ ]` items are listed in rough priority order: correctness +and robustness of the existing modeling workflow (especially after edits/imports) precede +new feature breadth. \ No newline at end of file diff --git a/backend b/backend deleted file mode 160000 index d6ac71f..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d6ac71f1b1c85be4ed0dc0ee31bf77d497eefb94 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c0de4ab --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +# Python +__pycache__ +app.egg-info +*.pyc +.mypy_cache +.coverage +htmlcov +.venv diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..adc0f1a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +*.png +__pycache__ +app.egg-info +*.pyc +.mypy_cache +.coverage +htmlcov +.cache +.venv diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9f31dcd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.10 + +ENV PYTHONUNBUFFERED=1 + +# Install uv +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv +COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/ + +# Compile bytecode +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode +ENV UV_COMPILE_BYTECODE=1 + +# uv Cache +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching +ENV UV_LINK_MODE=copy + +WORKDIR /app/ + +# Place executables in the environment at the front of the path +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment +ENV PATH="/app/.venv/bin:$PATH" + +# Install dependencies +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-workspace --package app + +COPY ./backend/scripts /app/backend/scripts + +COPY ./backend/pyproject.toml ./backend/alembic.ini /app/backend/ + +COPY ./backend/app /app/backend/app + +# Sync the project +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --package app + +WORKDIR /app/backend/ + +CMD ["fastapi", "run", "--workers", "4", "app/main.py"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..24841c2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,71 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = app/alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# 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 alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# 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/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/alembic/README b/backend/app/alembic/README new file mode 100755 index 0000000..2500aa1 --- /dev/null +++ b/backend/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py new file mode 100755 index 0000000..7f29c04 --- /dev/null +++ b/backend/app/alembic/env.py @@ -0,0 +1,84 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# 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. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None + +from app.models import SQLModel # noqa +from app.core.config import settings # noqa + +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 get_url(): + return str(settings.SQLALCHEMY_DATABASE_URI) + + +def run_migrations_offline(): + """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 = get_url() + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/app/alembic/script.py.mako b/backend/app/alembic/script.py.mako new file mode 100755 index 0000000..217a9a8 --- /dev/null +++ b/backend/app/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/app/alembic/versions/.keep b/backend/app/alembic/versions/.keep new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py new file mode 100644 index 0000000..10e47a1 --- /dev/null +++ b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py @@ -0,0 +1,37 @@ +"""Add cascade delete relationships + +Revision ID: 1a31ce608336 +Revises: d98dd8ec85a3 +Create Date: 2024-07-31 22:24:34.447891 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '1a31ce608336' +down_revision = 'd98dd8ec85a3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('item', 'owner_id', + existing_type=sa.UUID(), + nullable=False) + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'item', type_='foreignkey') + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) + op.alter_column('item', 'owner_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py new file mode 100755 index 0000000..78a4177 --- /dev/null +++ b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py @@ -0,0 +1,69 @@ +"""Add max length for string(varchar) fields in User and Items models + +Revision ID: 9c0a54914c78 +Revises: e2412789c190 +Create Date: 2024-06-17 14:42:44.639457 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '9c0a54914c78' +down_revision = 'e2412789c190' +branch_labels = None +depends_on = None + + +def upgrade(): + # Adjust the length of the email field in the User table + op.alter_column('user', 'email', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=False) + + # Adjust the length of the full_name field in the User table + op.alter_column('user', 'full_name', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=True) + + # Adjust the length of the title field in the Item table + op.alter_column('item', 'title', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=False) + + # Adjust the length of the description field in the Item table + op.alter_column('item', 'description', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=True) + + +def downgrade(): + # Revert the length of the email field in the User table + op.alter_column('user', 'email', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=False) + + # Revert the length of the full_name field in the User table + op.alter_column('user', 'full_name', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=True) + + # Revert the length of the title field in the Item table + op.alter_column('item', 'title', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=False) + + # Revert the length of the description field in the Item table + op.alter_column('item', 'description', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=True) diff --git a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py new file mode 100755 index 0000000..37af1fa --- /dev/null +++ b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py @@ -0,0 +1,90 @@ +"""Edit replace id integers in all models to use UUID instead + +Revision ID: d98dd8ec85a3 +Revises: 9c0a54914c78 +Create Date: 2024-07-19 04:08:04.000976 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = 'd98dd8ec85a3' +down_revision = '9c0a54914c78' +branch_labels = None +depends_on = None + + +def upgrade(): + # Ensure uuid-ossp extension is available + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + + # Create a new UUID column with a default UUID value + op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) + op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) + op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True)) + + # Populate the new columns with UUIDs + op.execute('UPDATE "user" SET new_id = uuid_generate_v4()') + op.execute('UPDATE item SET new_id = uuid_generate_v4()') + op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)') + + # Set the new_id as not nullable + op.alter_column('user', 'new_id', nullable=False) + op.alter_column('item', 'new_id', nullable=False) + + # Drop old columns and rename new columns + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.drop_column('item', 'owner_id') + op.alter_column('item', 'new_owner_id', new_column_name='owner_id') + + op.drop_column('user', 'id') + op.alter_column('user', 'new_id', new_column_name='id') + + op.drop_column('item', 'id') + op.alter_column('item', 'new_id', new_column_name='id') + + # Create primary key constraint + op.create_primary_key('user_pkey', 'user', ['id']) + op.create_primary_key('item_pkey', 'item', ['id']) + + # Recreate foreign key constraint + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) + +def downgrade(): + # Reverse the upgrade process + op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True)) + op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True)) + op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True)) + + # Populate the old columns with default values + # Generate sequences for the integer IDs if not exist + op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id') + op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id') + + op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)') + op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)') + + op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')') + op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)') + + # Drop new columns and rename old columns back + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.drop_column('item', 'owner_id') + op.alter_column('item', 'old_owner_id', new_column_name='owner_id') + + op.drop_column('user', 'id') + op.alter_column('user', 'old_id', new_column_name='id') + + op.drop_column('item', 'id') + op.alter_column('item', 'old_id', new_column_name='id') + + # Create primary key constraint + op.create_primary_key('user_pkey', 'user', ['id']) + op.create_primary_key('item_pkey', 'item', ['id']) + + # Recreate foreign key constraint + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) diff --git a/backend/app/alembic/versions/e2412789c190_initialize_models.py b/backend/app/alembic/versions/e2412789c190_initialize_models.py new file mode 100644 index 0000000..7529ea9 --- /dev/null +++ b/backend/app/alembic/versions/e2412789c190_initialize_models.py @@ -0,0 +1,54 @@ +"""Initialize models + +Revision ID: e2412789c190 +Revises: +Create Date: 2023-11-24 22:55:43.195942 + +""" +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e2412789c190" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user", + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) + op.create_table( + "item", + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["owner_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("item") + op.drop_index(op.f("ix_user_email"), table_name="user") + op.drop_table("user") + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py b/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py new file mode 100644 index 0000000..3e15754 --- /dev/null +++ b/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py @@ -0,0 +1,31 @@ +"""Add created_at to User and Item + +Revision ID: fe56fa70289e +Revises: 1a31ce608336 +Create Date: 2026-01-23 15:50:37.171462 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'fe56fa70289e' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('item', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'created_at') + op.drop_column('item', 'created_at') + # ### end Alembic commands ### diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c2b83c8 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,57 @@ +from collections.abc import Generator +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from pydantic import ValidationError +from sqlmodel import Session + +from app.core import security +from app.core.config import settings +from app.core.db import engine +from app.models import TokenPayload, User + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token" +) + + +def get_db() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session + + +SessionDep = Annotated[Session, Depends(get_db)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] + + +def get_current_user(session: SessionDep, token: TokenDep) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def get_current_active_superuser(current_user: CurrentUser) -> User: + if not current_user.is_superuser: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user diff --git a/backend/app/api/main.py b/backend/app/api/main.py new file mode 100644 index 0000000..eac18c8 --- /dev/null +++ b/backend/app/api/main.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from app.api.routes import items, login, private, users, utils +from app.core.config import settings + +api_router = APIRouter() +api_router.include_router(login.router) +api_router.include_router(users.router) +api_router.include_router(utils.router) +api_router.include_router(items.router) + + +if settings.ENVIRONMENT == "local": + api_router.include_router(private.router) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py new file mode 100644 index 0000000..2b2ce57 --- /dev/null +++ b/backend/app/api/routes/items.py @@ -0,0 +1,112 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("/", response_model=ItemsPublic) +def read_items( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve items. + """ + + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Item) + count = session.exec(count_statement).one() + statement = ( + select(Item).order_by(Item.created_at.desc()).offset(skip).limit(limit) + ) + items = session.exec(statement).all() + else: + count_statement = ( + select(func.count()) + .select_from(Item) + .where(Item.owner_id == current_user.id) + ) + count = session.exec(count_statement).one() + statement = ( + select(Item) + .where(Item.owner_id == current_user.id) + .order_by(Item.created_at.desc()) + .offset(skip) + .limit(limit) + ) + items = session.exec(statement).all() + + return ItemsPublic(data=items, count=count) + + +@router.get("/{id}", response_model=ItemPublic) +def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get item by ID. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return item + + +@router.post("/", response_model=ItemPublic) +def create_item( + *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate +) -> Any: + """ + Create new item. + """ + item = Item.model_validate(item_in, update={"owner_id": current_user.id}) + session.add(item) + session.commit() + session.refresh(item) + return item + + +@router.put("/{id}", response_model=ItemPublic) +def update_item( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + item_in: ItemUpdate, +) -> Any: + """ + Update an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + update_dict = item_in.model_dump(exclude_unset=True) + item.sqlmodel_update(update_dict) + session.add(item) + session.commit() + session.refresh(item) + return item + + +@router.delete("/{id}") +def delete_item( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(item) + session.commit() + return Message(message="Item deleted successfully") diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py new file mode 100644 index 0000000..58441e3 --- /dev/null +++ b/backend/app/api/routes/login.py @@ -0,0 +1,123 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from app import crud +from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser +from app.core import security +from app.core.config import settings +from app.models import Message, NewPassword, Token, UserPublic, UserUpdate +from app.utils import ( + generate_password_reset_token, + generate_reset_password_email, + send_email, + verify_password_reset_token, +) + +router = APIRouter(tags=["login"]) + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.authenticate( + session=session, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) + + +@router.post("/login/test-token", response_model=UserPublic) +def test_token(current_user: CurrentUser) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/password-recovery/{email}") +def recover_password(email: str, session: SessionDep) -> Message: + """ + Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + # Always return the same response to prevent email enumeration attacks + # Only send email if user actually exists + if user: + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + send_email( + email_to=user.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message( + message="If that email is registered, we sent a password recovery link" + ) + + +@router.post("/reset-password/") +def reset_password(session: SessionDep, body: NewPassword) -> Message: + """ + Reset password + """ + email = verify_password_reset_token(token=body.token) + if not email: + raise HTTPException(status_code=400, detail="Invalid token") + user = crud.get_user_by_email(session=session, email=email) + if not user: + # Don't reveal that the user doesn't exist - use same error as invalid token + raise HTTPException(status_code=400, detail="Invalid token") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + user_in_update = UserUpdate(password=body.new_password) + crud.update_user( + session=session, + db_user=user, + user_in=user_in_update, + ) + return Message(message="Password updated successfully") + + +@router.post( + "/password-recovery-html-content/{email}", + dependencies=[Depends(get_current_active_superuser)], + response_class=HTMLResponse, +) +def recover_password_html_content(email: str, session: SessionDep) -> Any: + """ + HTML Content for Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this username does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + + return HTMLResponse( + content=email_data.html_content, headers={"subject:": email_data.subject} + ) diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py new file mode 100644 index 0000000..9f33ef1 --- /dev/null +++ b/backend/app/api/routes/private.py @@ -0,0 +1,38 @@ +from typing import Any + +from fastapi import APIRouter +from pydantic import BaseModel + +from app.api.deps import SessionDep +from app.core.security import get_password_hash +from app.models import ( + User, + UserPublic, +) + +router = APIRouter(tags=["private"], prefix="/private") + + +class PrivateUserCreate(BaseModel): + email: str + password: str + full_name: str + is_verified: bool = False + + +@router.post("/users/", response_model=UserPublic) +def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: + """ + Create a new user. + """ + + user = User( + email=user_in.email, + full_name=user_in.full_name, + hashed_password=get_password_hash(user_in.password), + ) + + session.add(user) + session.commit() + + return user diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py new file mode 100644 index 0000000..6172794 --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,229 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import col, delete, func, select + +from app import crud +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, +) +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.models import ( + Item, + Message, + UpdatePassword, + User, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) +from app.utils import generate_new_account_email, send_email + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=UsersPublic, +) +def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """ + Retrieve users. + """ + + count_statement = select(func.count()).select_from(User) + count = session.exec(count_statement).one() + + statement = select(User).order_by(User.created_at.desc()).offset(skip).limit(limit) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) + + +@router.post( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic +) +def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: + """ + Create new user. + """ + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + + user = crud.create_user(session=session, user_create=user_in) + if settings.emails_enabled and user_in.email: + email_data = generate_new_account_email( + email_to=user_in.email, username=user_in.email, password=user_in.password + ) + send_email( + email_to=user_in.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return user + + +@router.patch("/me", response_model=UserPublic) +def update_user_me( + *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser +) -> Any: + """ + Update own user. + """ + + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != current_user.id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + user_data = user_in.model_dump(exclude_unset=True) + current_user.sqlmodel_update(user_data) + session.add(current_user) + session.commit() + session.refresh(current_user) + return current_user + + +@router.patch("/me/password", response_model=Message) +def update_password_me( + *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser +) -> Any: + """ + Update own password. + """ + verified, _ = verify_password(body.current_password, current_user.hashed_password) + if not verified: + raise HTTPException(status_code=400, detail="Incorrect password") + if body.current_password == body.new_password: + raise HTTPException( + status_code=400, detail="New password cannot be the same as the current one" + ) + hashed_password = get_password_hash(body.new_password) + current_user.hashed_password = hashed_password + session.add(current_user) + session.commit() + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserPublic) +def read_user_me(current_user: CurrentUser) -> Any: + """ + Get current user. + """ + return current_user + + +@router.delete("/me", response_model=Message) +def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Delete own user. + """ + if current_user.is_superuser: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + session.delete(current_user) + session.commit() + return Message(message="User deleted successfully") + + +@router.post("/signup", response_model=UserPublic) +def register_user(session: SessionDep, user_in: UserRegister) -> Any: + """ + Create new user without the need to be logged in. + """ + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system", + ) + user_create = UserCreate.model_validate(user_in) + user = crud.create_user(session=session, user_create=user_create) + return user + + +@router.get("/{user_id}", response_model=UserPublic) +def read_user_by_id( + user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser +) -> Any: + """ + Get a specific user by id. + """ + user = session.get(User, user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="The user doesn't have enough privileges", + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def update_user( + *, + session: SessionDep, + user_id: uuid.UUID, + user_in: UserUpdate, +) -> Any: + """ + Update a user. + """ + + db_user = session.get(User, user_id) + if not db_user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != user_id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + + db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) + return db_user + + +@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_user( + session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID +) -> Message: + """ + Delete a user. + """ + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user == current_user: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + statement = delete(Item).where(col(Item.owner_id) == user_id) + session.exec(statement) # type: ignore + session.delete(user) + session.commit() + return Message(message="User deleted successfully") diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py new file mode 100644 index 0000000..fc09341 --- /dev/null +++ b/backend/app/api/routes/utils.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends +from pydantic.networks import EmailStr + +from app.api.deps import get_current_active_superuser +from app.models import Message +from app.utils import generate_test_email, send_email + +router = APIRouter(prefix="/utils", tags=["utils"]) + + +@router.post( + "/test-email/", + dependencies=[Depends(get_current_active_superuser)], + status_code=201, +) +def test_email(email_to: EmailStr) -> Message: + """ + Test emails. + """ + email_data = generate_test_email(email_to=email_to) + send_email( + email_to=email_to, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message(message="Test email sent") + + +@router.get("/health-check/") +async def health_check() -> bool: + return True diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py new file mode 100644 index 0000000..c2f8e29 --- /dev/null +++ b/backend/app/backend_pre_start.py @@ -0,0 +1,39 @@ +import logging + +from sqlalchemy import Engine +from sqlmodel import Session, select +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +from app.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init(db_engine: Engine) -> None: + try: + with Session(db_engine) as session: + # Try to create session to check if DB is awake + session.exec(select(1)) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init(engine) + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..650b9f7 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,119 @@ +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import ( + AnyUrl, + BeforeValidator, + EmailStr, + HttpUrl, + PostgresDsn, + computed_field, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",") if i.strip()] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + # Use top level .env file (one level above ./backend/) + env_file="../.env", + env_ignore_empty=True, + extra="ignore", + ) + API_V1_STR: str = "/api/v1" + SECRET_KEY: str = secrets.token_urlsafe(32) + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + FRONTEND_HOST: str = "http://localhost:5173" + ENVIRONMENT: Literal["local", "staging", "production"] = "local" + + BACKEND_CORS_ORIGINS: Annotated[ + list[AnyUrl] | str, BeforeValidator(parse_cors) + ] = [] + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.FRONTEND_HOST + ] + + PROJECT_NAME: str + SENTRY_DSN: HttpUrl | None = None + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str = "" + POSTGRES_DB: str = "" + + @computed_field # type: ignore[prop-decorator] + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + + SMTP_TLS: bool = True + SMTP_SSL: bool = False + SMTP_PORT: int = 587 + SMTP_HOST: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + EMAILS_FROM_EMAIL: EmailStr | None = None + EMAILS_FROM_NAME: str | None = None + + @model_validator(mode="after") + def _set_default_emails_from(self) -> Self: + if not self.EMAILS_FROM_NAME: + self.EMAILS_FROM_NAME = self.PROJECT_NAME + return self + + EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + + @computed_field # type: ignore[prop-decorator] + @property + def emails_enabled(self) -> bool: + return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) + + EMAIL_TEST_USER: EmailStr = "test@example.com" + FIRST_SUPERUSER: EmailStr + FIRST_SUPERUSER_PASSWORD: str + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "local": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) + self._check_default_secret( + "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD + ) + + return self + + +settings = Settings() # type: ignore diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..ba991fb --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,33 @@ +from sqlmodel import Session, create_engine, select + +from app import crud +from app.core.config import settings +from app.models import User, UserCreate + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +# make sure all SQLModel models are imported (app.models) before initializing DB +# otherwise, SQLModel might fail to initialize relationships properly +# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 + + +def init_db(session: Session) -> None: + # Tables should be created with Alembic migrations + # But if you don't want to use migrations, create + # the tables un-commenting the next lines + # from sqlmodel import SQLModel + + # This works because the models are already imported and registered from app.models + # SQLModel.metadata.create_all(engine) + + user = session.exec( + select(User).where(User.email == settings.FIRST_SUPERUSER) + ).first() + if not user: + user_in = UserCreate( + email=settings.FIRST_SUPERUSER, + password=settings.FIRST_SUPERUSER_PASSWORD, + is_superuser=True, + ) + user = crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1e49ebc --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher + +from app.core.config import settings + +password_hash = PasswordHash( + ( + Argon2Hasher(), + BcryptHasher(), + ) +) + + +ALGORITHM = "HS256" + + +def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: + expire = datetime.now(timezone.utc) + expires_delta + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password( + plain_password: str, hashed_password: str +) -> tuple[bool, str | None]: + return password_hash.verify_and_update(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return password_hash.hash(password) diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..a8ceba6 --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,68 @@ +import uuid +from typing import Any + +from sqlmodel import Session, select + +from app.core.security import get_password_hash, verify_password +from app.models import Item, ItemCreate, User, UserCreate, UserUpdate + + +def create_user(*, session: Session, user_create: UserCreate) -> User: + db_obj = User.model_validate( + user_create, update={"hashed_password": get_password_hash(user_create.password)} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: + user_data = user_in.model_dump(exclude_unset=True) + extra_data = {} + if "password" in user_data: + password = user_data["password"] + hashed_password = get_password_hash(password) + extra_data["hashed_password"] = hashed_password + db_user.sqlmodel_update(user_data, update=extra_data) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +def get_user_by_email(*, session: Session, email: str) -> User | None: + statement = select(User).where(User.email == email) + session_user = session.exec(statement).first() + return session_user + + +# Dummy hash to use for timing attack prevention when user is not found +# This is an Argon2 hash of a random password, used to ensure constant-time comparison +DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" + + +def authenticate(*, session: Session, email: str, password: str) -> User | None: + db_user = get_user_by_email(session=session, email=email) + if not db_user: + # Prevent timing attacks by running password verification even when user doesn't exist + # This ensures the response time is similar whether or not the email exists + verify_password(password, DUMMY_HASH) + return None + verified, updated_password_hash = verify_password(password, db_user.hashed_password) + if not verified: + return None + if updated_password_hash: + db_user.hashed_password = updated_password_hash + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item diff --git a/backend/app/email-templates/build/new_account.html b/backend/app/email-templates/build/new_account.html new file mode 100644 index 0000000..3445050 --- /dev/null +++ b/backend/app/email-templates/build/new_account.html @@ -0,0 +1,25 @@ +
|
|
|
Rows per page
+ ++ Something went wrong. Please try again. +
+ + + ++ The page you are looking for was not found. +
+{fullName}
+{email}
++ Permanently delete your account and all associated data. +
++ {body} +
+ ) +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..8916905 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps+ Manage user accounts and permissions +
++ Welcome back, nice to see you again!!! +
+Add a new item to get started
+Create and manage your items
++ Manage your account settings and preferences +
+