Private
Public Access
Phase 1: auth, room CRUD, WebSocket chat, PWA frontend
Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat) and a React + Vite PWA frontend (login, room list, chat view). Backend tests pass against a local Postgres DB. See README.md and backend/README.md for setup, and ARCHITECTURE.md for the full phased design. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
|
||||
SESSION_SECRET=change-me-to-a-long-random-string
|
||||
SESSION_HTTPS_ONLY=false
|
||||
@@ -0,0 +1,107 @@
|
||||
# KeepItTalking backend (Phase 1)
|
||||
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, open-room CRUD,
|
||||
and a single-instance WebSocket chat endpoint. See `../ARCHITECTURE.md` for the
|
||||
full system design and the phased build plan.
|
||||
|
||||
This is an **invite-only site**: there is no public registration endpoint.
|
||||
Accounts are created by an operator on the app server — see step 4 below.
|
||||
|
||||
## Local dev setup
|
||||
|
||||
### 1. Postgres
|
||||
|
||||
Any local Postgres 14+ works. The quickest option is a container:
|
||||
|
||||
```bash
|
||||
docker run -d --name chatapp-postgres \
|
||||
-e POSTGRES_USER=chatapp -e POSTGRES_PASSWORD=chatapp -e POSTGRES_DB=chatapp \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
```
|
||||
|
||||
Then create the test database (used by the test suite, kept separate from dev data):
|
||||
|
||||
```bash
|
||||
docker exec chatapp-postgres psql -U chatapp -d chatapp -c "CREATE DATABASE chatapp_test;"
|
||||
```
|
||||
|
||||
(Docker here is purely a local-dev convenience for standing up Postgres quickly —
|
||||
the actual deployment target has no containers at all, see `ARCHITECTURE.md` §9.)
|
||||
|
||||
### 2. Python environment
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e ".[dev]"
|
||||
cp .env.example .env
|
||||
# edit .env: set SESSION_SECRET to a long random string, e.g.
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
### 3. Migrations
|
||||
|
||||
```bash
|
||||
.venv/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Create a user
|
||||
|
||||
There's no public sign-up. Create accounts directly with the CLI (add
|
||||
`--admin` to grant `is_site_admin`, useful ahead of the phase-6 admin portal):
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
|
||||
```
|
||||
|
||||
### 5. Run the dev server
|
||||
|
||||
```bash
|
||||
.venv/bin/uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
API docs: http://localhost:8000/docs. WebSocket chat endpoint: `ws://localhost:8000/ws/chat`.
|
||||
|
||||
### 6. Run tests
|
||||
|
||||
Tests run against a real Postgres database (`chatapp_test` by default — native
|
||||
`ENUM`/`UUID` types aren't faithfully reproduced by SQLite), with each test
|
||||
wrapped in a transaction that's rolled back afterward:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test .venv/bin/pytest
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/
|
||||
main.py create_app(), session middleware, router/WS mounting
|
||||
config.py environment-driven settings (pydantic-settings)
|
||||
database.py async engine/session, get_db() dependency
|
||||
dependencies.py get_current_user, require_room_member
|
||||
security.py argon2 password hashing
|
||||
cli.py `python -m app.cli create-user` (account provisioning)
|
||||
models/ SQLAlchemy models (users, rooms, room_memberships, messages)
|
||||
schemas/ Pydantic request/response models
|
||||
routers/ auth, rooms, health
|
||||
services/ business logic called by routers
|
||||
ws/ WebSocket connection manager + /ws/chat handler
|
||||
alembic/ migrations
|
||||
tests/ pytest + httpx/TestClient tests
|
||||
```
|
||||
|
||||
## Notes / scope decisions
|
||||
|
||||
- Invite-only: no `POST /api/auth/register`. Accounts are provisioned with
|
||||
`python -m app.cli create-user` (see step 4 above). A more self-service
|
||||
invite flow (per-user tokens, or an admin-portal "generate invite" button)
|
||||
is a natural phase-2/6 follow-up, not built now.
|
||||
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
|
||||
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
|
||||
through a WebSocket handshake). This means there's currently no way to force-
|
||||
revoke a session server-side; that needs a real session table later.
|
||||
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
||||
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
||||
- `rooms.is_private` exists in the schema but the API never sets it `True` yet;
|
||||
private rooms/invites are phase 2 (tracked as a Gitea issue).
|
||||
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# 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
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be 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 <script_location>/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 "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
|
||||
|
||||
|
||||
[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 module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from app.models import Base
|
||||
|
||||
# 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)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Allow the DB URL to come from the environment (matches app/config.py),
|
||||
# falling back to alembic.ini's sqlalchemy.url for local dev convenience.
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${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
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[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:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: c7981d17890c
|
||||
Revises:
|
||||
Create Date: 2026-08-13 19:40:11.425029
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c7981d17890c'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('username', sa.String(length=50), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('is_bot', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_site_admin', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('rooms',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_private', sa.Boolean(), nullable=False),
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_rooms_name'), 'rooms', ['name'], unique=True)
|
||||
op.create_table('messages',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('room_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('edited_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_messages_created_at'), 'messages', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_messages_room_id'), 'messages', ['room_id'], unique=False)
|
||||
op.create_table('room_memberships',
|
||||
sa.Column('room_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('role', sa.Enum('owner', 'admin', 'member', name='room_role'), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('room_id', 'user_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('room_memberships')
|
||||
op.drop_index(op.f('ix_messages_room_id'), table_name='messages')
|
||||
op.drop_index(op.f('ix_messages_created_at'), table_name='messages')
|
||||
op.drop_table('messages')
|
||||
op.drop_index(op.f('ix_rooms_name'), table_name='rooms')
|
||||
op.drop_table('rooms')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Command-line user management.
|
||||
|
||||
Public self-registration is disabled (invite-only site), so accounts are
|
||||
created by an operator running this script directly on the app server.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import DuplicateUserError, register_user
|
||||
|
||||
|
||||
async def _create_user(username: str, email: str, password: str, is_admin: bool) -> None:
|
||||
try:
|
||||
data = UserCreate(username=username, email=email, password=password)
|
||||
except ValidationError as exc:
|
||||
raise SystemExit(str(exc))
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
user = await register_user(db, data)
|
||||
except DuplicateUserError:
|
||||
raise SystemExit(f"Username or email already taken: {username} / {email}")
|
||||
|
||||
if is_admin:
|
||||
user.is_site_admin = True
|
||||
await db.commit()
|
||||
|
||||
print(f"Created user {username!r} (id={user.id}, admin={is_admin})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.cli")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
create_user = subparsers.add_parser("create-user", help="Create a new user account")
|
||||
create_user.add_argument("username")
|
||||
create_user.add_argument("email")
|
||||
create_user.add_argument("password")
|
||||
create_user.add_argument("--admin", action="store_true", help="Grant is_site_admin")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "create-user":
|
||||
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str
|
||||
session_secret: str
|
||||
session_https_only: bool = True
|
||||
session_max_age_seconds: int = 60 * 60 * 24 * 14
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,13 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, User
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request, db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user = await db.get(User, uuid.UUID(user_id))
|
||||
if user is None:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def require_room_member(
|
||||
room_id: uuid.UUID, user: User, db: AsyncSession
|
||||
) -> RoomMembership:
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user.id
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if membership is None:
|
||||
raise HTTPException(status_code=403, detail="Not a member of this room")
|
||||
return membership
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, health, rooms
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="KeepItTalking")
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.session_secret,
|
||||
same_site="lax",
|
||||
https_only=settings.session_https_only,
|
||||
max_age=settings.session_max_age_seconds,
|
||||
)
|
||||
|
||||
app.state.connection_manager = ConnectionManager()
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,7 @@
|
||||
from app.models.base import Base
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.room import Room
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["Base", "User", "Room", "RoomMembership", "RoomRole", "Message"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class RoomRole(str, enum.Enum):
|
||||
owner = "owner"
|
||||
admin = "admin"
|
||||
member = "member"
|
||||
|
||||
|
||||
class RoomMembership(Base):
|
||||
__tablename__ = "room_memberships"
|
||||
__table_args__ = (PrimaryKeyConstraint("room_id", "user_id"),)
|
||||
|
||||
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"))
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||
role: Mapped[RoomRole] = mapped_column(
|
||||
Enum(RoomRole, name="room_role"), default=RoomRole.member, nullable=False
|
||||
)
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room", back_populates="memberships")
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Room(Base):
|
||||
__tablename__ = "rooms"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
owner = relationship("User")
|
||||
memberships = relationship(
|
||||
"RoomMembership", back_populates="room", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.auth_service import InvalidCredentialsError, authenticate_user
|
||||
|
||||
# No POST /register here: this is an invite-only site. Accounts are created
|
||||
# by an operator via `python -m app.cli create-user` (see app/cli.py), not
|
||||
# through a public endpoint.
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserRead)
|
||||
async def login(
|
||||
request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
try:
|
||||
user = await authenticate_user(
|
||||
db, data.username_or_email, data.password
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
raise HTTPException(status_code=401, detail="Invalid username/email or password")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request) -> Response:
|
||||
request.session.clear()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_room_member
|
||||
from app.models import User
|
||||
from app.schemas.message import MessageRead
|
||||
from app.schemas.room import RoomCreate, RoomListItem, RoomRead
|
||||
from app.services.message_service import list_recent_messages
|
||||
from app.services.room_service import (
|
||||
DuplicateRoomError,
|
||||
RoomIsPrivateError,
|
||||
RoomNotFoundError,
|
||||
create_room,
|
||||
get_room,
|
||||
join_room,
|
||||
list_open_rooms,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||
|
||||
|
||||
@router.post("", response_model=RoomRead, status_code=201)
|
||||
async def create_room_endpoint(
|
||||
data: RoomCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return await create_room(db, current_user.id, data)
|
||||
except DuplicateRoomError:
|
||||
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoomListItem])
|
||||
async def list_rooms_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rooms = await list_open_rooms(db, current_user.id)
|
||||
return [
|
||||
RoomListItem(
|
||||
id=room.id,
|
||||
name=room.name,
|
||||
description=room.description,
|
||||
is_private=room.is_private,
|
||||
owner_id=room.owner_id,
|
||||
created_at=room.created_at,
|
||||
is_member=is_member,
|
||||
)
|
||||
for room, is_member in rooms
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{room_id}/join", response_model=RoomRead)
|
||||
async def join_room_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await join_room(db, room_id, current_user.id)
|
||||
return await get_room(db, room_id)
|
||||
except RoomNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except RoomIsPrivateError:
|
||||
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
|
||||
|
||||
|
||||
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||
async def get_room_messages_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
return await list_recent_messages(db, room_id, limit)
|
||||
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username_or_email: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
@@ -0,0 +1,14 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
room_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
content: str
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RoomCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class RoomRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
is_private: bool
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RoomListItem(RoomRead):
|
||||
is_member: bool
|
||||
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=200)
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
email: EmailStr
|
||||
is_bot: bool
|
||||
is_site_admin: bool
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,15 @@
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
from app.security import hash_password, verify_password
|
||||
|
||||
|
||||
class DuplicateUserError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def register_user(db: AsyncSession, data: UserCreate) -> User:
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateUserError() from exc
|
||||
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, username_or_email: str, password: str
|
||||
) -> User:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
or_(User.username == username_or_email, User.email == username_or_email)
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError()
|
||||
return user
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message
|
||||
|
||||
|
||||
async def create_message(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = Message(room_id=room_id, user_id=user_id, content=content)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.room_id == room_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
messages.reverse()
|
||||
return messages
|
||||
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Room, RoomMembership, RoomRole
|
||||
from app.schemas.room import RoomCreate
|
||||
|
||||
|
||||
class DuplicateRoomError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RoomNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RoomIsPrivateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||
room = Room(name=data.name, description=data.description, owner_id=owner_id)
|
||||
db.add(room)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateRoomError() from exc
|
||||
|
||||
db.add(RoomMembership(room_id=room.id, user_id=owner_id, role=RoomRole.owner))
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
|
||||
|
||||
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
|
||||
result = await db.execute(
|
||||
select(Room)
|
||||
.where(Room.is_private.is_(False))
|
||||
.options(selectinload(Room.memberships))
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
rooms = result.scalars().all()
|
||||
return [
|
||||
(room, any(m.user_id == user_id for m in room.memberships)) for room in rooms
|
||||
]
|
||||
|
||||
|
||||
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
raise RoomNotFoundError()
|
||||
return room
|
||||
|
||||
|
||||
async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
|
||||
room = await get_room(db, room_id)
|
||||
if room.is_private:
|
||||
raise RoomIsPrivateError()
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if membership is not None:
|
||||
return membership
|
||||
|
||||
membership = RoomMembership(room_id=room_id, user_id=user_id, role=RoomRole.member)
|
||||
db.add(membership)
|
||||
await db.commit()
|
||||
await db.refresh(membership)
|
||||
return membership
|
||||
@@ -0,0 +1,112 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, User
|
||||
from app.services.message_service import create_message
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
WS_UNAUTHENTICATED = 4401
|
||||
|
||||
|
||||
class ClientEnvelope(BaseModel):
|
||||
type: str
|
||||
room_id: uuid.UUID | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
if user is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
manager = websocket.app.state.connection_manager
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
try:
|
||||
envelope = ClientEnvelope.model_validate(raw)
|
||||
except ValidationError:
|
||||
await websocket.send_json({"type": "error", "detail": "Malformed message"})
|
||||
continue
|
||||
|
||||
if envelope.type == "join":
|
||||
if envelope.room_id is None:
|
||||
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
||||
continue
|
||||
if not await _is_room_member(db, envelope.room_id, user.id):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
manager.join(envelope.room_id, websocket)
|
||||
joined_rooms.add(envelope.room_id)
|
||||
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
||||
|
||||
elif envelope.type == "leave":
|
||||
if envelope.room_id is None:
|
||||
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
||||
continue
|
||||
manager.leave(envelope.room_id, websocket)
|
||||
joined_rooms.discard(envelope.room_id)
|
||||
|
||||
elif envelope.type == "message":
|
||||
if envelope.room_id is None or not envelope.content:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id and content required"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
message = await create_message(db, envelope.room_id, user.id, envelope.content)
|
||||
await manager.broadcast(
|
||||
envelope.room_id,
|
||||
{
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"room_id": str(message.room_id),
|
||||
"user_id": str(message.user_id),
|
||||
"username": user.username,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
else:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.leave_all(websocket)
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""In-memory, single-process WebSocket registry.
|
||||
|
||||
Correct for a single app-server instance only; cross-instance fan-out via
|
||||
Redis pub/sub is a later phase (ARCHITECTURE.md phase 5).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
||||
|
||||
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
self._rooms[room_id].add(websocket)
|
||||
|
||||
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
self._rooms[room_id].discard(websocket)
|
||||
if not self._rooms[room_id]:
|
||||
del self._rooms[room_id]
|
||||
|
||||
def leave_all(self, websocket: WebSocket) -> None:
|
||||
for room_id in list(self._rooms.keys()):
|
||||
self.leave(room_id, websocket)
|
||||
|
||||
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||
for websocket in list(self._rooms.get(room_id, ())):
|
||||
await websocket.send_json(payload)
|
||||
@@ -0,0 +1,37 @@
|
||||
[project]
|
||||
name = "chatapp"
|
||||
version = "0.1.0"
|
||||
description = "KeepItTalking chat service backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"sqlalchemy>=2.0.36",
|
||||
"asyncpg>=0.30",
|
||||
"alembic>=1.14",
|
||||
"pydantic>=2.9",
|
||||
"pydantic[email]>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"argon2-cffi>=23.1",
|
||||
"itsdangerous>=2.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
chatapp-create-user = "app.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL", "postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test"
|
||||
)
|
||||
os.environ.setdefault("SESSION_SECRET", "test-secret")
|
||||
os.environ.setdefault("SESSION_HTTPS_ONLY", "false")
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import get_db
|
||||
from app.main import create_app
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def apply_migrations():
|
||||
config = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
||||
command.upgrade(config, "head")
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
# Function-scoped (not session-scoped): asyncpg connections are bound to
|
||||
# the event loop they were created on, and pytest-asyncio gives each test
|
||||
# function its own loop by default. A session-scoped engine here would be
|
||||
# reused across loops and fail with asyncpg "another operation is in
|
||||
# progress" errors.
|
||||
engine = create_async_engine(TEST_DATABASE_URL)
|
||||
async with engine.connect() as conn:
|
||||
await conn.begin()
|
||||
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
|
||||
yield session
|
||||
await session.close()
|
||||
await conn.rollback()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db_session):
|
||||
application = create_app()
|
||||
|
||||
async def _get_db():
|
||||
yield db_session
|
||||
|
||||
application.dependency_overrides[get_db] = _get_db
|
||||
yield application
|
||||
application.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_client():
|
||||
# Starlette's TestClient (needed for websocket_connect, which httpx's
|
||||
# async client doesn't support) runs the ASGI app on a background thread
|
||||
# with its own event loop via anyio's BlockingPortal. asyncpg connections
|
||||
# are bound to the loop they're opened on, so this app gets its own
|
||||
# engine created here (no connections opened yet) rather than reusing
|
||||
# the `db_session`/`app` fixtures' engine, which belongs to pytest's
|
||||
# loop. No per-test rollback here (see test_ws_chat.py for the
|
||||
# unique-name convention that keeps tests independent without it).
|
||||
application = create_app()
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL)
|
||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
|
||||
async def _get_db():
|
||||
async with test_session_factory() as session:
|
||||
yield session
|
||||
|
||||
application.dependency_overrides[get_db] = _get_db
|
||||
|
||||
with TestClient(application) as tc:
|
||||
tc.session_factory = test_session_factory # type: ignore[attr-defined]
|
||||
yield tc
|
||||
|
||||
|
||||
async def register_and_login(
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
username: str = "alice",
|
||||
password: str = "password123",
|
||||
):
|
||||
# No public register endpoint (invite-only site) -- tests seed the
|
||||
# account the same way an operator would via `python -m app.cli
|
||||
# create-user`, by calling the service function directly, then log in
|
||||
# through the real endpoint to get a session cookie on `client`.
|
||||
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
|
||||
await register_user(db_session, data)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": password},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import DuplicateUserError, register_user
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
async def test_login_sets_session_and_me_returns_user(client, db_session):
|
||||
user = await register_and_login(client, db_session, username="alice")
|
||||
assert user["username"] == "alice"
|
||||
assert user["email"] == "alice@example.com"
|
||||
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == user["id"]
|
||||
|
||||
|
||||
async def test_login_wrong_password(client, db_session):
|
||||
data = UserCreate(username="erin", email="erin@example.com", password="password123")
|
||||
await register_user(db_session, data)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": "erin", "password": "wrong-password"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_login_unknown_user(client):
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": "nobody", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_me_requires_auth(client):
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_logout_clears_session(client, db_session):
|
||||
await register_and_login(client, db_session, username="frank")
|
||||
resp = await client.post("/api/auth/logout")
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = await client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_register_user_duplicate_username_conflicts(db_session):
|
||||
await register_user(
|
||||
db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
|
||||
)
|
||||
with pytest.raises(DuplicateUserError):
|
||||
await register_user(
|
||||
db_session,
|
||||
UserCreate(username="bob", email="different@example.com", password="password123"),
|
||||
)
|
||||
|
||||
|
||||
async def test_register_user_duplicate_email_conflicts(db_session):
|
||||
await register_user(
|
||||
db_session, UserCreate(username="carol", email="carol@example.com", password="password123")
|
||||
)
|
||||
with pytest.raises(DuplicateUserError):
|
||||
await register_user(
|
||||
db_session,
|
||||
UserCreate(username="different", email="carol@example.com", password="password123"),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership, RoomRole
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
async def test_create_room_requires_auth(client):
|
||||
resp = await client.post("/api/rooms", json={"name": "general"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_create_room_creates_owner_membership(client, db_session):
|
||||
user = await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms", json={"name": "general", "description": "chat"})
|
||||
assert resp.status_code == 201
|
||||
room = resp.json()
|
||||
assert room["name"] == "general"
|
||||
assert room["owner_id"] == user["id"]
|
||||
|
||||
result = await db_session.execute(
|
||||
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room["id"]))
|
||||
)
|
||||
membership = result.scalar_one()
|
||||
assert membership.user_id == uuid.UUID(user["id"])
|
||||
assert membership.role == RoomRole.owner
|
||||
|
||||
|
||||
async def test_list_rooms_excludes_private(client, db_session):
|
||||
user = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/rooms", json={"name": "open-room"})
|
||||
|
||||
private_room = Room(
|
||||
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
|
||||
)
|
||||
db_session.add(private_room)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/rooms")
|
||||
assert resp.status_code == 200
|
||||
names = {r["name"] for r in resp.json()}
|
||||
assert "open-room" in names
|
||||
assert "secret-room" not in names
|
||||
|
||||
|
||||
async def test_join_room_idempotent(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
create_resp = await client.post("/api/rooms", json={"name": "general"})
|
||||
room_id = create_resp.json()["id"]
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await register_and_login(client, db_session, username="bob")
|
||||
|
||||
resp1 = await client.post(f"/api/rooms/{room_id}/join")
|
||||
assert resp1.status_code == 200
|
||||
resp2 = await client.post(f"/api/rooms/{room_id}/join")
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
||||
async def test_join_nonexistent_room_404(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post(f"/api/rooms/{uuid.uuid4()}/join")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_join_private_room_400(client, db_session):
|
||||
user = await register_and_login(client, db_session, username="alice")
|
||||
private_room = Room(
|
||||
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
|
||||
)
|
||||
db_session.add(private_room)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(private_room)
|
||||
|
||||
resp = await client.post(f"/api/rooms/{private_room.id}/join")
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,74 @@
|
||||
import uuid
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.ws.chat import WS_UNAUTHENTICATED
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register(ws_client, username):
|
||||
# No public register endpoint (invite-only site): seed the user directly
|
||||
# via the ws_client's own session factory (see conftest.ws_client), then
|
||||
# log in through the real endpoint to get a session cookie.
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_ws_requires_auth(ws_client):
|
||||
try:
|
||||
with ws_client.websocket_connect("/ws/chat"):
|
||||
pass
|
||||
assert False, "expected the connection to be rejected"
|
||||
except WebSocketDisconnect as exc:
|
||||
assert exc.code == WS_UNAUTHENTICATED
|
||||
|
||||
|
||||
def test_ws_join_and_message_roundtrip(ws_client):
|
||||
username = _unique("alice")
|
||||
_register(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
joined = ws.receive_json()
|
||||
assert joined == {"type": "joined", "room_id": room["id"]}
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
assert message["type"] == "message"
|
||||
assert message["content"] == "hello"
|
||||
assert message["room_id"] == room["id"]
|
||||
assert message["username"] == username
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
assert resp.status_code == 200
|
||||
contents = [m["content"] for m in resp.json()]
|
||||
assert "hello" in contents
|
||||
|
||||
|
||||
def test_ws_message_without_join_errors(ws_client):
|
||||
_register(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
resp = ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
Reference in New Issue
Block a user